diff --git a/desktop/src/features/terminal/TerminalSubstrate.test.mjs b/desktop/src/features/terminal/TerminalSubstrate.test.mjs index d6113cbf08..f7b1dfa794 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.test.mjs +++ b/desktop/src/features/terminal/TerminalSubstrate.test.mjs @@ -823,6 +823,105 @@ test("control chords keep reaching the PTY instead of the tab layer", async () = ); }); +test("Windows paste chords reach paste instead of sending ^V", async (t) => { + Object.defineProperty(dom.window.navigator, "platform", { + configurable: true, + value: "Win32", + }); + t.after(() => { + Object.defineProperty(dom.window.navigator, "platform", { + configurable: true, + value: "MacIntel", + }); + }); + + const subject = await revealed({ bracketedPaste: true }); + const input = subject.view.getByLabelText("Terminal input"); + const keyDown = (shiftKey) => { + const event = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + code: "KeyV", + ctrlKey: true, + key: "v", + shiftKey, + }); + act(() => input.dispatchEvent(event)); + return event; + }; + + const plain = keyDown(false); + assert.equal(plain.defaultPrevented, false); + assert.deepEqual(subject.calls.input, [], "Ctrl+V must not send ^V"); + fireEvent.paste(input, { + clipboardData: { getData: () => "first" }, + }); + + const shifted = keyDown(true); + assert.equal(shifted.defaultPrevented, false); + assert.deepEqual( + subject.calls.input, + ["\u001b[200~first\u001b[201~"], + "Ctrl+Shift+V must not send ^V", + ); + fireEvent.paste(input, { + clipboardData: { getData: () => "second" }, + }); + + assert.deepEqual(subject.calls.input, [ + "\u001b[200~first\u001b[201~", + "\u001b[200~second\u001b[201~", + ]); +}); + +test("Linux keeps Ctrl+V quote-next and lets Ctrl+Shift+V paste", async (t) => { + Object.defineProperty(dom.window.navigator, "platform", { + configurable: true, + value: "Linux x86_64", + }); + t.after(() => { + Object.defineProperty(dom.window.navigator, "platform", { + configurable: true, + value: "MacIntel", + }); + }); + + const subject = await revealed({ bracketedPaste: true }); + const input = subject.view.getByLabelText("Terminal input"); + const keyDown = (shiftKey) => { + const event = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + code: "KeyV", + ctrlKey: true, + key: "v", + shiftKey, + }); + act(() => input.dispatchEvent(event)); + return event; + }; + + const quoteNext = keyDown(false); + assert.equal(quoteNext.defaultPrevented, true); + assert.deepEqual(subject.calls.input, ["\u0016"]); + + const pasteChord = keyDown(true); + assert.equal(pasteChord.defaultPrevented, false); + assert.deepEqual( + subject.calls.input, + ["\u0016"], + "Ctrl+Shift+V must wait for the paste event", + ); + fireEvent.paste(input, { + clipboardData: { getData: () => "from Linux" }, + }); + + assert.deepEqual(subject.calls.input, [ + "\u0016", + "\u001b[200~from Linux\u001b[201~", + ]); +}); + test("⌘W on an already-closing tab does not re-fire close", async () => { const subject = await revealed({ sessions: [ diff --git a/desktop/src/features/terminal/TerminalSubstrate.tsx b/desktop/src/features/terminal/TerminalSubstrate.tsx index d8aaee2e2d..f8b6b8ffd9 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.tsx +++ b/desktop/src/features/terminal/TerminalSubstrate.tsx @@ -3,12 +3,13 @@ import { ChevronRight, Maximize2, Minimize2, Plus, X } from "lucide-react"; import { useTheme } from "@/shared/theme/ThemeProvider"; import { cn } from "@/shared/lib/cn"; -import { isMacPlatform } from "@/shared/lib/platform"; +import { isLinuxPlatform, isMacPlatform } from "@/shared/lib/platform"; import { INITIAL_HANDOFF_STATE, accumulateScrollLines, encodePaste, encodeTerminalKey, + isTerminalPasteChord, matchTabChord, reduceHandoff, stepSession, @@ -785,6 +786,12 @@ export function TerminalSubstrate({ return; } if (isToggleChord(event.nativeEvent)) return; + const pastePlatform = isMacPlatform() + ? "mac" + : isLinuxPlatform() + ? "linux" + : "windows"; + if (isTerminalPasteChord(event.nativeEvent, pastePlatform)) return; const encoded = encodeTerminalKey(event); if (encoded) { event.preventDefault(); diff --git a/desktop/src/features/terminal/terminalState.test.mjs b/desktop/src/features/terminal/terminalState.test.mjs index dded3e5128..4da25ff67f 100644 --- a/desktop/src/features/terminal/terminalState.test.mjs +++ b/desktop/src/features/terminal/terminalState.test.mjs @@ -6,6 +6,7 @@ import { accumulateScrollLines, encodePaste, encodeTerminalKey, + isTerminalPasteChord, matchTabChord, reduceHandoff, stepSession, @@ -86,6 +87,37 @@ test("bracketed paste wraps the entire payload exactly once", () => { assert.equal(encodePaste("a\nb", true), "\u001b[200~a\nb\u001b[201~"); }); +test("paste chords preserve Windows and Linux terminal conventions", () => { + const chord = (overrides = {}) => ({ + altKey: false, + code: "KeyV", + ctrlKey: true, + key: "v", + metaKey: false, + shiftKey: false, + ...overrides, + }); + + assert.equal(isTerminalPasteChord(chord(), "windows"), true); + assert.equal( + isTerminalPasteChord(chord({ shiftKey: true }), "windows"), + true, + ); + assert.equal(isTerminalPasteChord(chord(), "linux"), false); + assert.equal(isTerminalPasteChord(chord({ shiftKey: true }), "linux"), true); + assert.equal( + isTerminalPasteChord(chord({ ctrlKey: false, metaKey: true }), "mac"), + true, + ); + assert.equal(isTerminalPasteChord(chord({ altKey: true }), "windows"), false); + assert.equal( + isTerminalPasteChord(chord({ code: "Period", key: "v" }), "windows"), + true, + "matching follows the typed key across keyboard layouts", + ); + assert.equal(isTerminalPasteChord(chord({ key: "c" }), "windows"), false); +}); + test("pixel scrolling retains fractional lines in both directions", () => { const state = { remainderPx: 0 }; let result = accumulateScrollLines(state, 3, 10); diff --git a/desktop/src/features/terminal/terminalState.ts b/desktop/src/features/terminal/terminalState.ts index 5598b3860a..419b07a164 100644 --- a/desktop/src/features/terminal/terminalState.ts +++ b/desktop/src/features/terminal/terminalState.ts @@ -93,6 +93,33 @@ export function encodeTerminalKey(event: { return null; } +export type TerminalPastePlatform = "linux" | "mac" | "windows"; + +/** + * Keep the platform's paste chords out of `encodeTerminalKey` so the focused + * textarea can emit its synchronous `paste` event. In particular, Windows + * treats both Ctrl+V and Ctrl+Shift+V as paste; encoding them first would turn + * them into the control byte ^V and `preventDefault()` the clipboard event. + * Linux retains the shell's traditional Ctrl+V quote-next byte and reserves + * Ctrl+Shift+V for paste. + */ +export function isTerminalPasteChord( + event: { + altKey: boolean; + code: string; + ctrlKey: boolean; + key: string; + metaKey: boolean; + shiftKey: boolean; + }, + platform: TerminalPastePlatform, +): boolean { + if (event.altKey || event.key.toLowerCase() !== "v") return false; + if (platform === "mac") return event.metaKey && !event.ctrlKey; + if (!event.ctrlKey || event.metaKey) return false; + return platform === "windows" || event.shiftKey; +} + export type TabChord = "close" | "new" | "next" | "previous"; /**