From 94953affa66367dacb92036b9a97a2bfb2a52b4a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Apr 2026 15:27:13 -0500 Subject: [PATCH] fix(tui): copy mouse selections automatically Copy stable TUI mouse selections on all platforms so Linux users can keep mouse tracking enabled without using Ctrl+C as the copy chord. --- ui-tui/src/__tests__/platform.test.ts | 4 ++-- ui-tui/src/app/useInputHandlers.ts | 5 +++-- ui-tui/src/app/useMainApp.ts | 13 ++++--------- ui-tui/src/content/hotkeys.ts | 10 +++++++--- ui-tui/src/lib/platform.ts | 6 +++--- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/ui-tui/src/__tests__/platform.test.ts b/ui-tui/src/__tests__/platform.test.ts index 4166f0b71f..dab223d35e 100644 --- a/ui-tui/src/__tests__/platform.test.ts +++ b/ui-tui/src/__tests__/platform.test.ts @@ -32,10 +32,10 @@ describe('platform action modifier', () => { }) describe('isCopyShortcut', () => { - it('keeps Ctrl+C as the local non-macOS copy chord', async () => { + it('keeps Ctrl+C as interrupt instead of local non-macOS copy', async () => { const { isCopyShortcut } = await importPlatform('linux') - expect(isCopyShortcut({ ctrl: true, meta: false, super: false }, 'c', {})).toBe(true) + expect(isCopyShortcut({ ctrl: true, meta: false, super: false }, 'c', {})).toBe(false) }) it('accepts client Cmd+C over SSH even when running on Linux', async () => { diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 6fdd04ea61..3fd9d06dfb 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -357,8 +357,9 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return } - // On macOS, Cmd+C with no selection is a no-op (Ctrl+C below handles interrupt). - // On non-macOS, isAction uses Ctrl, so fall through to interrupt/clear/exit. + // Cmd+C with no selection is a no-op; Ctrl+C below handles interrupt. + // Local non-macOS terminals keep Ctrl+C as interrupt because selection + // already copies on mouse-up while mouse tracking is enabled. if (isMac) { return } diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 70dc96fec3..c77d1c5604 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -17,7 +17,6 @@ import type { import { useGitBranch } from '../hooks/useGitBranch.js' import { useVirtualHistory } from '../hooks/useVirtualHistory.js' import { appendTranscriptMessage } from '../lib/messages.js' -import { isMac } from '../lib/platform.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import { terminalParityHints } from '../lib/terminalParity.js' import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' @@ -150,19 +149,15 @@ export function useMainApp(gw: GatewayClient) { selection.setSelectionBgColor(ui.theme.color.selectionBg) }, [selection, ui.theme.color.selectionBg]) - // macOS Terminal.app does not forward Cmd+C to fullscreen TUIs that enable - // mouse tracking, so the only reliable native-feeling path is iTerm-style - // copy-on-select: once a drag creates a stable TUI selection, write it to - // the system clipboard while keeping the highlight visible. + // Terminals generally route native selection/copy around fullscreen TUIs + // until mouse tracking is enabled; then the app owns selection. Mirror + // terminal copy-on-select behavior by writing stable TUI selections to the + // clipboard while keeping the highlight visible. // // Subscribe directly via the ink selection bus (not useSyncExternalStore) // so React doesn't re-render MainApp on every drag-move tick. The version // ref de-dupes against re-entrant notifications. useEffect(() => { - if (!isMac) { - return - } - return selection.subscribe(() => { if (!selection.hasSelection()) { return diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts index b79d08061b..17975466f3 100644 --- a/ui-tui/src/content/hotkeys.ts +++ b/ui-tui/src/content/hotkeys.ts @@ -5,15 +5,19 @@ const paste = isMac ? 'Cmd' : 'Alt' const copyHotkeys: [string, string][] = isMac ? [ + ['Mouse select', 'copy selection'], ['Cmd+C', 'copy selection'], ['Ctrl+C', 'interrupt / clear draft / exit'] ] : isRemoteShell() ? [ - ['Cmd+C', 'copy selection when forwarded by the terminal'], - ['Ctrl+C', 'copy selection / interrupt / clear draft / exit'] + ['Mouse select / Cmd+C', 'copy selection when forwarded by the terminal'], + ['Ctrl+C', 'interrupt / clear draft / exit'] + ] + : [ + ['Mouse select', 'copy selection'], + ['Ctrl+C', 'interrupt / clear draft / exit'] ] - : [['Ctrl+C', 'copy selection / interrupt / clear draft / exit']] export const HOTKEYS: [string, string][] = [ ...copyHotkeys, diff --git a/ui-tui/src/lib/platform.ts b/ui-tui/src/lib/platform.ts index 343d8f8683..f9e4a161ce 100644 --- a/ui-tui/src/lib/platform.ts +++ b/ui-tui/src/lib/platform.ts @@ -5,8 +5,8 @@ * as `key.meta`. Some macOS terminals also translate Cmd+Left/Right/Backspace * into readline-style Ctrl+A/Ctrl+E/Ctrl+U before the app sees them. * On other platforms the action modifier is Ctrl. - * Ctrl+C stays the interrupt key on macOS. On non-mac terminals it can also - * copy an active TUI selection, matching common terminal selection behavior. + * Ctrl+C stays the interrupt key on local terminals. Remote sessions can still + * accept client-forwarded Cmd+C for copying a TUI selection. */ export const isMac = process.platform === 'darwin' @@ -43,7 +43,7 @@ export const isCopyShortcut = ( env: NodeJS.ProcessEnv = process.env ): boolean => ch.toLowerCase() === 'c' && - (isAction(key, ch, 'c') || + ((isMac && isAction(key, ch, 'c')) || (isRemoteShell(env) && (key.meta || key.super === true)) || // VS Code/Cursor/Windsurf terminal setup forwards Cmd+C as a CSI-u // sequence with the super bit plus a benign ctrl bit. Accept that shape