fix(shortcuts): defer TUI editing chords in terminal-first mode (#24640)

Restack the original focused change onto current main, preserving every owned source and test blob and the merged CI contract and journal cleanup fixes.

Original-commit: f707cde14a
fix(shortcuts): defer TUI editing chords in terminal-first mode

Original-commit: 0c6348e49e
docs(shortcuts): describe deferred preview terminal chords

Original-commit: be62c1b6c6
Align worktree history shortcut metadata with terminal conflict policy

Restacked-from: be62c1b6c6
Restacked-onto: f7b1f9d8be
This commit is contained in:
Neil
2026-10-02 22:06:09 -07:00
committed by GitHub
parent 54ddebf837
commit 08ee7ba9ef
7 changed files with 98 additions and 32 deletions
@@ -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
@@ -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,
@@ -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(
@@ -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' }
}
}
@@ -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',
+2 -4
View File
@@ -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',
+17 -2
View File
@@ -73,12 +73,27 @@ export function normalizeTerminalShortcutPolicy(
return policy === 'terminal-first' ? 'terminal-first' : 'orca-first'
}
const TUI_CONFLICT_ACTIONS = new Set<KeybindingActionId>([
'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(