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:
@@ -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("");
|
||||
|
||||
Reference in New Issue
Block a user