mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
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:f707cde14afix(shortcuts): defer TUI editing chords in terminal-first mode Original-commit:0c6348e49edocs(shortcuts): describe deferred preview terminal chords Original-commit:be62c1b6c6Align worktree history shortcut metadata with terminal conflict policy Restacked-from:be62c1b6c6Restacked-onto:f7b1f9d8be
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user