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:
Brennan Benson
2026-07-10 13:45:33 -07:00
committed by GitHub
co-authored by Brennan Benson
parent d9b1fbbc07
commit da2c692d31
15 changed files with 889 additions and 36 deletions
@@ -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)
}
}