diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 258cf7cee..eea300291 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -8,6 +8,7 @@ import type { VoiceRecordResponse } from '../gatewayTypes.js' import { writeOsc52Clipboard } from '../lib/osc52.js' +import { isAction, isMac } from '../lib/platform.js' import { getInputSelection } from './inputSelectionStore.js' import type { InputHandlerContext, InputHandlerResult } from './interfaces.js' @@ -224,10 +225,6 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return terminal.scrollWithSelection(key.pageUp ? -step : step) } - if (key.ctrl && key.shift && ch.toLowerCase() === 'c') { - return copySelection() - } - if (key.escape && terminal.hasSelection) { return clearSelection() } @@ -244,7 +241,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return } - if (isCtrl(key, ch, 'c')) { + if (isAction(key, ch, 'c') || (key.ctrl && key.shift && ch.toLowerCase() === 'c')) { if (terminal.hasSelection) { return copySelection() } @@ -254,6 +251,21 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { if (inputSel && inputSel.end > inputSel.start) { writeOsc52Clipboard(inputSel.value.slice(inputSel.start, inputSel.end)) inputSel.clear() + } + + return + } + + if (isCtrl(key, ch, 'c')) { + if (!isMac && terminal.hasSelection) { + return copySelection() + } + + const inputSel = getInputSelection() + + if (!isMac && inputSel && inputSel.end > inputSel.start) { + writeOsc52Clipboard(inputSel.value.slice(inputSel.start, inputSel.end)) + inputSel.clear() return } @@ -274,11 +286,11 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return actions.die() } - if (isCtrl(key, ch, 'd')) { + if (isAction(key, ch, 'd') || isCtrl(key, ch, 'd')) { return actions.die() } - if (isCtrl(key, ch, 'l')) { + if (isAction(key, ch, 'l') || isCtrl(key, ch, 'l')) { if (actions.guardBusySessionSwitch()) { return } @@ -288,11 +300,11 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return actions.newSession() } - if (isCtrl(key, ch, 'b')) { + if (isAction(key, ch, 'b') || isCtrl(key, ch, 'b')) { return voice.recording ? voiceStop() : voiceStart() } - if (isCtrl(key, ch, 'g')) { + if (isAction(key, ch, 'g') || isCtrl(key, ch, 'g')) { return cActions.openEditor() } @@ -311,7 +323,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return } - if (isCtrl(key, ch, 'k') && cRefs.queueRef.current.length && live.sid) { + if ((isAction(key, ch, 'k') || isCtrl(key, ch, 'k')) && cRefs.queueRef.current.length && live.sid) { const next = cActions.dequeue() if (next) { diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index dff8121b5..fcd13a7b1 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -519,22 +519,22 @@ export function TextInput({ const range = selRange() const delFwd = k.delete || fwdDel.current - if (k.ctrl && inp === 'z') { + if ((k.ctrl || k.meta) && inp === 'z') { return swap(undo, redo) } - if ((k.ctrl && inp === 'y') || (k.meta && k.shift && inp === 'z')) { + if (((k.ctrl || k.meta) && inp === 'y') || ((k.ctrl || k.meta) && k.shift && inp === 'z')) { return swap(redo, undo) } - if (k.ctrl && inp === 'a') { + if ((k.ctrl || k.meta) && inp === 'a') { return selectAll() } if (k.home) { clearSel() c = 0 - } else if (k.end || (k.ctrl && inp === 'e')) { + } else if (k.end || (k.ctrl && inp === 'e') || (k.meta && inp === 'e')) { clearSel() c = v.length } else if (k.leftArrow) { @@ -553,10 +553,10 @@ export function TextInput({ clearSel() c = mod ? wordRight(v, c) : nextPos(v, c) } - } else if (k.meta && inp === 'b') { + } else if ((k.ctrl || k.meta) && inp === 'b') { clearSel() c = wordLeft(v, c) - } else if (k.meta && inp === 'f') { + } else if ((k.ctrl || k.meta) && inp === 'f') { clearSel() c = wordRight(v, c) } else if (range && (k.backspace || delFwd)) { @@ -579,7 +579,7 @@ export function TextInput({ } else { v = v.slice(0, c) + v.slice(nextPos(v, c)) } - } else if (k.ctrl && inp === 'w') { + } else if ((k.ctrl || k.meta) && inp === 'w') { if (range) { v = v.slice(0, range.start) + v.slice(range.end) c = range.start @@ -591,7 +591,7 @@ export function TextInput({ } else { return } - } else if (k.ctrl && inp === 'u') { + } else if ((k.ctrl || k.meta) && inp === 'u') { if (range) { v = v.slice(0, range.start) + v.slice(range.end) c = range.start @@ -599,7 +599,7 @@ export function TextInput({ v = v.slice(c) c = 0 } - } else if (k.ctrl && inp === 'k') { + } else if ((k.ctrl || k.meta) && inp === 'k') { if (range) { v = v.slice(0, range.start) + v.slice(range.end) c = range.start diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts index f08ca6136..292b7b86f 100644 --- a/ui-tui/src/content/hotkeys.ts +++ b/ui-tui/src/content/hotkeys.ts @@ -1,16 +1,21 @@ +import { isMac } from '../lib/platform.js' + +const mod = isMac ? 'Cmd' : 'Ctrl' +const pasteMod = isMac ? 'Cmd' : 'Alt' + export const HOTKEYS: [string, string][] = [ - ['Ctrl+C', 'interrupt / clear draft / exit'], - ['Ctrl+D', 'exit'], - ['Ctrl+G', 'open $EDITOR for prompt'], - ['Ctrl+L', 'new session (clear)'], - ['Alt+V / /paste', 'paste clipboard image'], + [mod + '+C / ' + mod + '+Shift+C', 'copy selection'], + [mod + '+D', 'exit'], + [mod + '+G', 'open $EDITOR for prompt'], + [mod + '+L', 'new session (clear)'], + [pasteMod + '+V / /paste', 'paste clipboard image'], ['Tab', 'apply completion'], ['↑/↓', 'completions / queue edit / history'], - ['Ctrl+A/E', 'home / end of line'], - ['Ctrl+Z / Ctrl+Y', 'undo / redo input edits'], - ['Ctrl+W', 'delete word'], - ['Ctrl+U/K', 'delete to start / end'], - ['Ctrl+←/→', 'jump word'], + [mod + '+A/E', 'home / end of line'], + [mod + '+Z / ' + mod + '+Y', 'undo / redo input edits'], + [mod + '+W', 'delete word'], + [mod + '+U/K', 'delete to start / end'], + [mod + '+←/→', 'jump word'], ['Home/End', 'start / end of line'], ['Shift+Enter / Alt+Enter', 'insert newline'], ['\\+Enter', 'multi-line continuation (fallback)'], diff --git a/ui-tui/src/lib/platform.ts b/ui-tui/src/lib/platform.ts new file mode 100644 index 000000000..d052324ad --- /dev/null +++ b/ui-tui/src/lib/platform.ts @@ -0,0 +1,19 @@ +/** Platform-aware keybinding helpers. + * + * On macOS the "action" modifier is Cmd (key.meta in Ink), on other platforms + * it is Ctrl. Ctrl+C is ALWAYS the interrupt key regardless of platform — + * it must never be remapped to copy. + */ + +export const isMac = process.platform === 'darwin' + +/** The display label for the action modifier key. */ +export const modLabel = isMac ? '⌘' : 'Ctrl' + +/** True when the platform action-modifier is pressed (Cmd on macOS, Ctrl elsewhere). */ +export const isActionMod = (key: { ctrl: boolean; meta: boolean }): boolean => + isMac ? key.meta : key.ctrl + +/** Match action-modifier + a single character (case-insensitive). */ +export const isAction = (key: { ctrl: boolean; meta: boolean }, ch: string, target: string): boolean => + isActionMod(key) && ch.toLowerCase() === target