fix(tui): honor client copy shortcut over ssh

- accept forwarded Cmd+C for selection copy in SSH sessions even when Hermes runs on Linux
- keep local Linux Alt+C from acting as copy and update TUI hotkey hints for remote shells
This commit is contained in:
Brooklyn Nicholson 2026-04-25 14:44:39 -05:00
parent 283c8fd6e2
commit bcc5362432
4 changed files with 48 additions and 10 deletions

View file

@ -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')

View file

@ -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()
}

View file

@ -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'
export const HOTKEYS: [string, string][] = [
...(isMac
? ([
const copyHotkeys: [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][])),
]
: [
...(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][] = [
...copyHotkeys,
[action + '+D', 'exit'],
[action + '+G', 'open $EDITOR for prompt'],
[action + '+L', 'new session (clear)'],

View file

@ -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).
*