diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 08467497d..268a7fa85 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2958,7 +2958,7 @@ export class InteractiveMode { this.editorComponentFactory = factory; // Save text from current editor before switching - const currentText = this.editor.getText(); + const currentText = this.editor.getExpandedText?.() ?? this.editor.getText(); // An open extension dialog keeps the editor slot; the new editor is shown when the last dialog closes. const dialogOpen = this.extensionDialogs.length > 0; @@ -3063,7 +3063,7 @@ export class InteractiveMode { blocked?: BlockedStatus; }, ): Promise { - const savedText = this.editor.getText(); + const savedText = this.editor.getExpandedText?.() ?? this.editor.getText(); const isOverlay = options?.overlay ?? false; return new Promise((resolve, reject) => { @@ -4880,7 +4880,7 @@ export class InteractiveMode { return 0; } const queuedText = allQueued.join("\n\n"); - const currentText = options?.currentText ?? this.editor.getText(); + const currentText = options?.currentText ?? this.editor.getExpandedText?.() ?? this.editor.getText(); const combinedText = [queuedText, currentText].filter((t) => t.trim()).join("\n\n"); this.editor.setText(combinedText); this.updatePendingMessagesDisplay(); diff --git a/packages/coding-agent/test/interactive-mode-paste.test.ts b/packages/coding-agent/test/interactive-mode-paste.test.ts new file mode 100644 index 000000000..4e6b04d2e --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-paste.test.ts @@ -0,0 +1,162 @@ +import { + type Component, + Container, + Editor, + type EditorComponent, + getKeybindings, + setKeybindings, + type TUI, + TuiMainScreen, +} from "@earendil-works/pi-tui"; +import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts"; +import type { EditorFactory } from "../src/core/extensions/types.ts"; +import { KeybindingsManager } from "../src/core/keybindings.ts"; +import { CustomEditor } from "../src/modes/interactive/components/custom-editor.ts"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; +import { getEditorTheme, initTheme, type Theme } from "../src/modes/interactive/theme/theme.ts"; + +// Pastes over 10 lines collapse into a marker whose content only the receiving editor can expand. +const COLLAPSED_PASTE_LINE_COUNT = 12; + +function pastedText(label: string): string { + return Array.from({ length: COLLAPSED_PASTE_LINE_COUNT }, (_, i) => `${label} ${i + 1}`).join("\n"); +} + +function paste(editor: EditorComponent, text: string): void { + editor.handleInput(`\x1b[200~${text}\x1b[201~`); + // Without a collapsed marker, the transition under test would copy plain text and prove nothing. + expect(editor.getText()).not.toBe(text); +} + +function submit(editor: EditorComponent): string | undefined { + let submitted: string | undefined; + editor.onSubmit = (text) => { + submitted = text; + }; + editor.handleInput("\r"); + return submitted; +} + +type CustomUiFactory = ( + tui: TUI, + theme: Theme, + keybindings: KeybindingsManager, + done: (result: string) => void, +) => Component | Promise; + +// Regression tests for #9809. +describe("InteractiveMode keeps collapsed paste content when it moves a draft", () => { + let keybindings: KeybindingsManager; + let previousKeybindings: ReturnType; + let ui: TUI; + + beforeAll(() => initTheme("dark")); + beforeEach(() => { + previousKeybindings = getKeybindings(); + keybindings = new KeybindingsManager(); + setKeybindings(keybindings); + ui = new TuiMainScreen(new VirtualTerminal()); + }); + afterEach(() => setKeybindings(previousKeybindings)); + + test("between the default and a replacement editor", () => { + const defaultEditor = new CustomEditor(ui, getEditorTheme(), keybindings); + const editor: EditorComponent = defaultEditor; + const host = { + ui, + keybindings, + defaultEditor, + editor, + editorContainer: new Container(), + extensionDialogs: [], + disposeActiveSelector() {}, + }; + const setCustomEditorComponent = Reflect.get(InteractiveMode.prototype, "setCustomEditorComponent") as ( + this: typeof host, + factory: EditorFactory | undefined, + ) => void; + + const intoReplacement = pastedText("into replacement"); + paste(host.editor, intoReplacement); + setCustomEditorComponent.call(host, (tui, theme) => new Editor(tui, theme)); + expect(submit(host.editor)).toBe(intoReplacement); + + const backToDefault = pastedText("back to default"); + paste(host.editor, backToDefault); + setCustomEditorComponent.call(host, undefined); + expect(submit(host.editor)).toBe(backToDefault); + }); + + describe("around non-overlay custom UI", () => { + function createHost() { + return { + ui, + keybindings, + editor: new Editor(ui, getEditorTheme()), + editorContainer: new Container(), + extensionDialogs: [], + programStatus: { setBlocked() {} }, + disposeActiveSelector() {}, + pushExtensionDialog: Reflect.get(InteractiveMode.prototype, "pushExtensionDialog"), + removeExtensionDialog: Reflect.get(InteractiveMode.prototype, "removeExtensionDialog"), + showTopExtensionDialog: Reflect.get(InteractiveMode.prototype, "showTopExtensionDialog"), + }; + } + const showExtensionCustom = Reflect.get(InteractiveMode.prototype, "showExtensionCustom") as ( + this: ReturnType, + factory: CustomUiFactory, + ) => Promise; + + test("after it closes", async () => { + const host = createHost(); + const draft = pastedText("draft"); + paste(host.editor, draft); + const component: Component = { render: () => [], invalidate() {} }; + let close = (_result: string): void => { + throw new Error("custom UI factory was not called"); + }; + + const shown = showExtensionCustom.call(host, (_tui, _theme, _keybindings, done) => { + close = done; + return component; + }); + await vi.waitFor(() => expect(host.editorContainer.children[0]).toBe(component)); + close("closed"); + await shown; + + expect(submit(host.editor)).toBe(draft); + }); + + test("after it fails to open", async () => { + const host = createHost(); + const draft = pastedText("draft"); + paste(host.editor, draft); + + const shown = showExtensionCustom.call(host, async () => { + throw new Error("factory failed"); + }); + await expect(shown).rejects.toThrow("factory failed"); + + expect(submit(host.editor)).toBe(draft); + }); + }); + + test("when queued messages are restored above it", () => { + const editor = new Editor(ui, getEditorTheme()); + const host = { + editor, + clearAllQueues: () => ({ steering: [], followUp: ["queued follow-up"] }), + updatePendingMessagesDisplay() {}, + }; + const restoreQueuedMessagesToEditor = Reflect.get(InteractiveMode.prototype, "restoreQueuedMessagesToEditor") as ( + this: typeof host, + ) => number; + + const draft = pastedText("draft"); + paste(editor, draft); + restoreQueuedMessagesToEditor.call(host); + + expect(submit(editor)).toBe(`queued follow-up\n\n${draft}`); + }); +});