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
+40
View File
@@ -498,6 +498,46 @@ describe('HeadlessEmulator', () => {
expect(snapshot.rehydrateSequences).toContain('\x1b[?1006h')
expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1002h')
})
it('rehydrates kitty keyboard flags a TUI pushed (CSI > u)', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
// OMP/pi negotiate progressive enhancement with a level-1 push.
await emulator.write('\x1b[>1u')
const snapshot = emulator.getSnapshot()
expect(snapshot.modes.kittyKeyboardFlags).toBe(1)
expect(snapshot.rehydrateSequences).toContain('\x1b[=1;1u')
})
it('omits kitty rehydration after the TUI pops its flags', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b[>1u')
await emulator.write('\x1b[<u')
const snapshot = emulator.getSnapshot()
expect(snapshot.modes.kittyKeyboardFlags).toBe(0)
expect(snapshot.rehydrateSequences).not.toContain('u')
})
it('re-arms kitty flags after the alt-screen switch so they land on the negotiated screen', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b[?1049h\x1b[>1u')
const snapshot = emulator.getSnapshot()
const altScreenIndex = snapshot.rehydrateSequences.indexOf('\x1b[?1049h')
const kittyIndex = snapshot.rehydrateSequences.indexOf('\x1b[=1;1u')
expect(altScreenIndex).toBeGreaterThanOrEqual(0)
expect(kittyIndex).toBeGreaterThan(altScreenIndex)
})
it('drops kitty rehydration after a TUI soft reset (DECSTR)', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b[>1u')
await emulator.write('\x1b[!p')
const snapshot = emulator.getSnapshot()
expect(snapshot.modes.kittyKeyboardFlags).toBe(0)
})
})
describe('dispose', () => {
+15 -1
View File
@@ -4,6 +4,7 @@ import { SerializeAddon } from '@xterm/addon-serialize'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { activateOrcaTerminalUnicodeProvider } from '../../shared/terminal-unicode-provider'
import { advancePartialEscapeTail } from '../../shared/terminal-partial-escape-tail'
import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker'
import { extractLastOscTitle } from '../../shared/agent-detection'
import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges'
import { extractOscScanTail, scanOsc7Uris } from './osc7-uri-extraction'
@@ -36,6 +37,7 @@ export class HeadlessEmulator {
private lastTitle: string | null = null
private oscScanTail = ''
private privateModes = new TerminalPrivateModeTracker()
private kittyKeyboardModes = new TerminalKittyKeyboardModeTracker()
private restoredOscLinks: TerminalOscLinkRange[] = []
// Why: a PTY read can end mid-escape-sequence — those bytes live in xterm's
// parser, not the screen buffer, so serialize() drops them and the next
@@ -97,6 +99,7 @@ export class HeadlessEmulator {
// Why: snapshots combine serialized xterm state with mirrored mouse
// modes. Commit the mirror only after xterm has parsed the same bytes.
this.privateModes.scan(data)
this.kittyKeyboardModes.scan(data)
this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data)
resolve()
})
@@ -124,6 +127,7 @@ export class HeadlessEmulator {
// PTY bursts; queued headless writes can snapshot half-cleared TUI rows.
writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data)
this.privateModes.scan(data)
this.kittyKeyboardModes.scan(data)
this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data)
return true
}
@@ -286,7 +290,8 @@ export class HeadlessEmulator {
sgrMousePixelsMode: this.privateModes.sgrMousePixelsMode,
applicationCursor:
buffer.type === 'normal' ? this.terminal.modes.applicationCursorKeysMode : false,
alternateScreen: buffer.type === 'alternate'
alternateScreen: buffer.type === 'alternate',
kittyKeyboardFlags: this.kittyKeyboardModes.flags
}
}
@@ -326,6 +331,15 @@ export class HeadlessEmulator {
} else if (modes.sgrMouseMode) {
seqs.push('\x1b[?1006h')
}
// Why: kitty keyboard flags are per-screen state SerializeAddon cannot
// capture; without re-arming them, the still-running TUI keeps expecting
// protocol-encoded keys the restored client no longer sends. `=` (set)
// instead of `>` (push) so repeated replays cannot grow the flag stack.
// Emitted after the alt-screen switch above so the flags land on the
// screen the TUI negotiated them on.
if (modes.kittyKeyboardFlags && modes.kittyKeyboardFlags > 0) {
seqs.push(`\x1b[=${modes.kittyKeyboardFlags};1u`)
}
return seqs.join('')
}
}
+4
View File
@@ -47,6 +47,10 @@ export type TerminalModes = {
sgrMousePixelsMode?: boolean
applicationCursor: boolean
alternateScreen: boolean
/** Kitty keyboard protocol flags (CSI > u) the session's TUI negotiated;
* 0/absent when inactive. SerializeAddon cannot capture these, so the
* emulator mirrors them for snapshot rehydration. */
kittyKeyboardFlags?: number
}
// The on-disk checkpoint.json shape lives in daemon-checkpoint-file.ts (it
@@ -43,6 +43,7 @@ import {
serializeTerminalLayout
} from './layout-serialization'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker'
import {
applyExpandedLayoutTo,
cancelPendingPaneSizeRefreshFrames,
@@ -296,6 +297,10 @@ export default function TerminalPane({
// read this map at dispatch time to pass cwd into splitPane.
const paneCwdRef = useRef<Map<number, { cwd: string; confirmed: boolean }>>(new Map())
const paneMode2031Ref = useRef<Map<number, boolean>>(new Map())
// Why: per-pane mirror of the kitty keyboard flags negotiated by the pane's
// application (fed from PTY output in pty-connection). The keyboard policy
// reads it to encode Option chords as kitty CSI-u for opted-in TUIs.
const paneKittyKeyboardModesRef = useRef<Map<number, TerminalKittyKeyboardModeTracker>>(new Map())
const paneLastThemeModeRef = useRef<Map<number, 'dark' | 'light'>>(new Map())
const panePtyBindingsRef = useRef<Map<number, IDisposable>>(new Map())
// Why: tracks panes currently replaying recorded PTY bytes into xterm
@@ -1472,6 +1477,7 @@ export default function TerminalPane({
paneTransportsRef,
paneCwdRef,
paneMode2031Ref,
paneKittyKeyboardModesRef,
paneLastThemeModeRef,
panePtyBindingsRef,
replayingPanesRef,
@@ -1696,6 +1702,7 @@ export default function TerminalPane({
startup: { command: 'codex' },
paneTransportsRef,
paneMode2031Ref,
paneKittyKeyboardModesRef,
paneLastThemeModeRef,
replayingPanesRef,
isActiveRef,
@@ -1795,6 +1802,7 @@ export default function TerminalPane({
searchOpenRef,
searchStateRef,
macOptionAsAltRef,
paneKittyKeyboardModesRef,
keybindings,
terminalShortcutPolicy: settings?.terminalShortcutPolicy ?? 'orca-first'
})
@@ -14,7 +14,12 @@ import {
type TerminalShortcutPolicy
} from '../../../../shared/keybindings'
import type { PaneCwdMap } from './resolve-split-cwd'
import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker'
import { keyboardEventBelongsToScope } from './terminal-keyboard-scope'
import {
getLayoutBaseCharacterForCode,
prefetchLayoutBaseCharacters
} from '@/lib/keyboard-layout/layout-base-character'
import { normalizeSelectedTextForFileSearch } from '@/lib/file-search-selection'
import { isFindQueryTooLarge } from '@/lib/find-query-bounds'
import { handleEmptyFloatingWorkspacePanelCloseShortcut } from '@/lib/floating-workspace-terminal-actions'
@@ -161,6 +166,7 @@ type KeyboardHandlersDeps = {
searchOpenRef: React.RefObject<boolean>
searchStateRef: React.RefObject<SearchState>
macOptionAsAltRef: React.RefObject<MacOptionAsAlt>
paneKittyKeyboardModesRef?: React.RefObject<Map<number, TerminalKittyKeyboardModeTracker>>
keybindings?: KeybindingOverrides
terminalShortcutPolicy?: TerminalShortcutPolicy
}
@@ -194,6 +200,7 @@ export function useTerminalKeyboardShortcuts({
searchOpenRef,
searchStateRef,
macOptionAsAltRef,
paneKittyKeyboardModesRef,
keybindings,
terminalShortcutPolicy = 'orca-first'
}: KeyboardHandlersDeps): void {
@@ -206,6 +213,12 @@ export function useTerminalKeyboardShortcuts({
const isWindows = navigator.userAgent.includes('Windows')
const shortcutPlatform: KeybindingPlatform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux'
// Why: kitty Option-chord encoding resolves base keys through the async
// KeyboardLayoutMap; prefetch so the map is cached before the first chord.
if (isMac) {
prefetchLayoutBaseCharacters()
}
// Why: KeyboardEvent.location on a character key (e.g. Period) always
// reports that key's own position (0 = standard), not which modifier is
// held. To distinguish left vs right Option, we record the Option key's
@@ -293,6 +306,17 @@ export function useTerminalKeyboardShortcuts({
})
}
// Why: the pane's TUI opted into kitty keyboard reporting via CSI > u;
// the tracker mirrors that from PTY output so the policy can encode
// Option chords the way the application negotiated.
const isKittyKeyboardActivePane = (): boolean => {
const activePane = manager.getActivePane() ?? manager.getPanes()[0]
if (!activePane) {
return false
}
return (paneKittyKeyboardModesRef?.current.get(activePane.id)?.flags ?? 0) > 0
}
const action = resolveTerminalShortcutAction(
e,
isMac,
@@ -300,7 +324,9 @@ export function useTerminalKeyboardShortcuts({
optionKeyLocation,
isWindows,
keybindings,
isLocalWindowsConptyPane
isLocalWindowsConptyPane,
isKittyKeyboardActivePane,
getLayoutBaseCharacterForCode
)
if (!action) {
return
@@ -537,6 +563,7 @@ export function useTerminalKeyboardShortcuts({
searchOpenRef,
searchStateRef,
macOptionAsAltRef,
paneKittyKeyboardModesRef,
keybindings,
terminalShortcutPolicy,
tabId,
@@ -7,6 +7,7 @@ import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-
import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery'
import type { SetupSplitDirection, TuiAgent } from '../../../../shared/types'
import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume'
import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker'
export type PtyConnectionDeps = {
tabId: string
@@ -37,6 +38,10 @@ export type PtyConnectionDeps = {
restoredPtyIdByLeafId?: Record<string, string>
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
paneMode2031Ref: React.RefObject<Map<number, boolean>>
/** Per-pane mirror of the kitty keyboard flags the pane's application
* negotiated. Fed from PTY output here; read by the keyboard policy. */
paneKittyKeyboardModesRef: React.RefObject<Map<number, TerminalKittyKeyboardModeTracker>>
paneLastThemeModeRef: React.RefObject<Map<number, TerminalColorSchemeMode>>
replayingPanesRef: ReplayingPanesRef
restoredViewportBlankingPanesRef?: RestoredViewportBlankingPanesRef
@@ -486,6 +486,7 @@ function createDeps(overrides: Record<string, unknown> = {}) {
restoredPtyIdByLeafId: {},
paneTransportsRef: { current: new Map() },
paneMode2031Ref: { current: new Map() },
paneKittyKeyboardModesRef: { current: new Map() },
paneLastThemeModeRef: { current: new Map() },
replayingPanesRef: { current: new Map() },
isActiveRef: { current: true },
@@ -921,6 +922,32 @@ describe('connectPanePty', () => {
)
})
// Why: a late exit from a replaced PTY takes the stale-transport early
// return in onExit and skips the kitty mirror reset there — a fresh spawn
// must therefore reset the reused per-pane tracker itself, or a
// restart-in-place leaks the old TUI's kitty flags into a fresh shell.
it('resets a stale kitty keyboard mirror when spawning a fresh PTY', async () => {
const { connectPanePty } = await import('./pty-connection')
const { TerminalKittyKeyboardModeTracker } =
await import('../../../../shared/terminal-kitty-keyboard-mode-tracker')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
const staleTracker = new TerminalKittyKeyboardModeTracker()
staleTracker.scan('\x1b[>1u')
expect(staleTracker.flags).toBe(1)
// Why: a unique tab id keeps this pane's key clear of pendingSpawnByPaneKey
// entries from other tests, so the connect deterministically fresh-spawns.
const deps = createDeps({
tabId: 'tab-kitty-fresh-spawn',
paneKittyKeyboardModesRef: { current: new Map([[91, staleTracker]]) }
})
connectPanePty(createPane(91) as never, createManager(91) as never, deps as never)
await flushAsyncTicks()
expect(staleTracker.flags).toBe(0)
})
it('threads the resolved local project runtime into IPC terminal transport options', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
@@ -9,6 +9,7 @@ import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { useAppStore } from '@/store'
import { getWorktreeMapFromState } from '@/store/selectors'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker'
import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host'
import { createTerminalZeroDimensionsMessage } from '../../../../shared/terminal-zero-dimensions-diagnostic'
import { parseTerminalOscColorQuery } from '../../../../shared/terminal-osc-color-reply'
@@ -1125,6 +1126,19 @@ export function connectPanePty(
// Why: paneKey crosses PTY env, hook IPC, retained rows, and reload/replay.
// Use the stable layout leaf UUID, not the renderer-local numeric pane id.
const cacheKey = makePaneKey(deps.tabId, pane.leafId)
// Why: mirrors the kitty keyboard flags the pane's application negotiates.
// Fed only from application output (live PTY bytes + daemon replay
// payloads), never from renderer-generated resets, so it reflects what the
// application expects even after defensive renderer-side kitty wipes.
const kittyKeyboardModes = (() => {
const existing = deps.paneKittyKeyboardModesRef.current.get(pane.id)
if (existing) {
return existing
}
const created = new TerminalKittyKeyboardModeTracker()
deps.paneKittyKeyboardModesRef.current.set(pane.id, created)
return created
})()
const getSleepingRecordForPane = (
state: ReturnType<typeof useAppStore.getState>
): { paneKey: string; record: SleepingAgentSessionRecord } | null => {
@@ -2178,6 +2192,9 @@ export function connectPanePty(
handledExitPtyId = ptyId
agentCompletionCoordinator.dispose()
clearPanePtyFitBinding()
// Why: the negotiating application died with its PTY; any replacement
// session starts with kitty keyboard flags at zero.
kittyKeyboardModes.reset()
const isSuppressedExit = deps.consumeSuppressedPtyExit(ptyId)
if (!isSuppressedExit) {
deps.clearExitedPanePtyLayoutBinding(pane.id, ptyId)
@@ -3819,6 +3836,11 @@ export function connectPanePty(
): Promise<string | null> => {
clearPaneMode2031State()
clearHiddenOutputRestoreState()
// Why: a fresh spawn is a new process with kitty keyboard flags at
// zero. The exit-handler reset alone is not enough: a late exit from a
// replaced PTY takes the stale-transport early return and skips it, so
// a restart-in-place would leak the old TUI's flags into a fresh shell.
kittyKeyboardModes.reset()
prepareFreshShellViewportForSpawn(options)
if (connectionId && startupOverride?.command) {
// Why: SSH providers use `command` only as spawn metadata; the renderer
@@ -4183,6 +4205,11 @@ export function connectPanePty(
// must clear a stale agent signal from an earlier payload.
rememberReattachPayloadAgentSignal(data, { fullScreenReplay: clearBeforeReplay })
}
// Why: replayed application bytes carry the live TUI's kitty keyboard
// negotiation; the mirror must re-arm from them after a reload. Replay
// semantics: relay reconnects redeliver the same window, so pushes
// apply as sets to keep the mirrored stack from accumulating frames.
kittyKeyboardModes.scanReplay(data)
await writeReplayDataAsync(data)
if (clearBeforeReplay || data.length > 0) {
await writeReplayDataAsync(reattachReplayResetSequence(data))
@@ -4466,6 +4493,10 @@ export function connectPanePty(
foreground: boolean,
opts?: { hiddenStartupRendererQuery?: boolean }
): void {
// Why: every application byte funnels through here (foreground, hidden,
// and background writes), so this is the one place the kitty keyboard
// mirror observes the pane's protocol negotiation.
kittyKeyboardModes.scan(data)
if (foreground) {
resetHiddenOutputRestoreIfPtyChanged()
resetHiddenRendererRiskState()
@@ -5571,6 +5602,10 @@ export function connectPanePty(
}
}
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
// Why: the daemon snapshot's rehydrate preamble carries the live
// session's kitty keyboard flags; re-arm the mirror from it so Option
// chords keep their kitty encoding after a window reload.
kittyKeyboardModes.scanReplay(connectResult.snapshot)
writeReplayData(connectResult.snapshot)
// Snapshot reattach keeps a live session, so avoid the broader mode
// reset. We only drop renderer-owned state that should not leak from
@@ -5597,6 +5632,10 @@ export function connectPanePty(
// duplication. The reattach reset clears renderer-owned state without
// tearing down the still-running TUI's live modes.
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
// Why: raw relay replay contains the application's own kitty pushes
// when they fall inside the retained window; re-arm the mirror with
// replay (set) semantics so redelivery cannot grow the stack.
kittyKeyboardModes.scanReplay(connectResult.replay)
writeReplayData(connectResult.replay)
writeReplayData(reattachReplayResetSequence(connectResult.replay))
sendFocusedReattachFocusInAfterReplay()
@@ -5624,6 +5663,9 @@ export function connectPanePty(
// crashed TUI (e.g. Claude's \e[?1004h) left in the scrollback, so
// reset them to match the fresh shell's expectations.
writeReplayData(POST_REPLAY_MODE_RESET)
// Why: the dead run's scrollback was never scanned, and any kitty
// flags it pushed died with it — the fresh shell starts at zero.
kittyKeyboardModes.reset()
consumeRestoredViewportBlankingMarker()
writeFreshShellViewportBlanking()
if (!isRemoteRuntimePtyId(ptyId)) {
@@ -510,3 +510,174 @@ describe('resolveTerminalShortcutAction', () => {
).toEqual({ type: 'splitActivePane', direction: 'horizontal' })
})
})
describe('kitty keyboard protocol panes', () => {
const kittyActive = (): boolean => true
const kittyInactive = (): boolean => false
const resolveKitty = (
input: TerminalShortcutEvent,
macOptionAsAlt: 'true' | 'false' | 'left' | 'right' = 'false',
optionKeyLocation = 0,
active: () => boolean = kittyActive
) =>
resolveTerminalShortcutAction(
input,
true,
macOptionAsAlt,
optionKeyLocation,
false,
undefined,
undefined,
active
)
it('encodes Option+letter as kitty CSI-u with the physical base key in compose mode', () => {
// macOS composition reports key='π' for Option+P on ABC/compose layouts;
// OMP binds alt+p (temporary model) and alt+m (model selector).
expect(resolveKitty(event({ key: 'π', code: 'KeyP', altKey: true }))).toEqual({
type: 'sendInput',
data: '\x1b[112;3u'
})
expect(resolveKitty(event({ key: 'µ', code: 'KeyM', altKey: true }))).toEqual({
type: 'sendInput',
data: '\x1b[109;3u'
})
})
it('includes shift in the kitty modifier field', () => {
expect(resolveKitty(event({ key: '∏', code: 'KeyP', altKey: true, shiftKey: true }))).toEqual({
type: 'sendInput',
data: '\x1b[112;4u'
})
})
it('encodes Option+digit and mapped Option+punctuation', () => {
expect(resolveKitty(event({ key: '¡', code: 'Digit1', altKey: true }))).toEqual({
type: 'sendInput',
data: '\x1b[49;3u'
})
expect(resolveKitty(event({ key: '≥', code: 'Period', altKey: true }))).toEqual({
type: 'sendInput',
data: '\x1b[46;3u'
})
})
it('exempts dead keys so Option composition still starts', () => {
expect(resolveKitty(event({ key: 'Dead', code: 'KeyE', altKey: true }))).toBeNull()
})
it('defers to xterm in macOptionAsAlt=true mode (native kitty encoding is correct there)', () => {
expect(resolveKitty(event({ key: 'p', code: 'KeyP', altKey: true }), 'true')).toBeNull()
})
it('keeps shift+Option composition untouched in non-kitty panes', () => {
expect(
resolveKitty(
event({ key: '∏', code: 'KeyP', altKey: true, shiftKey: true }),
'false',
0,
kittyInactive
)
).toBeNull()
// Meta-side Option in 'left' mode stays shift-exempt without kitty.
expect(
resolveKitty(
event({ key: '∏', code: 'KeyP', altKey: true, shiftKey: true }),
'left',
1,
kittyInactive
)
).toBeNull()
})
it('keeps compose-mode behavior unchanged when the pane is not kitty-active', () => {
expect(
resolveKitty(event({ key: 'π', code: 'KeyP', altKey: true }), 'false', 0, kittyInactive)
).toBeNull()
// The B/F/D readline patches still apply without kitty.
expect(
resolveKitty(event({ key: '∫', code: 'KeyB', altKey: true }), 'false', 0, kittyInactive)
).toEqual({ type: 'sendInput', data: '\x1bb' })
})
it('encodes the compose-side Option key as kitty CSI-u in left/right modes', () => {
// In 'left' mode the right Option normally composes; a kitty pane asked
// for modifier-accurate keys, so it gets alt-encoded too.
expect(resolveKitty(event({ key: '¬', code: 'KeyL', altKey: true }), 'left', 2)).toEqual({
type: 'sendInput',
data: '\x1b[108;3u'
})
// The designated meta side upgrades from legacy Esc+letter to CSI-u.
expect(resolveKitty(event({ key: '¬', code: 'KeyL', altKey: true }), 'left', 1)).toEqual({
type: 'sendInput',
data: '\x1b[108;3u'
})
})
it('yields Alt+Arrow and Alt+Backspace to xterm kitty encoding', () => {
expect(resolveKitty(event({ key: 'ArrowLeft', code: 'ArrowLeft', altKey: true }))).toBeNull()
expect(resolveKitty(event({ key: 'Backspace', code: 'Backspace', altKey: true }))).toBeNull()
// Without kitty, the readline translations still apply.
expect(
resolveKitty(
event({ key: 'ArrowLeft', code: 'ArrowLeft', altKey: true }),
'false',
0,
kittyInactive
)
).toEqual({ type: 'sendInput', data: '\x1bb' })
expect(
resolveKitty(
event({ key: 'Backspace', code: 'Backspace', altKey: true }),
'false',
0,
kittyInactive
)
).toEqual({ type: 'sendInput', data: '\x1b\x7f' })
})
it('does not intercept Option chords with Cmd or Ctrl held', () => {
expect(resolveKitty(event({ key: 'π', code: 'KeyP', altKey: true, metaKey: true }))).toBeNull()
expect(resolveKitty(event({ key: 'π', code: 'KeyP', altKey: true, ctrlKey: true }))).toBeNull()
})
it('resolves the kitty base key through the active layout map when provided', () => {
const resolveWithLayout = (
input: TerminalShortcutEvent,
layoutBaseCharacterForCode: (code: string) => string | undefined
) =>
resolveTerminalShortcutAction(
input,
true,
'false',
0,
false,
undefined,
undefined,
kittyActive,
layoutBaseCharacterForCode
)
// AZERTY types M at the physical Semicolon position; the layout map must
// win over the US punctuation table so the chord reports alt+m, not alt+;.
const azerty = (code: string): string | undefined => (code === 'Semicolon' ? 'm' : undefined)
expect(resolveWithLayout(event({ key: 'µ', code: 'Semicolon', altKey: true }), azerty)).toEqual(
{ type: 'sendInput', data: '\x1b[109;3u' }
)
// Colemak types P at the physical KeyR position.
const colemak = (code: string): string | undefined => (code === 'KeyR' ? 'p' : undefined)
expect(resolveWithLayout(event({ key: 'π', code: 'KeyR', altKey: true }), colemak)).toEqual({
type: 'sendInput',
data: '\x1b[112;3u'
})
// Falls back to the US table when the layout map has no entry.
const empty = (): string | undefined => undefined
expect(resolveWithLayout(event({ key: 'π', code: 'KeyP', altKey: true }), empty)).toEqual({
type: 'sendInput',
data: '\x1b[112;3u'
})
})
})
@@ -42,6 +42,26 @@ export type TerminalShortcutAction =
| { type: 'scrollViewport'; position: 'top' | 'bottom' }
| { type: 'sendInput'; data: string }
/** Kitty keyboard protocol modifier field: 1 + shift(1) + alt(2). */
function kittyAltModifiers(shiftKey: boolean): number {
return shiftKey ? 4 : 3
}
/** The un-shifted ASCII character for a physical key code (letters, digits,
* and the punctuation map above), or undefined for unmapped codes. */
function resolveUnshiftedCharacterForCode(code: string | undefined): string | undefined {
if (!code) {
return undefined
}
if (code.startsWith('Key') && code.length === 4) {
return code.charAt(3).toLowerCase()
}
if (code.startsWith('Digit') && code.length === 6) {
return code.charAt(5)
}
return PUNCTUATION_CODE_MAP[code]
}
/**
* Resolves terminal keyboard events before xterm receives them.
* Keeps configurable Orca shortcuts and terminal byte fallbacks in one
@@ -58,7 +78,16 @@ export function resolveTerminalShortcutAction(
// ConPTY (PowerShell/cmd via PSReadLine). Only consulted for the Ctrl+Arrow
// word-nav rule below, so the execution-host lookup it performs stays off the
// hot path for every other keystroke.
isLocalWindowsConptyPane?: () => boolean
isLocalWindowsConptyPane?: () => boolean,
// Why: lazily reports whether the active pane's application has enabled the
// kitty keyboard protocol (CSI > u). Gates the Option-as-Alt compensation
// below on the application's own opt-in, so shells keep composition.
isKittyKeyboardActivePane?: () => boolean,
// Why: kitty key reports carry the key's unshifted codepoint in the active
// layout; the physical-code table above is US QWERTY and reports the wrong
// key on Dvorak/Colemak/AZERTY-class layouts. This resolves through
// Chromium's KeyboardLayoutMap when it is available.
layoutBaseCharacterForCode?: (code: string) => string | undefined
): TerminalShortcutAction | null {
const platform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux'
if (!event.repeat) {
@@ -184,6 +213,11 @@ export function resolveTerminalShortcutAction(
!event.shiftKey &&
event.key === 'Backspace'
) {
// Why: a kitty-protocol TUI binds the CSI 127;3u that xterm's kitty
// encoder emits natively; the legacy \x1b\x7f fallback would bypass it.
if (isKittyKeyboardActivePane?.()) {
return null
}
return { type: 'sendInput', data: '\x1b\x7f' }
}
@@ -194,6 +228,11 @@ export function resolveTerminalShortcutAction(
!event.shiftKey &&
(event.key === 'ArrowLeft' || event.key === 'ArrowRight')
) {
// Why: a kitty-protocol TUI binds alt+arrow via the CSI 1;3D / 1;3C that
// xterm's kitty encoder emits natively; \eb/\ef would reach it as alt+b/f.
if (isKittyKeyboardActivePane?.()) {
return null
}
// Why: xterm.js would otherwise emit \e[1;3D / \e[1;3C for option/alt+arrow,
// which default readline (bash, zsh) does not bind to backward-word /
// forward-word — so word navigation silently doesn't work without a custom
@@ -240,46 +279,61 @@ export function resolveTerminalShortcutAction(
//
// The handling depends on the macOptionAsAlt setting (mirrors Ghostty):
// - 'true': xterm handles all Option as Meta natively; nothing to do here.
// - kitty-protocol pane (any other mode): the TUI asked for modifier-accurate
// keys, so every Option chord is encoded as kitty CSI-u with the physical
// base key (Option+P → \x1b[112;3u). Without this, xterm's kitty encoder
// reports the composed codepoint (alt+π), which no TUI binds — the chord
// neither triggers the hotkey nor types the character (issue: OMP Alt+P /
// Alt+M dead on compose layouts). Dead keys are exempt so composition
// (Option+E → ´) keeps working.
// - 'false': compensate the three most critical readline shortcuts (B/F/D).
// - 'left'/'right': the designated Option key acts as full Meta (emit Esc+
// for any single letter); the other key composes, with B/F/D compensated.
if (isMac && !event.metaKey && !event.ctrlKey && event.altKey && !event.shiftKey) {
// Why: event.location on a character key reports that key's position (always
// 0 for standard keys), NOT which modifier is held. The caller must track
// the Option key's own keydown location and pass it as optionKeyLocation.
const isLeftOption = optionKeyLocation === 1
const isRightOption = optionKeyLocation === 2
const shouldActAsMeta =
(macOptionAsAlt === 'left' && isLeftOption) || (macOptionAsAlt === 'right' && isRightOption)
if (shouldActAsMeta) {
// Emit Esc+key for letter keys (e.g. Option+B → \x1bb)
if (event.code?.startsWith('Key') && event.code.length === 4) {
const letter = event.code.charAt(3).toLowerCase()
return { type: 'sendInput', data: `\x1b${letter}` }
}
// Emit Esc+digit for number keys (e.g. Option+1 → \x1b1)
if (event.code?.startsWith('Digit') && event.code.length === 6) {
return { type: 'sendInput', data: `\x1b${event.code.charAt(5)}` }
}
const punct = event.code ? PUNCTUATION_CODE_MAP[event.code] : undefined
if (punct) {
return { type: 'sendInput', data: `\x1b${punct}` }
if (isMac && !event.metaKey && !event.ctrlKey && event.altKey && macOptionAsAlt !== 'true') {
if (event.key !== 'Dead' && isKittyKeyboardActivePane?.()) {
const baseCharacter =
(event.code ? layoutBaseCharacterForCode?.(event.code) : undefined) ??
resolveUnshiftedCharacterForCode(event.code)
if (baseCharacter) {
return {
type: 'sendInput',
data: `\x1b[${baseCharacter.codePointAt(0)};${kittyAltModifiers(event.shiftKey)}u`
}
}
}
// In 'false', 'left', or 'right' mode, the compose-side Option key still
// needs the three most critical readline shortcuts patched.
if (macOptionAsAlt !== 'true' && !shouldActAsMeta) {
if (event.code === 'KeyB') {
return { type: 'sendInput', data: '\x1bb' }
if (!event.shiftKey) {
// Why: event.location on a character key reports that key's position
// (always 0 for standard keys), NOT which modifier is held. The caller
// must track the Option key's own keydown location and pass it as
// optionKeyLocation.
const isLeftOption = optionKeyLocation === 1
const isRightOption = optionKeyLocation === 2
const shouldActAsMeta =
(macOptionAsAlt === 'left' && isLeftOption) || (macOptionAsAlt === 'right' && isRightOption)
if (shouldActAsMeta) {
// Emit Esc+key (e.g. Option+B → \x1bb) for letters, digits, and
// mapped punctuation.
const character = resolveUnshiftedCharacterForCode(event.code)
if (character) {
return { type: 'sendInput', data: `\x1b${character}` }
}
}
if (event.code === 'KeyF') {
return { type: 'sendInput', data: '\x1bf' }
}
if (event.code === 'KeyD') {
return { type: 'sendInput', data: '\x1bd' }
// In 'false', 'left', or 'right' mode, the compose-side Option key still
// needs the three most critical readline shortcuts patched.
if (!shouldActAsMeta) {
if (event.code === 'KeyB') {
return { type: 'sendInput', data: '\x1bb' }
}
if (event.code === 'KeyF') {
return { type: 'sendInput', data: '\x1bf' }
}
if (event.code === 'KeyD') {
return { type: 'sendInput', data: '\x1bd' }
}
}
}
}
@@ -2,6 +2,7 @@
import { useEffect, useRef } from 'react'
import type { IDisposable, Terminal } from '@xterm/xterm'
import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types'
import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker'
import {
PaneManager,
type PaneExternalDropHandler,
@@ -242,6 +243,7 @@ type UseTerminalPaneLifecycleDeps = {
* context-menu split handlers can read it synchronously for cache hits. */
paneCwdRef: React.RefObject<PaneCwdMap>
paneMode2031Ref: React.RefObject<Map<number, boolean>>
paneKittyKeyboardModesRef: React.RefObject<Map<number, TerminalKittyKeyboardModeTracker>>
paneLastThemeModeRef: React.RefObject<Map<number, 'dark' | 'light'>>
panePtyBindingsRef: React.RefObject<Map<number, IDisposable>>
replayingPanesRef: ReplayingPanesRef
@@ -516,6 +518,7 @@ export function useTerminalPaneLifecycle({
paneTransportsRef,
paneCwdRef,
paneMode2031Ref,
paneKittyKeyboardModesRef,
paneLastThemeModeRef,
panePtyBindingsRef,
replayingPanesRef,
@@ -736,6 +739,7 @@ export function useTerminalPaneLifecycle({
startup: startupWithSetupSplitWait,
paneTransportsRef,
paneMode2031Ref,
paneKittyKeyboardModesRef,
paneLastThemeModeRef,
replayingPanesRef,
restoredViewportBlankingPanesRef,
@@ -1201,6 +1205,7 @@ export function useTerminalPaneLifecycle({
mode2031DisposablesRef.current.delete(paneId)
}
paneMode2031Ref.current.delete(paneId)
paneKittyKeyboardModesRef.current.delete(paneId)
paneLastThemeModeRef.current.delete(paneId)
const osc52Disposable = osc52DisposablesRef.current.get(paneId)
if (osc52Disposable) {
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
_setLayoutMapForTests,
getLayoutBaseCharacterForCode,
normalizeLayoutBaseCharacter
} from './layout-base-character'
describe('normalizeLayoutBaseCharacter', () => {
it('accepts a single printable codepoint, lowercased', () => {
expect(normalizeLayoutBaseCharacter('p')).toBe('p')
expect(normalizeLayoutBaseCharacter('P')).toBe('p')
expect(normalizeLayoutBaseCharacter('ö')).toBe('ö')
expect(normalizeLayoutBaseCharacter(';')).toBe(';')
})
it('rejects empty, named-key, multi-codepoint, and control values', () => {
expect(normalizeLayoutBaseCharacter(undefined)).toBeUndefined()
expect(normalizeLayoutBaseCharacter('')).toBeUndefined()
expect(normalizeLayoutBaseCharacter('Dead')).toBeUndefined()
expect(normalizeLayoutBaseCharacter('\t')).toBeUndefined()
expect(normalizeLayoutBaseCharacter(' ')).toBeUndefined()
})
})
describe('getLayoutBaseCharacterForCode', () => {
afterEach(() => {
_setLayoutMapForTests(null)
})
it('returns undefined without a cached map, and resolves through one', () => {
expect(getLayoutBaseCharacterForCode('KeyP')).toBeUndefined()
const azertyEntries = new Map([
['Semicolon', 'm'],
['KeyE', 'Dead']
])
_setLayoutMapForTests({
get: (code) => azertyEntries.get(code),
size: azertyEntries.size
})
expect(getLayoutBaseCharacterForCode('Semicolon')).toBe('m')
expect(getLayoutBaseCharacterForCode('KeyE')).toBeUndefined()
expect(getLayoutBaseCharacterForCode('KeyZ')).toBeUndefined()
})
})
@@ -0,0 +1,78 @@
/**
* Synchronous lookup of the active keyboard layout's base (unshifted)
* character for a physical `KeyboardEvent.code`.
*
* Why: kitty keyboard CSI-u reports must carry the codepoint of the key in
* the *current layout* with no modifiers. Deriving it from the physical code
* alone assumes US QWERTY and reports the wrong key on Dvorak, Colemak,
* AZERTY, QWERTZ, etc. — misfiring TUI hotkeys. Chromium's KeyboardLayoutMap
* is the layout-true source, but it resolves asynchronously, so this module
* prefetches it and refreshes on window focus-in (every macOS layout-switch
* path blurs and refocuses the window; Chromium has no layoutchange event —
* see option-as-alt-probe.ts).
*/
import type { LayoutMapLike } from './detect-option-as-alt'
type NavigatorWithKeyboard = Navigator & {
keyboard?: {
getLayoutMap: () => Promise<LayoutMapLike>
}
}
let cachedLayoutMap: LayoutMapLike | null = null
let focusListenerAttached = false
async function refreshLayoutMap(): Promise<void> {
const keyboard = (window.navigator as NavigatorWithKeyboard).keyboard
if (!keyboard?.getLayoutMap) {
return
}
try {
cachedLayoutMap = await keyboard.getLayoutMap()
} catch {
// Why: getLayoutMap can transiently reject; keep the last known map
// instead of dropping layout awareness mid-session.
}
}
/** Idempotent. Kicks off the initial fetch and keeps the cache fresh across
* layout switches. Call from terminal keyboard setup so the map is resolved
* before the first Option chord. */
export function prefetchLayoutBaseCharacters(): void {
if (focusListenerAttached || typeof window === 'undefined') {
return
}
focusListenerAttached = true
window.addEventListener('focus', () => {
void refreshLayoutMap()
})
void refreshLayoutMap()
}
/** A layout map entry is usable as a kitty base key only if it is a single
* printable codepoint (dead keys report names like 'Dead'; some entries are
* empty). Exposed for tests. */
export function normalizeLayoutBaseCharacter(value: string | undefined): string | undefined {
if (!value) {
return undefined
}
const lowered = value.toLowerCase()
const codePoints = [...lowered]
if (codePoints.length !== 1) {
return undefined
}
const codePoint = lowered.codePointAt(0) as number
return codePoint <= 0x20 ? undefined : lowered
}
/** The active layout's unshifted character for a physical key code, or
* undefined when the map is unavailable or the key has no single printable
* base character (callers fall back to the US table). */
export function getLayoutBaseCharacterForCode(code: string): string | undefined {
return normalizeLayoutBaseCharacter(cachedLayoutMap?.get(code))
}
/** Test-only: replace or clear the cached layout map. */
export function _setLayoutMapForTests(map: LayoutMapLike | null): void {
cachedLayoutMap = map
}
@@ -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)
}
}