fix(coding-agent): expand paste markers in Ctrl+Q follow-ups

InputController.handleFollowUp read raw editor text via getText(),
bypassing the paste-store expansion the Enter path applies through
Editor.getExpandedText(). A large paste collapsed into a [Paste #N, +X
lines] marker was therefore sent verbatim to the model when queued with
Ctrl+Q / Ctrl+Enter, silently dropping the pasted content.

Switch the follow-up path to getExpandedText() so queued submissions
match the Enter path. Image markers are untouched; pendingImages
forwarding is unchanged.

Updated existing input-controller stubs (skill-queue, followup-image,
keybindings) to implement getExpandedText, matching the production
CustomEditor surface.

Fixes #3737
This commit is contained in:
roboomp
2026-06-28 16:42:39 +00:00
parent 2622ab13fe
commit 404a43e02b
6 changed files with 133 additions and 1 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed Ctrl+Q / Ctrl+Enter follow-up submissions sending the literal `[Paste #N]` marker instead of the expanded paste body ([#3737](https://github.com/can1357/oh-my-pi/issues/3737)).
## [16.2.3] - 2026-06-28
### Added
@@ -1145,7 +1145,7 @@ export class InputController {
/** Send editor text as a follow-up message (queued behind current stream). */
async handleFollowUp(): Promise<void> {
let text = this.ctx.editor.getText().trim();
let text = this.ctx.editor.getExpandedText().trim();
const images = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined;
const imageLinks =
images && this.ctx.editor.pendingImageLinks.length > 0 ? [...this.ctx.editor.pendingImageLinks] : undefined;
@@ -13,6 +13,7 @@ import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/typ
interface StubEditor {
setText: (text: string) => void;
getText: () => string;
getExpandedText: () => string;
addToHistory: (text: string) => void;
imageLinks?: unknown;
pendingImages: ImageContent[];
@@ -37,6 +38,9 @@ function createContext(opts: {
getText() {
return editorText;
},
getExpandedText() {
return editorText;
},
addToHistory: vi.fn(),
pendingImages: opts.pendingImages,
pendingImageLinks: opts.pendingImageLinks ? [...opts.pendingImageLinks] : opts.pendingImages.map(() => undefined),
@@ -0,0 +1,116 @@
/**
* Regression: queuing a follow-up message (Ctrl+Q / Ctrl+Enter → `app.message.followUp`)
* with a collapsed `[Paste #N, +X lines]` marker must expand the marker to its stored text
* before dispatch, identical to the Enter path. Previously `handleFollowUp` read raw
* `getText()` so the model received the literal marker string and the paste was silently
* dropped (issue #3737).
*/
import { afterEach, describe, expect, it, vi } from "bun:test";
import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller";
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
interface PromptOptionsLike {
streamingBehavior?: "steer" | "followUp";
}
describe("InputController.handleFollowUp paste-marker expansion", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("expands [Paste #N] markers to their stored text before dispatch while streaming", async () => {
const expanded = "line1\nline2\nline3\nline4\nline5";
let editorText = "[Paste #1, +5 lines]";
const prompt = vi.fn(async (_text: string, _options?: PromptOptionsLike) => {});
const ctx = {
editor: {
setText(text: string) {
editorText = text;
},
getText: () => editorText,
getExpandedText: () =>
editorText.replace(/\[Paste #1(?:, (?:\+\d+ lines|\d+ chars))?\]/g, expanded),
addToHistory: vi.fn(),
pendingImages: [],
pendingImageLinks: [],
clearDraft(text?: string) {
if (text !== undefined) this.addToHistory(text);
this.setText("");
},
},
ui: { requestRender: vi.fn() },
skillCommands: new Map<string, string>(),
session: {
isStreaming: true,
isCompacting: false,
isBashRunning: false,
isEvalRunning: false,
extensionRunner: undefined,
prompt,
},
loopModeEnabled: false,
compactionQueuedMessages: [],
locallySubmittedUserSignatures: new Set<string>(),
updatePendingMessagesDisplay: vi.fn(),
showError: vi.fn(),
withLocalSubmission: async (_text: string, fn: () => unknown) => fn(),
} as unknown as InteractiveModeContext;
await new InputController(ctx).handleFollowUp();
expect(prompt).toHaveBeenCalledTimes(1);
const call = prompt.mock.calls[0];
if (!call) throw new Error("expected session.prompt to be called");
expect(call[0]).toBe(expanded);
expect(call[0]).not.toContain("[Paste #");
expect(call[1]?.streamingBehavior).toBe("followUp");
});
it("expands [Paste #N] markers when idle (not streaming)", async () => {
const expanded = "queued-paste-body\nspans multiple lines";
let editorText = "[Paste #2, +2 lines]";
const prompt = vi.fn(async (_text: string, _options?: PromptOptionsLike) => {});
const ctx = {
editor: {
setText(text: string) {
editorText = text;
},
getText: () => editorText,
getExpandedText: () =>
editorText.replace(/\[Paste #2(?:, (?:\+\d+ lines|\d+ chars))?\]/g, expanded),
addToHistory: vi.fn(),
pendingImages: [],
pendingImageLinks: [],
clearDraft(text?: string) {
if (text !== undefined) this.addToHistory(text);
this.setText("");
},
},
ui: { requestRender: vi.fn() },
skillCommands: new Map<string, string>(),
session: {
isStreaming: false,
isCompacting: false,
isBashRunning: false,
isEvalRunning: false,
extensionRunner: undefined,
prompt,
},
loopModeEnabled: false,
compactionQueuedMessages: [],
locallySubmittedUserSignatures: new Set<string>(),
updatePendingMessagesDisplay: vi.fn(),
showError: vi.fn(),
withLocalSubmission: async (_text: string, fn: () => unknown) => fn(),
} as unknown as InteractiveModeContext;
await new InputController(ctx).handleFollowUp();
expect(prompt).toHaveBeenCalledTimes(1);
const call = prompt.mock.calls[0];
if (!call) throw new Error("expected session.prompt to be called");
expect(call[0]).toBe(expanded);
expect(call[0]).not.toContain("[Paste #");
expect(call[1]?.streamingBehavior).toBeUndefined();
});
});
@@ -26,6 +26,7 @@ type FakeEditor = {
onSubmit?: (text: string) => Promise<void>;
setText(text: string): void;
getText(): string;
getExpandedText(): string;
addToHistory(text: string): void;
setActionKeys(action: string, keys: string[]): void;
setCustomKeyHandler(key: string, handler: () => void): void;
@@ -105,6 +106,9 @@ async function createContext() {
getText() {
return editorText;
},
getExpandedText() {
return editorText;
},
addToHistory: vi.fn(),
pasteText(text: string) {
editorText += text;
@@ -27,6 +27,7 @@ import { TempDir } from "@oh-my-pi/pi-utils";
type StubEditor = {
setText: (text: string) => void;
getText: () => string;
getExpandedText: () => string;
clearDraft: (historyText?: string) => void;
addToHistory: Mock<(...args: unknown[]) => unknown>;
onSubmit?: (text: string) => Promise<void>;
@@ -67,6 +68,9 @@ function createStubInputControllerContext(opts: {
getText() {
return editorText;
},
getExpandedText() {
return editorText;
},
clearDraft(historyText?: string) {
if (historyText !== undefined) this.addToHistory(historyText);
this.setText("");