diff --git a/apps/web/src/components/features/terminal/terminal.test.ts b/apps/web/src/components/features/terminal/terminal.test.ts index 86c9e4e..3f97022 100644 --- a/apps/web/src/components/features/terminal/terminal.test.ts +++ b/apps/web/src/components/features/terminal/terminal.test.ts @@ -4,6 +4,7 @@ import { getTerminalScrollbackLimit, isCurrentWebSocket, shouldRetryWebSocketClose, + shouldCopyTerminalSelection, } from "./terminal.tsx"; describe("getTerminalScrollbackLimit", () => { @@ -30,3 +31,29 @@ describe("getTerminalScrollbackLimit", () => { ); }); }); + +describe("shouldCopyTerminalSelection", () => { + it("copies a selected terminal region with Ctrl+C or Cmd+C", () => { + expect( + shouldCopyTerminalSelection( + { ctrlKey: true, metaKey: false, key: "c" }, + true, + ), + ).toBe(true); + expect( + shouldCopyTerminalSelection( + { ctrlKey: false, metaKey: true, key: "C" }, + true, + ), + ).toBe(true); + }); + + it("keeps Ctrl+C as a terminal interrupt without a selection", () => { + expect( + shouldCopyTerminalSelection( + { ctrlKey: true, metaKey: false, 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 b9aa5f1..004a178 100644 --- a/apps/web/src/components/features/terminal/terminal.tsx +++ b/apps/web/src/components/features/terminal/terminal.tsx @@ -68,6 +68,17 @@ export function shouldRetryWebSocketClose(code: number, reason: string): boolean return code !== 1000 && !(code === 4000 && reason === "New connection established"); } +export function shouldCopyTerminalSelection( + event: Pick, + hasSelection: boolean, +): boolean { + return ( + hasSelection && + (event.ctrlKey || event.metaKey) && + event.key.toLowerCase() === "c" + ); +} + function matchesByteSequence( data: Uint8Array, start: number, @@ -462,8 +473,36 @@ export const TerminalComponent = React.forwardRef( term.paste(text); }; + const handleBrowserCopy = (event: ClipboardEvent) => { + const selection = term.getSelection(); + if (!selection) return; + + event.preventDefault(); + event.clipboardData?.setData("text/plain", selection); + }; + 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"); + } + }; + term.attachCustomKeyEventHandler((event) => { + if (!shouldCopyTerminalSelection(event, term.hasSelection())) { + return true; + } + + event.preventDefault(); + copySelection(); + return false; + }); + const handleBrowserPaste = (event: ClipboardEvent) => { - if (!bracketedPasteEnabledRef.current) return; const text = event.clipboardData?.getData("text/plain"); if (text === undefined || wsRef.current?.readyState !== WebSocket.OPEN) { return; @@ -473,6 +512,7 @@ export const TerminalComponent = React.forwardRef( event.stopImmediatePropagation(); pasteTextRef.current(text); }; + container.addEventListener("copy", handleBrowserCopy, true); container.addEventListener("paste", handleBrowserPaste, true); // Mobile touch scroll. @@ -720,6 +760,7 @@ export const TerminalComponent = React.forwardRef( handleVisibilityChange, ); if (touchCleanup) touchCleanup(); + container.removeEventListener("copy", handleBrowserCopy, 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 new file mode 100644 index 0000000..ed1f7f5 --- /dev/null +++ b/openspec/changes/fix-web-terminal-clipboard/change.md @@ -0,0 +1,12 @@ +# Fix Web Terminal Clipboard + +## Problem + +Users cannot reliably copy terminal output from the browser terminal. Browser copy shortcuts may be forwarded to the terminal as input instead of copying the xterm selection, and paste behavior differs by bracketed-paste mode. + +## 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. +- 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. diff --git a/openspec/changes/fix-web-terminal-clipboard/tasks.md b/openspec/changes/fix-web-terminal-clipboard/tasks.md new file mode 100644 index 0000000..cf44e6a --- /dev/null +++ b/openspec/changes/fix-web-terminal-clipboard/tasks.md @@ -0,0 +1,6 @@ +# Web Terminal Clipboard Tasks + +- [x] Add selected-text copy interception without suppressing unselected terminal interrupts. +- [x] Normalize browser paste handling through the existing paste transport. +- [x] Add focused frontend coverage for copy shortcut decisions. +- [x] Run frontend tests, typecheck/build, lint, and diagnostics.