diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts index 20ff21e6a91..80fddd93c29 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts @@ -176,6 +176,12 @@ describe('resolvePreviewShortcutAction', () => { expect( resolvePreviewShortcutAction(keydown({ key: 'd', code: 'KeyD', metaKey: true }), contextFor()) ).toEqual({ type: 'splitActivePane', direction: 'vertical' }) + expect( + resolvePreviewShortcutAction( + keydown({ key: 'd', code: 'KeyD', metaKey: true }), + contextFor({ terminalShortcutPolicy: 'terminal-first' }) + ) + ).toBeNull() }) // Why: a terminal-first user remapped terminal.closePane away, so only the diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts index fb90fb5b4f6..0fef94397d9 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts @@ -22,15 +22,15 @@ export type PreviewShortcutContext = { terminalInput: DashboardCardTerminalInput | null /** Live kitty-protocol flags mirrored from this pty's output. */ getKittyKeyboardFlags: () => number - /** The user's setting; terminal-first yields the tab.close alias to the shell. */ + /** The user's policy for chords shared with shells and TUIs. */ terminalShortcutPolicy: TerminalShortcutPolicy | null | undefined } /** * Runs the preview terminal's keys through the same policy a pane uses, so the * dashboard encodes word-kills, Option chords, and modified Enter identically. - * Every pane-scoped verdict (splits, search, focus) still comes back — the - * caller swallows those rather than leaking raw bytes to the agent. + * Active pane commands come back for the caller to handle; deferred TUI + * chords continue through xterm. */ export function resolvePreviewShortcutAction( event: KeyboardEvent, diff --git a/src/renderer/src/components/settings/shortcut-terminal-status.ts b/src/renderer/src/components/settings/shortcut-terminal-status.ts index f8abf54abbe..4a1de39b801 100644 --- a/src/renderer/src/components/settings/shortcut-terminal-status.ts +++ b/src/renderer/src/components/settings/shortcut-terminal-status.ts @@ -24,7 +24,7 @@ export function getShortcutTerminalStatus( if (!hasEffectiveBinding) { return undefined } - if (definition.scope === 'terminal') { + if (definition.scope === 'terminal' && isKeybindingAllowedInTerminal(definition)) { return { label: translate('auto.components.settings.ShortcutsPane.cb02e00202', 'Terminal'), description: translate( diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts index 98f47941bb7..1c8340678dd 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts @@ -1,6 +1,7 @@ import { keybindingMatchesAction, type KeybindingInput, + type KeybindingActionId, type KeybindingMatchOptions, type KeybindingOverrides, type TerminalShortcutPolicy @@ -31,7 +32,7 @@ export type TerminalShortcutEvent = { // Shared close-chord predicate: the terminal pane (L3) and the floating panel's focused-terminal // branch (L2) both treat terminal.closePane OR a terminal-scope tab.close as "close the active // pane," so the two layers can't diverge. Callers pass the options each binding needs — -// terminal.closePane is context-free; tab.close is scoped to the terminal surface. +// both bindings use the caller’s terminal priority policy. export function isTerminalPaneCloseChord( event: KeybindingInput, platform: NodeJS.Platform, @@ -88,77 +89,75 @@ export function resolveTerminalShortcutAction( getWindowsShiftEnterEncoding?: () => WindowsShiftEnterEncoding, // Why: keybindings follow the client OS, but byte protocols follow the PTY host — they differ for macOS clients on Windows runtimes. isWindowsTerminalHost: () => boolean = () => isWindows, - // Why: gates the tab.close pane-close alias — under terminal-first a remapped tab.close yields to the shell (terminal.closePane, scope terminal, still closes). + // Why: terminal-first yields editing and TUI chords before xterm encodes them. terminalShortcutPolicy: TerminalShortcutPolicy = 'orca-first', // Why: query-only Droid/Grok consumers need CSI-u even when the live kitty flags remain inactive. hasCtrlEnterCsiUAuthority?: () => boolean ): TerminalShortcutAction | null { const platform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux' + const matchOptions: KeybindingMatchOptions = { context: 'terminal', terminalShortcutPolicy } + const matches = (action: KeybindingActionId): boolean => + keybindingMatchesAction(action, event, platform, keybindings, matchOptions) // Why: capture this chord even on repeat without blocking the OS default input-source switch. - if (keybindingMatchesAction('terminal.switchInputSource', event, platform, keybindings)) { + if (matches('terminal.switchInputSource')) { return { type: 'switchInputSource' } } // Why: held select-all and copy keydowns must remain claimed until keyup so // Kitty event reporting cannot encode their repeat or release into the PTY. - if (keybindingMatchesAction('terminal.selectAll', event, platform, keybindings)) { + if (matches('terminal.selectAll')) { return { type: 'selectAll' } } - if (keybindingMatchesAction('terminal.copySelection', event, platform, keybindings)) { + if (matches('terminal.copySelection')) { return { type: 'copySelection' } } if (!event.repeat) { - if (keybindingMatchesAction('terminal.search', event, platform, keybindings)) { + if (matches('terminal.search')) { return { type: 'toggleSearch' } } - if (keybindingMatchesAction('terminal.clear', event, platform, keybindings)) { + if (matches('terminal.clear')) { return { type: 'clearActivePane' } } - if (keybindingMatchesAction('terminal.focusPreviousPane', event, platform, keybindings)) { + if (matches('terminal.focusPreviousPane')) { return { type: 'focusPane', direction: 'previous' } } - if (keybindingMatchesAction('terminal.focusNextPane', event, platform, keybindings)) { + if (matches('terminal.focusNextPane')) { return { type: 'focusPane', direction: 'next' } } - if (keybindingMatchesAction('terminal.equalizePaneSizes', event, platform, keybindings)) { + if (matches('terminal.equalizePaneSizes')) { return { type: 'equalizePaneSizes' } } - if (keybindingMatchesAction('terminal.expandPane', event, platform, keybindings)) { + if (matches('terminal.expandPane')) { return { type: 'toggleExpandActivePane' } } - if (keybindingMatchesAction('terminal.setTitle', event, platform, keybindings)) { + if (matches('terminal.setTitle')) { return { type: 'setTitle' } } - if (keybindingMatchesAction('terminal.clearPaneTitle', event, platform, keybindings)) { + if (matches('terminal.clearPaneTitle')) { return { type: 'clearPaneTitle' } } // Why: recognize the active tab.close binding as a pane-close alias too, so a user who remaps // tab.close alone still closes the focused split pane (never the whole tab); L2 always defers to us. - if ( - isTerminalPaneCloseChord(event, platform, keybindings, undefined, { - context: 'terminal', - terminalShortcutPolicy - }) - ) { + if (isTerminalPaneCloseChord(event, platform, keybindings, matchOptions, matchOptions)) { return { type: 'closeActivePane' } } - if (keybindingMatchesAction('terminal.splitRight', event, platform, keybindings)) { + if (matches('terminal.splitRight')) { return { type: 'splitActivePane', direction: 'vertical' } } - if (keybindingMatchesAction('terminal.splitDown', event, platform, keybindings)) { + if (matches('terminal.splitDown')) { return { type: 'splitActivePane', direction: 'horizontal' } } } diff --git a/src/shared/keybindings-terminal-context.test.ts b/src/shared/keybindings-terminal-context.test.ts index 887b8ee3b28..daf4d880a0f 100644 --- a/src/shared/keybindings-terminal-context.test.ts +++ b/src/shared/keybindings-terminal-context.test.ts @@ -38,9 +38,57 @@ describe('keybindings', () => { undefined, { context: 'terminal', terminalShortcutPolicy: 'terminal-first' } ) - ).toBe(true) + ).toBe(false) }) + it.each(['darwin', 'linux', 'win32'] as const)( + 'defers editing and history chords on %s while app focus retains them', + (platform) => { + const chords = [ + { action: 'terminal.search', key: 'f', code: 'KeyF', shift: false }, + { action: 'terminal.clear', key: 'k', code: 'KeyK', shift: false }, + { action: 'terminal.closePane', key: 'w', code: 'KeyW', shift: false }, + { action: 'tab.newTerminal', key: 't', code: 'KeyT', shift: false }, + { action: 'terminal.selectAll', key: 'a', code: 'KeyA', shift: platform !== 'darwin' }, + { action: 'terminal.splitRight', key: 'd', code: 'KeyD', shift: platform !== 'darwin' } + ] as const + for (const chord of chords) { + const input = { + ...chord, + meta: platform === 'darwin', + control: platform !== 'darwin', + alt: false + } + expect( + keybindingMatchesAction(chord.action, input, platform, undefined, { + context: 'terminal', + terminalShortcutPolicy: 'terminal-first' + }) + ).toBe(false) + expect( + keybindingMatchesAction(chord.action, input, platform, undefined, { + context: 'app', + terminalShortcutPolicy: 'terminal-first' + }) + ).toBe(true) + } + const historyInput = { + key: 'ArrowLeft', + code: 'ArrowLeft', + meta: platform === 'darwin', + control: platform !== 'darwin', + alt: true, + shift: false + } + expect( + keybindingMatchesAction('worktree.history.back', historyInput, platform, undefined, { + context: 'terminal', + terminalShortcutPolicy: 'terminal-first' + }) + ).toBe(false) + } + ) + it('keeps floating workspace tab shortcuts active in app focus even with terminal-first policy configured', () => { const panelFocus = { context: 'app', diff --git a/src/shared/keybindings/definitions-core-2.ts b/src/shared/keybindings/definitions-core-2.ts index d8fd6ad3227..1ae589b67c5 100644 --- a/src/shared/keybindings/definitions-core-2.ts +++ b/src/shared/keybindings/definitions-core-2.ts @@ -55,8 +55,7 @@ export const KEYBINDING_DEFINITION_CORE_2: readonly KeybindingDefinition[] = [ group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'worktree', 'history', 'back'], - defaultBindings: platformBindings(['Mod+Alt+ArrowLeft']), - allowInTerminal: true + defaultBindings: platformBindings(['Mod+Alt+ArrowLeft']) }, { id: 'worktree.history.forward', @@ -64,8 +63,7 @@ export const KEYBINDING_DEFINITION_CORE_2: readonly KeybindingDefinition[] = [ group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'worktree', 'history', 'forward'], - defaultBindings: platformBindings(['Mod+Alt+ArrowRight']), - allowInTerminal: true + defaultBindings: platformBindings(['Mod+Alt+ArrowRight']) }, { id: 'tab.newTerminal', diff --git a/src/shared/keybindings/effective.ts b/src/shared/keybindings/effective.ts index 23489eb296e..c8e8a879e4f 100644 --- a/src/shared/keybindings/effective.ts +++ b/src/shared/keybindings/effective.ts @@ -73,12 +73,27 @@ export function normalizeTerminalShortcutPolicy( return policy === 'terminal-first' ? 'terminal-first' : 'orca-first' } +const TUI_CONFLICT_ACTIONS = new Set([ + 'terminal.search', + 'terminal.clear', + 'terminal.selectAll', + 'terminal.splitRight', + 'terminal.splitDown', + 'terminal.closePane', + 'worktree.history.back', + 'worktree.history.forward' +]) + export function isKeybindingAllowedInTerminal(definition: KeybindingDefinition): boolean { - return definition.scope === 'terminal' || definition.allowInTerminal === true + // Keep clipboard, input-source and pane navigation controls available. + return ( + !TUI_CONFLICT_ACTIONS.has(definition.id) && + (definition.scope === 'terminal' || definition.allowInTerminal === true) + ) } export function isKeybindingPotentialTerminalConflict(definition: KeybindingDefinition): boolean { - return definition.scope !== 'terminal' && definition.allowInTerminal !== true + return !isKeybindingAllowedInTerminal(definition) } export function keybindingIsActiveInContext(