mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 08:02:32 +00:00
fix(terminal): forward Option hotkeys to kitty-keyboard TUIs on compose layouts (#8031)
* fix(terminal): forward Option hotkeys to kitty-keyboard TUIs on compose layouts On macOS layouts where Option composes characters (ABC and all non-US, the effective default), pressing Option+P in a TUI that negotiated the kitty keyboard protocol made xterm's kitty encoder report the composed codepoint (alt+pi, CSI 960;3u) instead of the physical key (alt+p, CSI 112;3u). No TUI binds the composed form, so agent hotkeys like OMP's Alt+P / Alt+M neither fired nor typed anything. Fix: mirror each pane's application-negotiated kitty flags with a scan-based tracker fed only from PTY output (immune to Orca's defensive renderer-side kitty resets on Ctrl+C interrupts and reattach), and have the terminal shortcut policy encode Option chords as kitty CSI-u from the physical key when the pane's app opted in. Dead keys stay exempt so Option composition still works, and panes without kitty negotiation (shells) are unchanged. Alt+Arrow / Alt+Backspace now defer to xterm's native kitty encoding in kitty panes instead of the legacy readline translations. The daemon's headless emulator tracks the same flags and re-arms them via the snapshot rehydrate preamble (CSI = flags ; 1 u), so the behavior survives window reloads and reattaches; PTY exit and cold restore reset the mirror. Validated byte-for-byte against a real omp 16.3.15 in a pty: the policy's emitted CSI-u opens the temporary-model selector (Alt+P) and agent hub (Alt+A), identical to the legacy ESC-prefixed forms it parses. * fix(terminal): harden kitty Option-chord mirror for soft resets, replay redelivery, and non-QWERTY layouts Three review findings on the kitty keyboard mirror, each verified against a live omp 16.3.15 pty session using the real production modules: - DECSTR: xterm's soft reset (CSI ! p) clears its kitty flags and stacks for both screens without switching buffers; the tracker now mirrors that, so a soft-resetting TUI stops receiving kitty-encoded Option chords. - Replay redelivery: relay reconnects can redeliver the retained replay window, and each scan of the app's one-time CSI > u push grew the mirrored stack while the renderer's post-replay reset drained xterm's copy. The TUI's single exit pop (omp emits a bare CSI < u on quit) then landed on a stale frame, leaving Option+B/F/D kitty-encoded in a plain shell. Replay paths now scan with scanReplay(), which applies pushes as idempotent sets so redelivery cannot grow the stack; the live-output funnel and the daemon's once-per-byte emulator keep full stack semantics. - Layout-correct base keys: kitty CSI-u reports must carry the key's unshifted codepoint in the active layout, but the encoder resolved it from a US-QWERTY physical-code table — on Dvorak/Colemak/AZERTY-class layouts (exactly the population whose effective Option-as-Alt default activates this path) the wrong key's chord fired, e.g. Colemak Option+P sent alt+r. A new keyboard-layout module caches Chromium's KeyboardLayoutMap (fetched at terminal setup and on focus-in, like the option-as-alt probe) and the policy resolves through it before the US fallback. Verified live: the layout-resolved bytes open omp's model selector; the US-table bytes do not. * fix(terminal): reset kitty mirror on fresh spawn to cover replaced-PTY late exits The exit handler resets the per-pane kitty keyboard mirror, but a late exit from a replaced PTY takes the stale-transport early return and skips it — so a restart-in-place could leak the old TUI's kitty flags into the fresh shell, kitty-encoding Option chords the shell cannot parse. A fresh spawn is by definition a new process with kitty state at zero, so startFreshSpawn now resets the reused tracker itself, alongside the other per-pane mode state it already clears. Reattach paths are untouched, so a live TUI's mirrored flags still survive reconnects. --------- Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local>
This commit is contained in:
co-authored by
Brennan Benson
parent
d9b1fbbc07
commit
da2c692d31
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { TerminalKittyKeyboardModeTracker } from './terminal-kitty-keyboard-mode-tracker'
|
||||
|
||||
describe('TerminalKittyKeyboardModeTracker', () => {
|
||||
it('starts inactive and ignores non-kitty sequences', () => {
|
||||
const tracker = new TerminalKittyKeyboardModeTracker()
|
||||
expect(tracker.flags).toBe(0)
|
||||
tracker.scan('plain output \x1b[?2004h\x1b[38;5;10mcolored\x1b[0m')
|
||||
expect(tracker.flags).toBe(0)
|
||||
})
|
||||
|
||||
it('does not treat CSI u (restore cursor) or the CSI ? u query as kitty state', () => {
|
||||
const tracker = new TerminalKittyKeyboardModeTracker()
|
||||
tracker.scan('\x1b[u\x1b[?u')
|
||||
expect(tracker.flags).toBe(0)
|
||||
})
|
||||
|
||||
it('tracks push and pop like xterm, including the pop-to-empty zeroing', () => {
|
||||
const tracker = new TerminalKittyKeyboardModeTracker()
|
||||
tracker.scan('\x1b[>1u')
|
||||
expect(tracker.flags).toBe(1)
|
||||
tracker.scan('\x1b[>7u')
|
||||
expect(tracker.flags).toBe(7)
|
||||
tracker.scan('\x1b[<u')
|
||||
expect(tracker.flags).toBe(1)
|
||||
|
||||
// Why: xterm zeroes flags whenever a pop drains the stack, even though the
|
||||
// popped frame was the pre-push value — mirror that exactly.
|
||||
const drained = new TerminalKittyKeyboardModeTracker()
|
||||
drained.scan('\x1b[=3;1u\x1b[>5u\x1b[<u')
|
||||
expect(drained.flags).toBe(0)
|
||||
})
|
||||
|
||||
it('applies set/or/clear modes of CSI = u', () => {
|
||||
const tracker = new TerminalKittyKeyboardModeTracker()
|
||||
tracker.scan('\x1b[=1;1u')
|
||||
expect(tracker.flags).toBe(1)
|
||||
tracker.scan('\x1b[=2;2u')
|
||||
expect(tracker.flags).toBe(3)
|
||||
tracker.scan('\x1b[=1;3u')
|
||||
expect(tracker.flags).toBe(2)
|
||||
// Mode defaults to 1 (set) when omitted.
|
||||
tracker.scan('\x1b[=4u')
|
||||
expect(tracker.flags).toBe(4)
|
||||
})
|
||||
|
||||
it("clears state for Orca's defensive reset sequence and RIS", () => {
|
||||
const tracker = new TerminalKittyKeyboardModeTracker()
|
||||
tracker.scan('\x1b[>1u')
|
||||
tracker.scan('\x1b[<99u\x1b[=0u')
|
||||
expect(tracker.flags).toBe(0)
|
||||
|
||||
tracker.scan('\x1b[>1u')
|
||||
tracker.scan('\x1bc')
|
||||
expect(tracker.flags).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps per-screen flags across alternate-screen switches', () => {
|
||||
const tracker = new TerminalKittyKeyboardModeTracker()
|
||||
tracker.scan('\x1b[>1u')
|
||||
expect(tracker.flags).toBe(1)
|
||||
tracker.scan('\x1b[?1049h')
|
||||
expect(tracker.flags).toBe(0)
|
||||
tracker.scan('\x1b[>2u')
|
||||
expect(tracker.flags).toBe(2)
|
||||
tracker.scan('\x1b[?1049l')
|
||||
expect(tracker.flags).toBe(1)
|
||||
})
|
||||
|
||||
it('handles sequences split across chunks and C1 CSI', () => {
|
||||
const tracker = new TerminalKittyKeyboardModeTracker()
|
||||
tracker.scan('\x1b[>')
|
||||
expect(tracker.flags).toBe(0)
|
||||
tracker.scan('1u')
|
||||
expect(tracker.flags).toBe(1)
|
||||
tracker.scan('\x9b<99u')
|
||||
expect(tracker.flags).toBe(0)
|
||||
tracker.scan('\x9b>7u')
|
||||
expect(tracker.flags).toBe(7)
|
||||
})
|
||||
|
||||
it('caps the mirrored stack without losing the current flags', () => {
|
||||
const tracker = new TerminalKittyKeyboardModeTracker()
|
||||
for (let i = 0; i < 40; i++) {
|
||||
tracker.scan(`\x1b[>${(i % 3) + 1}u`)
|
||||
}
|
||||
expect(tracker.flags).toBe((39 % 3) + 1)
|
||||
})
|
||||
|
||||
it('reset() returns to the inactive state', () => {
|
||||
const tracker = new TerminalKittyKeyboardModeTracker()
|
||||
tracker.scan('\x1b[>1u\x1b[?1049h\x1b[>2u')
|
||||
tracker.reset()
|
||||
expect(tracker.flags).toBe(0)
|
||||
tracker.scan('\x1b[?1049l')
|
||||
expect(tracker.flags).toBe(0)
|
||||
})
|
||||
|
||||
it('clears kitty state on DECSTR (CSI ! p) like xterm, without switching screens', () => {
|
||||
const tracker = new TerminalKittyKeyboardModeTracker()
|
||||
tracker.scan('\x1b[>1u\x1b[!p')
|
||||
expect(tracker.flags).toBe(0)
|
||||
|
||||
// xterm's soft reset wipes both screens' slots but stays on the current
|
||||
// buffer; a later alt-screen exit must not resurrect pre-reset flags.
|
||||
const onAlt = new TerminalKittyKeyboardModeTracker()
|
||||
onAlt.scan('\x1b[>1u\x1b[?1049h\x1b[>2u')
|
||||
expect(onAlt.flags).toBe(2)
|
||||
onAlt.scan('\x1b[!p')
|
||||
expect(onAlt.flags).toBe(0)
|
||||
onAlt.scan('\x1b[?1049l')
|
||||
expect(onAlt.flags).toBe(0)
|
||||
})
|
||||
|
||||
it('handles DECSTR split across chunks', () => {
|
||||
const tracker = new TerminalKittyKeyboardModeTracker()
|
||||
tracker.scan('\x1b[>1u\x1b[!')
|
||||
expect(tracker.flags).toBe(1)
|
||||
tracker.scan('p')
|
||||
expect(tracker.flags).toBe(0)
|
||||
})
|
||||
|
||||
it('applies replayed pushes as sets so redelivered windows cannot grow the stack', () => {
|
||||
const tracker = new TerminalKittyKeyboardModeTracker()
|
||||
// Live negotiation, then two relay reconnects redelivering the same
|
||||
// retained window containing the app's one-time push.
|
||||
tracker.scan('\x1b[>1u')
|
||||
tracker.scanReplay('\x1b[>1u')
|
||||
tracker.scanReplay('\x1b[>1u')
|
||||
expect(tracker.flags).toBe(1)
|
||||
// The TUI's single exit pop must drain to zero despite the redeliveries.
|
||||
tracker.scan('\x1b[<u')
|
||||
expect(tracker.flags).toBe(0)
|
||||
})
|
||||
|
||||
it('replay scans arm a fresh tracker and honor pops inside the window', () => {
|
||||
const fresh = new TerminalKittyKeyboardModeTracker()
|
||||
fresh.scanReplay('\x1b[>1u')
|
||||
expect(fresh.flags).toBe(1)
|
||||
fresh.scan('\x1b[<u')
|
||||
expect(fresh.flags).toBe(0)
|
||||
|
||||
const ranAndExited = new TerminalKittyKeyboardModeTracker()
|
||||
ranAndExited.scanReplay('\x1b[>1uoutput\x1b[<u')
|
||||
expect(ranAndExited.flags).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
// Why: PTY/SSH chunks can split an escape sequence before its final byte.
|
||||
// Keep parser state far beyond normal sequence lengths while bounding memory.
|
||||
const KITTY_SCAN_TAIL_LIMIT = 4096
|
||||
|
||||
// Why: mirrors xterm's InputHandler cap so a runaway TUI cannot grow the
|
||||
// mirrored stacks unboundedly while the renderer's own stacks stay at 16.
|
||||
const KITTY_STACK_LIMIT = 16
|
||||
|
||||
/**
|
||||
* Mirrors the kitty keyboard protocol flag state (CSI > u push, CSI < u pop,
|
||||
* CSI = u set) by scanning the raw PTY output stream, replicating xterm's
|
||||
* exact stack/screen algorithm including the per-screen flag slots swapped by
|
||||
* DECSET/DECRST 47/1047/1049, the full reset on RIS, and the soft reset on
|
||||
* DECSTR (CSI ! p).
|
||||
*
|
||||
* Why a mirror instead of reading xterm's internal state: Orca defensively
|
||||
* wipes the renderer terminal's kitty flags at moments when the TUI may have
|
||||
* died (Ctrl+C interrupts, reattach resets) while the TUI is usually still
|
||||
* alive and expecting protocol-encoded input. This tracker is fed only by
|
||||
* application output, so it reflects what the *application* negotiated,
|
||||
* independent of renderer-side defensive writes. The daemon reuses it to
|
||||
* carry flags into snapshots (xterm's SerializeAddon does not serialize kitty
|
||||
* state).
|
||||
*/
|
||||
export class TerminalKittyKeyboardModeTracker {
|
||||
private scanTail = ''
|
||||
private currentFlags = 0
|
||||
private mainFlags = 0
|
||||
private altFlags = 0
|
||||
private mainStack: number[] = []
|
||||
private altStack: number[] = []
|
||||
private alternateScreenActive = false
|
||||
|
||||
/** Current effective kitty keyboard flags (0 = protocol inactive). */
|
||||
get flags(): number {
|
||||
return this.currentFlags
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.scanTail = ''
|
||||
this.currentFlags = 0
|
||||
this.mainFlags = 0
|
||||
this.altFlags = 0
|
||||
this.mainStack = []
|
||||
this.altStack = []
|
||||
this.alternateScreenActive = false
|
||||
}
|
||||
|
||||
scan(data: string): void {
|
||||
this.scanInternal(data, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan bytes replayed from a retained history window (reattach payloads,
|
||||
* relay replays, daemon snapshots). Replays can redeliver the application's
|
||||
* one-time CSI > u push — applying it with stack semantics on every delivery
|
||||
* grows the mirrored stack, so the TUI's eventual single pop lands on a
|
||||
* stale frame and Option chords stay kitty-encoded in a plain shell. Pushes
|
||||
* seen during replay therefore apply as idempotent sets. Known limit: a
|
||||
* NESTED push/push/pop inside the window collapses to flags 0 on the pop —
|
||||
* unavoidable stackless-replay tradeoff (a redelivered push is byte-wise
|
||||
* indistinguishable from a new one); real TUIs push once at startup.
|
||||
*/
|
||||
scanReplay(data: string): void {
|
||||
this.scanInternal(data, true)
|
||||
}
|
||||
|
||||
private scanInternal(data: string, replay: boolean): void {
|
||||
const input = this.scanTail + data
|
||||
this.scanTail = this.extractScanTail(input)
|
||||
// oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars
|
||||
const kittyModeRe = /\x1bc|(?:\x1b\[|\x9b)(?:!p|\?([0-9;]+)([hl])|([<>=])([0-9;]*)u)/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = kittyModeRe.exec(input)) !== null) {
|
||||
if (match[0] === '\x1bc') {
|
||||
// RIS resets kitty state and returns to the main screen.
|
||||
const tail = this.scanTail
|
||||
this.reset()
|
||||
this.scanTail = tail
|
||||
continue
|
||||
}
|
||||
if (match[0].endsWith('!p')) {
|
||||
this.applySoftReset()
|
||||
continue
|
||||
}
|
||||
if (match[1] !== undefined) {
|
||||
this.applyScreenSwitch(match[1], match[2] === 'h')
|
||||
continue
|
||||
}
|
||||
this.applyKittySequence(match[3], match[4] ?? '', replay)
|
||||
}
|
||||
}
|
||||
|
||||
private applySoftReset(): void {
|
||||
// Why: xterm's DECSTR (CSI ! p) wipes kitty flags and stacks for both
|
||||
// screens via coreService.reset but does not switch buffers — mirror that
|
||||
// so a soft-resetting TUI stops receiving kitty-encoded Option chords.
|
||||
this.currentFlags = 0
|
||||
this.mainFlags = 0
|
||||
this.altFlags = 0
|
||||
this.mainStack = []
|
||||
this.altStack = []
|
||||
}
|
||||
|
||||
private applyScreenSwitch(params: string, enabled: boolean): void {
|
||||
for (const rawParam of params.split(';')) {
|
||||
const param = Number(rawParam)
|
||||
if (param !== 47 && param !== 1047 && param !== 1049) {
|
||||
continue
|
||||
}
|
||||
// Why: xterm swaps the current flags with the inactive screen's slot on
|
||||
// every 47/1047/1049 transition, without an already-active guard —
|
||||
// mirror it exactly so this state matches what the renderer encodes.
|
||||
if (enabled) {
|
||||
this.mainFlags = this.currentFlags
|
||||
this.currentFlags = this.altFlags
|
||||
this.alternateScreenActive = true
|
||||
} else {
|
||||
this.altFlags = this.currentFlags
|
||||
this.currentFlags = this.mainFlags
|
||||
this.alternateScreenActive = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyKittySequence(prefix: string, params: string, replay: boolean): void {
|
||||
const parsed = params.split(';').map((entry) => Number(entry))
|
||||
const stack = this.alternateScreenActive ? this.altStack : this.mainStack
|
||||
if (prefix === '>') {
|
||||
if (!replay) {
|
||||
if (stack.length >= KITTY_STACK_LIMIT) {
|
||||
stack.shift()
|
||||
}
|
||||
stack.push(this.currentFlags)
|
||||
}
|
||||
this.currentFlags = parsed[0] || 0
|
||||
return
|
||||
}
|
||||
if (prefix === '<') {
|
||||
const count = Math.max(1, parsed[0] || 1)
|
||||
for (let i = 0; i < count && stack.length > 0; i++) {
|
||||
this.currentFlags = stack.pop() as number
|
||||
}
|
||||
if (stack.length === 0) {
|
||||
this.currentFlags = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
const flags = parsed[0] || 0
|
||||
const mode = parsed.length > 1 && parsed[1] ? parsed[1] : 1
|
||||
if (mode === 1) {
|
||||
this.currentFlags = flags
|
||||
} else if (mode === 2) {
|
||||
this.currentFlags |= flags
|
||||
} else if (mode === 3) {
|
||||
this.currentFlags &= ~flags
|
||||
}
|
||||
}
|
||||
|
||||
private extractScanTail(input: string): string {
|
||||
const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b'))
|
||||
if (start === -1) {
|
||||
return ''
|
||||
}
|
||||
const tail = input.slice(start)
|
||||
if (tail.length > KITTY_SCAN_TAIL_LIMIT) {
|
||||
return ''
|
||||
}
|
||||
if (tail === '\x1b' || tail === '\x1b[' || tail === '\x9b') {
|
||||
return tail
|
||||
}
|
||||
const body = tail.startsWith('\x1b[')
|
||||
? tail.slice(2)
|
||||
: tail.startsWith('\x9b')
|
||||
? tail.slice(1)
|
||||
: null
|
||||
if (body === null) {
|
||||
return ''
|
||||
}
|
||||
return this.isIncompleteSequenceBody(body) ? tail : ''
|
||||
}
|
||||
|
||||
private isIncompleteSequenceBody(body: string): boolean {
|
||||
return body === '!' || /^[<>=?]?[0-9;]*$/.test(body)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user