diff --git a/frontend/src/renderer/components/XtermTerminal.test.tsx b/frontend/src/renderer/components/XtermTerminal.test.tsx index 22757893b..3787148c8 100644 --- a/frontend/src/renderer/components/XtermTerminal.test.tsx +++ b/frontend/src/renderer/components/XtermTerminal.test.tsx @@ -277,16 +277,54 @@ describe("XtermTerminal", () => { expect(window.ao!.clipboard.writeText).not.toHaveBeenCalled(); }); - it("pastes from the Electron clipboard on Windows/Linux paste shortcuts", async () => { + it.each(["Linux x86_64", "Win32"])( + "pastes once from the Electron clipboard on Ctrl+Shift+V for %s", + async (platform) => { + setNavigatorPlatform(platform); + const onInput = vi.fn(); + window.ao!.clipboard.readText = vi.fn().mockResolvedValue("hello\nworld"); + const { container } = render( + terminal.onUserInput(onInput)} />, + ); + + const event = { + key: "v", + metaKey: false, + ctrlKey: true, + shiftKey: true, + altKey: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as KeyboardEvent; + const allowed = state.lastTerminal!.keyHandler!(event); + const pasteEvent = new Event("paste", { bubbles: true, cancelable: true }) as ClipboardEvent; + Object.defineProperty(pasteEvent, "clipboardData", { + value: { getData: vi.fn().mockReturnValue("native paste") }, + }); + container.firstElementChild!.dispatchEvent(pasteEvent); + await Promise.resolve(); + + expect(allowed).toBe(false); + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + expect(window.ao!.clipboard.readText).toHaveBeenCalledTimes(1); + expect(pasteEvent.defaultPrevented).toBe(true); + expect(onInput).toHaveBeenCalledTimes(1); + expect(onInput).toHaveBeenCalledWith("hello\rworld", "paste"); + }, + ); + + it("supports plain Ctrl+V paste on Windows", async () => { + setNavigatorPlatform("Win32"); const onInput = vi.fn(); - window.ao!.clipboard.readText = vi.fn().mockResolvedValue("hello\nworld"); + window.ao!.clipboard.readText = vi.fn().mockResolvedValue("windows paste"); render( terminal.onUserInput(onInput)} />); const event = { key: "v", metaKey: false, ctrlKey: true, - shiftKey: true, + shiftKey: false, altKey: false, preventDefault: vi.fn(), stopPropagation: vi.fn(), @@ -298,7 +336,36 @@ describe("XtermTerminal", () => { expect(event.preventDefault).toHaveBeenCalled(); expect(event.stopPropagation).toHaveBeenCalled(); expect(window.ao!.clipboard.readText).toHaveBeenCalled(); - expect(onInput).toHaveBeenCalledWith("hello\rworld", "paste"); + expect(onInput).toHaveBeenCalledWith("windows paste", "paste"); + }); + + it("suppresses a queued native paste event after a handled paste shortcut", async () => { + const onInput = vi.fn(); + window.ao!.clipboard.readText = vi.fn().mockResolvedValue("shortcut paste"); + const { container } = render( terminal.onUserInput(onInput)} />); + + const event = { + key: "v", + metaKey: false, + ctrlKey: true, + shiftKey: true, + altKey: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as KeyboardEvent; + expect(state.lastTerminal!.keyHandler!(event)).toBe(false); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + + const pasteEvent = new Event("paste", { bubbles: true, cancelable: true }) as ClipboardEvent; + Object.defineProperty(pasteEvent, "clipboardData", { + value: { getData: vi.fn().mockReturnValue("native paste") }, + }); + container.firstElementChild!.dispatchEvent(pasteEvent); + await Promise.resolve(); + + expect(pasteEvent.defaultPrevented).toBe(true); + expect(onInput).toHaveBeenCalledTimes(1); + expect(onInput).toHaveBeenCalledWith("shortcut paste", "paste"); }); it("supports classic Windows terminal copy and paste shortcuts", async () => { diff --git a/frontend/src/renderer/components/XtermTerminal.tsx b/frontend/src/renderer/components/XtermTerminal.tsx index b4fe2908e..625e80fa5 100644 --- a/frontend/src/renderer/components/XtermTerminal.tsx +++ b/frontend/src/renderer/components/XtermTerminal.tsx @@ -68,6 +68,7 @@ function loadRenderer(term: Terminal): void { // xterm palette tracks the app theme (see lib/terminal-themes.ts + --term-* in // styles.css). The PTY content is still the agent's own ANSI output. const terminalThemes = buildTerminalThemes(); +const SUPPRESS_NATIVE_PASTE_MS = 100; // Erase scrollback (3J) + display (2J) and home the cursor — yyork's // terminalResetSequence. Deliberately NOT term.reset(): every pane PTY is a @@ -102,7 +103,8 @@ function isTerminalPasteShortcut(event: KeyboardEvent): boolean { if (event.key === "Insert") return event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey; if (event.key.toLowerCase() !== "v") return false; if (event.metaKey) return true; - return event.ctrlKey && event.shiftKey; + if (event.ctrlKey && event.shiftKey && !event.altKey) return true; + return isWindowsPlatform() && event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey; } function consumeTerminalShortcut(event: KeyboardEvent): void { @@ -298,6 +300,20 @@ export function XtermTerminal(props: XtermTerminalProps) { const bracketed = term.modes.bracketedPasteMode && term.options.ignoreBracketedPasteMode !== true; emitUserInput(bracketPastedText(prepared, bracketed), "paste"); }; + let suppressNextNativePaste = false; + let suppressPasteTimer: number | null = null; + const clearSuppressNativePaste = () => { + suppressNextNativePaste = false; + if (suppressPasteTimer !== null) { + window.clearTimeout(suppressPasteTimer); + suppressPasteTimer = null; + } + }; + const suppressNativePasteOnce = () => { + suppressNextNativePaste = true; + if (suppressPasteTimer !== null) window.clearTimeout(suppressPasteTimer); + suppressPasteTimer = window.setTimeout(clearSuppressNativePaste, SUPPRESS_NATIVE_PASTE_MS); + }; const pasteFromClipboard = () => { void aoBridge.clipboard .readText() @@ -320,6 +336,7 @@ export function XtermTerminal(props: XtermTerminalProps) { } if (isTerminalPasteShortcut(event)) { consumeTerminalShortcut(event); + suppressNativePasteOnce(); pasteFromClipboard(); return false; } @@ -468,6 +485,10 @@ export function XtermTerminal(props: XtermTerminalProps) { const pasteInput = (event: ClipboardEvent) => { event.preventDefault(); event.stopPropagation(); + if (suppressNextNativePaste) { + clearSuppressNativePaste(); + return; + } const text = event.clipboardData?.getData("text/plain") ?? ""; pasteText(text); }; @@ -510,6 +531,7 @@ export function XtermTerminal(props: XtermTerminalProps) { selectionChange.dispose(); host.removeEventListener("paste", pasteInput, true); host.removeEventListener("compositionend", compositionInput, true); + clearSuppressNativePaste(); keyInput.dispose(); userInputListeners.clear(); try {