Compare commits

...

2 Commits

Author SHA1 Message Date
Brooklyn Nicholson
d3b143c49c fix(tui): swallow empty copy shortcuts
Prevent forwarded copy chords from reaching TextInput as literal input when there is no active selection.
2026-04-29 15:51:42 -05:00
Brooklyn Nicholson
94953affa6 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.
2026-04-29 15:27:13 -05:00
5 changed files with 22 additions and 20 deletions

View File

@@ -32,10 +32,10 @@ describe('platform action modifier', () => {
}) })
describe('isCopyShortcut', () => { 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') 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 () => { it('accepts client Cmd+C over SSH even when running on Linux', async () => {

View File

@@ -357,9 +357,12 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
return return
} }
// On macOS, Cmd+C with no selection is a no-op (Ctrl+C below handles interrupt). // Copy shortcuts with no selection are no-ops. Plain Ctrl+C below still
// On non-macOS, isAction uses Ctrl, so fall through to interrupt/clear/exit. // handles interrupt/clear/exit; forwarded Cmd+C over SSH should not
if (isMac) { // leak through to TextInput as a literal "c".
const plainCtrlC = key.ctrl && !key.meta && key.super !== true && ch.toLowerCase() === 'c'
if (isMac || !plainCtrlC) {
return return
} }
} }

View File

@@ -17,7 +17,6 @@ import type {
import { useGitBranch } from '../hooks/useGitBranch.js' import { useGitBranch } from '../hooks/useGitBranch.js'
import { useVirtualHistory } from '../hooks/useVirtualHistory.js' import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
import { appendTranscriptMessage } from '../lib/messages.js' import { appendTranscriptMessage } from '../lib/messages.js'
import { isMac } from '../lib/platform.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import { terminalParityHints } from '../lib/terminalParity.js' import { terminalParityHints } from '../lib/terminalParity.js'
import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.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.setSelectionBgColor(ui.theme.color.selectionBg)
}, [selection, ui.theme.color.selectionBg]) }, [selection, ui.theme.color.selectionBg])
// macOS Terminal.app does not forward Cmd+C to fullscreen TUIs that enable // Terminals generally route native selection/copy around fullscreen TUIs
// mouse tracking, so the only reliable native-feeling path is iTerm-style // until mouse tracking is enabled; then the app owns selection. Mirror
// copy-on-select: once a drag creates a stable TUI selection, write it to // terminal copy-on-select behavior by writing stable TUI selections to the
// the system clipboard while keeping the highlight visible. // clipboard while keeping the highlight visible.
// //
// Subscribe directly via the ink selection bus (not useSyncExternalStore) // Subscribe directly via the ink selection bus (not useSyncExternalStore)
// so React doesn't re-render MainApp on every drag-move tick. The version // so React doesn't re-render MainApp on every drag-move tick. The version
// ref de-dupes against re-entrant notifications. // ref de-dupes against re-entrant notifications.
useEffect(() => { useEffect(() => {
if (!isMac) {
return
}
return selection.subscribe(() => { return selection.subscribe(() => {
if (!selection.hasSelection()) { if (!selection.hasSelection()) {
return return

View File

@@ -5,15 +5,19 @@ const paste = isMac ? 'Cmd' : 'Alt'
const copyHotkeys: [string, string][] = isMac const copyHotkeys: [string, string][] = isMac
? [ ? [
['Mouse select', 'copy selection'],
['Cmd+C', 'copy selection'], ['Cmd+C', 'copy selection'],
['Ctrl+C', 'interrupt / clear draft / exit'] ['Ctrl+C', 'interrupt / clear draft / exit']
] ]
: isRemoteShell() : isRemoteShell()
? [ ? [
['Cmd+C', 'copy selection when forwarded by the terminal'], ['Mouse select / Cmd+C', 'copy selection when forwarded by the terminal'],
['Ctrl+C', 'copy selection / interrupt / clear draft / exit'] ['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][] = [ export const HOTKEYS: [string, string][] = [
...copyHotkeys, ...copyHotkeys,

View File

@@ -5,8 +5,8 @@
* as `key.meta`. Some macOS terminals also translate Cmd+Left/Right/Backspace * 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. * into readline-style Ctrl+A/Ctrl+E/Ctrl+U before the app sees them.
* On other platforms the action modifier is Ctrl. * On other platforms the action modifier is Ctrl.
* Ctrl+C stays the interrupt key on macOS. On non-mac terminals it can also * Ctrl+C stays the interrupt key on local terminals. Remote sessions can still
* copy an active TUI selection, matching common terminal selection behavior. * accept client-forwarded Cmd+C for copying a TUI selection.
*/ */
export const isMac = process.platform === 'darwin' export const isMac = process.platform === 'darwin'
@@ -43,7 +43,7 @@ export const isCopyShortcut = (
env: NodeJS.ProcessEnv = process.env env: NodeJS.ProcessEnv = process.env
): boolean => ): boolean =>
ch.toLowerCase() === 'c' && ch.toLowerCase() === 'c' &&
(isAction(key, ch, 'c') || ((isMac && isAction(key, ch, 'c')) ||
(isRemoteShell(env) && (key.meta || key.super === true)) || (isRemoteShell(env) && (key.meta || key.super === true)) ||
// VS Code/Cursor/Windsurf terminal setup forwards Cmd+C as a CSI-u // 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 // sequence with the super bit plus a benign ctrl bit. Accept that shape