From 881e8bdf4fda309f8acb06e8c478687489906326 Mon Sep 17 00:00:00 2001 From: shekohex Date: Fri, 20 Feb 2026 13:23:01 +0200 Subject: [PATCH] fix(terminal): restore terminal text copy behavior (#452) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): restore terminal text copy behavior * fix(desktop): route Cmd+C through terminal-aware copy handler on macOS - Use a custom macOS Copy menu action to dispatch app copy events first, so terminal selections copy without native “no target” beep, while preserving DOM copy fallback for non-terminal contexts. --------- Co-authored-by: Bohdan Triapitsyn --- packages/desktop/src-tauri/src/main.rs | 9 +- .../components/terminal/TerminalViewport.tsx | 87 ++++++++++++------- packages/ui/src/hooks/useMenuActions.ts | 10 +++ packages/ui/src/index.css | 6 -- 4 files changed, 73 insertions(+), 39 deletions(-) diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index e0373266..b00f1779 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -98,6 +98,8 @@ const MENU_ITEM_OPEN_FILES_TAB_ID: &str = "menu_open_files_tab"; #[cfg(target_os = "macos")] const MENU_ITEM_OPEN_TERMINAL_TAB_ID: &str = "menu_open_terminal_tab"; #[cfg(target_os = "macos")] +const MENU_ITEM_COPY_ID: &str = "menu_copy"; +#[cfg(target_os = "macos")] const MENU_ITEM_THEME_LIGHT_ID: &str = "menu_theme_light"; #[cfg(target_os = "macos")] const MENU_ITEM_THEME_DARK_ID: &str = "menu_theme_dark"; @@ -218,6 +220,7 @@ fn build_macos_menu( true, Some("Cmd+T"), )?; + let copy = MenuItem::with_id(app, MENU_ITEM_COPY_ID, "Copy", true, Some("Cmd+C"))?; let theme_light = MenuItem::with_id(app, MENU_ITEM_THEME_LIGHT_ID, "Light Theme", true, None::<&str>)?; @@ -353,7 +356,7 @@ fn build_macos_menu( &PredefinedMenuItem::redo(app, None)?, &PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::cut(app, None)?, - &PredefinedMenuItem::copy(app, None)?, + ©, &PredefinedMenuItem::paste(app, None)?, &PredefinedMenuItem::select_all(app, None)?, ], @@ -2462,6 +2465,10 @@ fn main() { dispatch_menu_action(app, "open-terminal-tab"); return; } + if id == MENU_ITEM_COPY_ID { + dispatch_menu_action(app, "copy"); + return; + } if id == MENU_ITEM_THEME_LIGHT_ID { dispatch_menu_action(app, "theme-light"); diff --git a/packages/ui/src/components/terminal/TerminalViewport.tsx b/packages/ui/src/components/terminal/TerminalViewport.tsx index f7337ead..b7e4ea7b 100644 --- a/packages/ui/src/components/terminal/TerminalViewport.tsx +++ b/packages/ui/src/components/terminal/TerminalViewport.tsx @@ -339,29 +339,55 @@ const TerminalViewport = React.forwardRef { - if (typeof window === 'undefined' || typeof document === 'undefined') { - return; + const getTerminalSelectionText = React.useCallback((): string => { + const terminal = terminalRef.current as unknown as { + getSelection?: () => string; + } | null; + if (!terminal || typeof terminal.getSelection !== 'function') { + return ''; + } + const text = terminal.getSelection(); + return typeof text === 'string' ? text : ''; + }, []); + + const getDomSelectionTextInViewport = React.useCallback((): string => { + if (typeof window === 'undefined') { + return ''; } const selection = window.getSelection(); if (!selection) { - return; + return ''; } + const text = selection.toString(); if (!text.trim()) { - return; + return ''; } const container = containerRef.current; if (!container) { - return; + return ''; } + const anchorNode = selection.anchorNode; const focusNode = selection.focusNode; if (anchorNode && !container.contains(anchorNode)) { - return; + return ''; } if (focusNode && !container.contains(focusNode)) { + return ''; + } + + return text; + }, []); + + const copySelectionToClipboard = React.useCallback(async () => { + if (typeof document === 'undefined') { + return; + } + + const text = getTerminalSelectionText() || getDomSelectionTextInViewport(); + if (!text.trim()) { return; } @@ -388,37 +414,34 @@ const TerminalViewport = React.forwardRef { + const terminalSelection = getTerminalSelectionText(); + if (terminalSelection.trim()) { + return true; + } + return Boolean(getDomSelectionTextInViewport().trim()); + }, [getDomSelectionTextInViewport, getTerminalSelectionText]); + + React.useEffect(() => { if (typeof window === 'undefined') { - return false; - } - const selection = window.getSelection(); - if (!selection) { - return false; - } - const text = selection.toString(); - if (!text.trim()) { - return false; + return; } - const container = containerRef.current; - if (!container) { - return false; - } + const handleMenuCopy = (event: Event) => { + if (!hasCopyableSelectionInViewport()) { + return; + } + event.preventDefault(); + void copySelectionToClipboard(); + }; - const anchorNode = selection.anchorNode; - const focusNode = selection.focusNode; - if (anchorNode && !container.contains(anchorNode)) { - return false; - } - if (focusNode && !container.contains(focusNode)) { - return false; - } - - return true; - }, []); + window.addEventListener('openchamber:copy', handleMenuCopy); + return () => { + window.removeEventListener('openchamber:copy', handleMenuCopy); + }; + }, [copySelectionToClipboard, hasCopyableSelectionInViewport]); const resetWriteState = React.useCallback(() => { pendingWriteRef.current = ''; diff --git a/packages/ui/src/hooks/useMenuActions.ts b/packages/ui/src/hooks/useMenuActions.ts index 9d8874ac..9e784e62 100644 --- a/packages/ui/src/hooks/useMenuActions.ts +++ b/packages/ui/src/hooks/useMenuActions.ts @@ -35,6 +35,7 @@ type MenuAction = | 'open-diff-tab' | 'open-files-tab' | 'open-terminal-tab' + | 'copy' | 'theme-light' | 'theme-dark' | 'theme-system' @@ -152,6 +153,15 @@ export const useMenuActions = ( break; } + case 'copy': { + const copyEvent = new Event('openchamber:copy', { cancelable: true }); + const wasHandled = !window.dispatchEvent(copyEvent); + if (!wasHandled) { + document.execCommand('copy'); + } + break; + } + case 'theme-light': setThemeMode('light'); break; diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index d421a514..934da3ee 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -888,11 +888,8 @@ html:not(.dark) .chat-scroll { /* Keep Ghostty's internal focus textarea fully non-painting on desktop. */ .terminal-viewport-container textarea:not([data-terminal-hidden-input="true"]) { opacity: 0 !important; - width: 0 !important; - height: 0 !important; font-size: 0 !important; line-height: 0 !important; - clip-path: inset(100%) !important; color: transparent !important; -webkit-text-fill-color: transparent !important; background: transparent !important; @@ -908,9 +905,6 @@ input[aria-label="Terminal input"] { color: transparent !important; -webkit-text-fill-color: transparent !important; opacity: 0 !important; - width: 0 !important; - height: 0 !important; - clip-path: inset(100%) !important; outline: none !important; }