diff --git a/apps/web/src/components/features/terminal/terminal.test.ts b/apps/web/src/components/features/terminal/terminal.test.ts index 3f97022..bf27341 100644 --- a/apps/web/src/components/features/terminal/terminal.test.ts +++ b/apps/web/src/components/features/terminal/terminal.test.ts @@ -33,25 +33,28 @@ describe("getTerminalScrollbackLimit", () => { }); describe("shouldCopyTerminalSelection", () => { - it("copies a selected terminal region with Ctrl+C or Cmd+C", () => { + it("copies a selected terminal region with Ctrl+Shift+C", () => { expect( shouldCopyTerminalSelection( - { ctrlKey: true, metaKey: false, key: "c" }, - true, - ), - ).toBe(true); - expect( - shouldCopyTerminalSelection( - { ctrlKey: false, metaKey: true, key: "C" }, + { ctrlKey: true, shiftKey: true, key: "c" }, true, ), ).toBe(true); }); - it("keeps Ctrl+C as a terminal interrupt without a selection", () => { + it("keeps Ctrl+C as a terminal interrupt", () => { expect( shouldCopyTerminalSelection( - { ctrlKey: true, metaKey: false, key: "c" }, + { ctrlKey: true, shiftKey: false, key: "c" }, + true, + ), + ).toBe(false); + }); + + it("does not copy without a selection", () => { + expect( + shouldCopyTerminalSelection( + { ctrlKey: true, shiftKey: true, key: "c" }, false, ), ).toBe(false); diff --git a/apps/web/src/components/features/terminal/terminal.tsx b/apps/web/src/components/features/terminal/terminal.tsx index 004a178..9d5a28f 100644 --- a/apps/web/src/components/features/terminal/terminal.tsx +++ b/apps/web/src/components/features/terminal/terminal.tsx @@ -69,12 +69,13 @@ export function shouldRetryWebSocketClose(code: number, reason: string): boolean } export function shouldCopyTerminalSelection( - event: Pick, + event: Pick, hasSelection: boolean, ): boolean { return ( hasSelection && - (event.ctrlKey || event.metaKey) && + event.ctrlKey && + event.shiftKey && event.key.toLowerCase() === "c" ); } @@ -483,24 +484,26 @@ export const TerminalComponent = React.forwardRef( const copySelection = () => { const selection = term.getSelection(); if (!selection) return; - const clipboard = navigator.clipboard; - if (clipboard) { - void clipboard.writeText(selection).catch(() => { - document.execCommand("copy"); - }); - } else { - document.execCommand("copy"); - } + + const textarea = document.createElement("textarea"); + textarea.value = selection; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + textarea.remove(); }; - term.attachCustomKeyEventHandler((event) => { + const handleBrowserCopyShortcut = (event: KeyboardEvent) => { if (!shouldCopyTerminalSelection(event, term.hasSelection())) { - return true; + return; } event.preventDefault(); + event.stopPropagation(); copySelection(); - return false; - }); + }; const handleBrowserPaste = (event: ClipboardEvent) => { const text = event.clipboardData?.getData("text/plain"); @@ -513,6 +516,7 @@ export const TerminalComponent = React.forwardRef( pasteTextRef.current(text); }; container.addEventListener("copy", handleBrowserCopy, true); + container.addEventListener("keydown", handleBrowserCopyShortcut, true); container.addEventListener("paste", handleBrowserPaste, true); // Mobile touch scroll. @@ -761,6 +765,11 @@ export const TerminalComponent = React.forwardRef( ); if (touchCleanup) touchCleanup(); container.removeEventListener("copy", handleBrowserCopy, true); + container.removeEventListener( + "keydown", + handleBrowserCopyShortcut, + true, + ); container.removeEventListener("paste", handleBrowserPaste, true); pasteTextRef.current = () => {}; bracketedPasteEnabledRef.current = false; diff --git a/openspec/changes/fix-web-terminal-clipboard/change.md b/openspec/changes/fix-web-terminal-clipboard/change.md index ed1f7f5..d34dca1 100644 --- a/openspec/changes/fix-web-terminal-clipboard/change.md +++ b/openspec/changes/fix-web-terminal-clipboard/change.md @@ -6,7 +6,8 @@ Users cannot reliably copy terminal output from the browser terminal. Browser co ## Required behavior -- Copying a selected terminal region with standard browser shortcuts (`Ctrl/Cmd+C`) copies text to the system clipboard without sending an interrupt to the terminal. +- Copying a selected terminal region with `Ctrl+Shift+C` copies text to the system clipboard without sending input to the terminal. +- `Ctrl+C` remains a terminal interrupt, including when output is selected. - Copy requests expose the xterm selection as plain text. - Pasting plain text is handled once and follows bracketed-paste mode when enabled. - Normal terminal interrupts still work when there is no active selection.