From b289edf5a97023817e3134e5df9e2b398eaece98 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Wed, 22 Jul 2026 14:15:46 +0200 Subject: [PATCH] fix(terminal): add right click copy action Replace the conflicting keyboard copy shortcut with an xterm-aware Copy action shown for selected output. --- .../features/terminal/terminal.test.ts | 31 ++------ .../components/features/terminal/terminal.tsx | 79 +++++++++++-------- .../fix-web-terminal-clipboard/change.md | 5 +- 3 files changed, 55 insertions(+), 60 deletions(-) diff --git a/apps/web/src/components/features/terminal/terminal.test.ts b/apps/web/src/components/features/terminal/terminal.test.ts index bf27341..5668527 100644 --- a/apps/web/src/components/features/terminal/terminal.test.ts +++ b/apps/web/src/components/features/terminal/terminal.test.ts @@ -4,7 +4,7 @@ import { getTerminalScrollbackLimit, isCurrentWebSocket, shouldRetryWebSocketClose, - shouldCopyTerminalSelection, + shouldShowTerminalCopyMenu, } from "./terminal.tsx"; describe("getTerminalScrollbackLimit", () => { @@ -32,31 +32,12 @@ describe("getTerminalScrollbackLimit", () => { }); }); -describe("shouldCopyTerminalSelection", () => { - it("copies a selected terminal region with Ctrl+Shift+C", () => { - expect( - shouldCopyTerminalSelection( - { ctrlKey: true, shiftKey: true, key: "c" }, - true, - ), - ).toBe(true); +describe("shouldShowTerminalCopyMenu", () => { + it("shows Copy for selected terminal output", () => { + expect(shouldShowTerminalCopyMenu("selected output")).toBe(true); }); - it("keeps Ctrl+C as a terminal interrupt", () => { - expect( - shouldCopyTerminalSelection( - { 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); + it("keeps the native context menu when no output is selected", () => { + expect(shouldShowTerminalCopyMenu("")).toBe(false); }); }); diff --git a/apps/web/src/components/features/terminal/terminal.tsx b/apps/web/src/components/features/terminal/terminal.tsx index 9d5a28f..3006733 100644 --- a/apps/web/src/components/features/terminal/terminal.tsx +++ b/apps/web/src/components/features/terminal/terminal.tsx @@ -68,16 +68,20 @@ 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.shiftKey && - event.key.toLowerCase() === "c" - ); +export function shouldShowTerminalCopyMenu(selection: string): boolean { + return selection.length > 0; +} + +function copyTextWithFallback(text: string): void { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + textarea.remove(); } function matchesByteSequence( @@ -119,6 +123,11 @@ export const TerminalComponent = React.forwardRef( >("connecting"); const [error, setError] = useState(null); const [showResetConfirm, setShowResetConfirm] = useState(false); + const [copyMenu, setCopyMenu] = useState<{ + x: number; + y: number; + text: string; + } | null>(null); const activeModifierRef = useRef(activeModifier); activeModifierRef.current = activeModifier; const [fontSize, setFontSize] = useState(() => { @@ -481,28 +490,15 @@ export const TerminalComponent = React.forwardRef( event.preventDefault(); event.clipboardData?.setData("text/plain", selection); }; - const copySelection = () => { + const handleTerminalContextMenu = (event: MouseEvent) => { const selection = term.getSelection(); - if (!selection) return; - - 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(); - }; - const handleBrowserCopyShortcut = (event: KeyboardEvent) => { - if (!shouldCopyTerminalSelection(event, term.hasSelection())) { + if (!shouldShowTerminalCopyMenu(selection)) { + setCopyMenu(null); return; } event.preventDefault(); - event.stopPropagation(); - copySelection(); + setCopyMenu({ x: event.clientX, y: event.clientY, text: selection }); }; const handleBrowserPaste = (event: ClipboardEvent) => { @@ -516,7 +512,7 @@ export const TerminalComponent = React.forwardRef( pasteTextRef.current(text); }; container.addEventListener("copy", handleBrowserCopy, true); - container.addEventListener("keydown", handleBrowserCopyShortcut, true); + container.addEventListener("contextmenu", handleTerminalContextMenu); container.addEventListener("paste", handleBrowserPaste, true); // Mobile touch scroll. @@ -765,11 +761,7 @@ export const TerminalComponent = React.forwardRef( ); if (touchCleanup) touchCleanup(); container.removeEventListener("copy", handleBrowserCopy, true); - container.removeEventListener( - "keydown", - handleBrowserCopyShortcut, - true, - ); + container.removeEventListener("contextmenu", handleTerminalContextMenu); container.removeEventListener("paste", handleBrowserPaste, true); pasteTextRef.current = () => {}; bracketedPasteEnabledRef.current = false; @@ -904,6 +896,7 @@ export const TerminalComponent = React.forwardRef( // Focus terminal on mobile to keep keyboard open const handleTerminalClick = () => { + setCopyMenu(null); if (isMobile && termRef.current) { termRef.current.focus(); } @@ -1018,6 +1011,26 @@ export const TerminalComponent = React.forwardRef( )} + {copyMenu && ( + + )} {error && (
{error} diff --git a/openspec/changes/fix-web-terminal-clipboard/change.md b/openspec/changes/fix-web-terminal-clipboard/change.md index d34dca1..6c4cd5d 100644 --- a/openspec/changes/fix-web-terminal-clipboard/change.md +++ b/openspec/changes/fix-web-terminal-clipboard/change.md @@ -6,8 +6,9 @@ Users cannot reliably copy terminal output from the browser terminal. Browser co ## Required behavior -- 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. +- Right-clicking a selected terminal region presents an xterm-aware Copy action that writes the selection to the system clipboard. +- Browser native context menus remain available when no terminal output is selected. +- Terminal keyboard input, including `Ctrl+C`, remains unchanged. - 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.