diff --git a/ui-tui/src/__tests__/platform.test.ts b/ui-tui/src/__tests__/platform.test.ts index 8995b9c6fc..e3035a79b2 100644 --- a/ui-tui/src/__tests__/platform.test.ts +++ b/ui-tui/src/__tests__/platform.test.ts @@ -31,6 +31,28 @@ describe('platform action modifier', () => { }) }) +describe('isCopyShortcut', () => { + it('keeps Ctrl+C as the local non-macOS copy chord', async () => { + const { isCopyShortcut } = await importPlatform('linux') + + expect(isCopyShortcut({ ctrl: true, meta: false, super: false }, 'c', {})).toBe(true) + }) + + it('accepts client Cmd+C over SSH even when running on Linux', async () => { + const { isCopyShortcut } = await importPlatform('linux') + const env = { SSH_CONNECTION: '1 2 3 4' } as NodeJS.ProcessEnv + + expect(isCopyShortcut({ ctrl: false, meta: false, super: true }, 'c', env)).toBe(true) + expect(isCopyShortcut({ ctrl: false, meta: true, super: false }, 'c', env)).toBe(true) + }) + + it('does not treat local Linux Alt+C as copy', async () => { + const { isCopyShortcut } = await importPlatform('linux') + + expect(isCopyShortcut({ ctrl: false, meta: true, super: false }, 'c', {})).toBe(false) + }) +}) + describe('isVoiceToggleKey', () => { it('matches raw Ctrl+B on macOS (doc-default across platforms)', async () => { const { isVoiceToggleKey } = await importPlatform('darwin') diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 294a44ca6f..6125d929fb 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -8,7 +8,7 @@ import type { SudoRespondResponse, VoiceRecordResponse } from '../gatewayTypes.js' -import { isAction, isMac, isVoiceToggleKey } from '../lib/platform.js' +import { isAction, isCopyShortcut, isMac, isVoiceToggleKey } from '../lib/platform.js' import { getInputSelection } from './inputSelectionStore.js' import type { InputHandlerContext, InputHandlerResult } from './interfaces.js' @@ -315,7 +315,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { } } - if (isAction(key, ch, 'c')) { + if (isCopyShortcut(key, ch)) { if (terminal.hasSelection) { return copySelection() } diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts index 0a58e305b8..e6311da0f4 100644 --- a/ui-tui/src/content/hotkeys.ts +++ b/ui-tui/src/content/hotkeys.ts @@ -1,15 +1,21 @@ import { isMac } from '../lib/platform.js' +const isRemoteShell = Boolean(process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY) const action = isMac ? 'Cmd' : 'Ctrl' const paste = isMac ? 'Cmd' : 'Alt' +const copyHotkeys: [string, string][] = isMac + ? [ + ['Cmd+C', 'copy selection'], + ['Ctrl+C', 'interrupt / clear draft / exit'] + ] + : [ + ...(isRemoteShell ? ([['Cmd+C', 'copy selection when forwarded by the terminal']] as [string, string][]) : []), + ['Ctrl+C', 'copy selection / interrupt / clear draft / exit'] + ] + export const HOTKEYS: [string, string][] = [ - ...(isMac - ? ([ - ['Cmd+C', 'copy selection'], - ['Ctrl+C', 'interrupt / clear draft / exit'] - ] as [string, string][]) - : ([['Ctrl+C', 'copy selection / interrupt / clear draft / exit']] as [string, string][])), + ...copyHotkeys, [action + '+D', 'exit'], [action + '+G', 'open $EDITOR for prompt'], [action + '+L', 'new session (clear)'], diff --git a/ui-tui/src/lib/platform.ts b/ui-tui/src/lib/platform.ts index 6913df4bc8..c75d981d05 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 is ALWAYS the interrupt key regardless of platform — it must never be - * remapped to copy. + * 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. */ export const isMac = process.platform === 'darwin' @@ -34,6 +34,16 @@ export const isMacActionFallback = ( export const isAction = (key: { ctrl: boolean; meta: boolean; super?: boolean }, ch: string, target: string): boolean => isActionMod(key) && ch.toLowerCase() === target +const isRemoteShell = (env: NodeJS.ProcessEnv = process.env): boolean => + Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) + +export const isCopyShortcut = ( + key: { ctrl: boolean; meta: boolean; super?: boolean }, + ch: string, + env: NodeJS.ProcessEnv = process.env +): boolean => + isAction(key, ch, 'c') || (isRemoteShell(env) && (key.meta || key.super === true) && ch.toLowerCase() === 'c') + /** * Voice recording toggle key (Ctrl+B). *