diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts index 1a7dd6c5abe..0a5832247c6 100644 --- a/src/main/daemon/headless-emulator.test.ts +++ b/src/main/daemon/headless-emulator.test.ts @@ -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[ { + 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', () => { diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 74381d3d543..7ce69db0cc8 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -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('') } } diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 10d27f2c8e0..6c1c7c281a0 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 003188d6c16..297b28da36d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -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>(new Map()) const paneMode2031Ref = useRef>(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>(new Map()) const paneLastThemeModeRef = useRef>(new Map()) const panePtyBindingsRef = useRef>(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' }) diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index ed78a5d6500..b5bf46b7d0c 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -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 searchStateRef: React.RefObject macOptionAsAltRef: React.RefObject + paneKittyKeyboardModesRef?: React.RefObject> 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, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 01c98e84557..70abd4fae3f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -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 paneTransportsRef: React.RefObject> paneMode2031Ref: React.RefObject> + /** 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> + paneLastThemeModeRef: React.RefObject> replayingPanesRef: ReplayingPanesRef restoredViewportBlankingPanesRef?: RestoredViewportBlankingPanesRef diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 662e46aae52..72aac62a8e8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -486,6 +486,7 @@ function createDeps(overrides: Record = {}) { 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() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index e86afd8018e..94fd3774fa4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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 ): { 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 => { 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)) { diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts index 0f0c4cec3d1..8bf8159d9d1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts @@ -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' + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts index 35cc6c00ffe..69bc914d048 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts @@ -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' } + } } } } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 1f590985dce..7999db503ca 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -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 paneMode2031Ref: React.RefObject> + paneKittyKeyboardModesRef: React.RefObject> paneLastThemeModeRef: React.RefObject> panePtyBindingsRef: React.RefObject> 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) { diff --git a/src/renderer/src/lib/keyboard-layout/layout-base-character.test.ts b/src/renderer/src/lib/keyboard-layout/layout-base-character.test.ts new file mode 100644 index 00000000000..f4cf3fb943e --- /dev/null +++ b/src/renderer/src/lib/keyboard-layout/layout-base-character.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/lib/keyboard-layout/layout-base-character.ts b/src/renderer/src/lib/keyboard-layout/layout-base-character.ts new file mode 100644 index 00000000000..d712d897ebd --- /dev/null +++ b/src/renderer/src/lib/keyboard-layout/layout-base-character.ts @@ -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 + } +} + +let cachedLayoutMap: LayoutMapLike | null = null +let focusListenerAttached = false + +async function refreshLayoutMap(): Promise { + 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 +} diff --git a/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts b/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts new file mode 100644 index 00000000000..6d88f56a070 --- /dev/null +++ b/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts @@ -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[5u\x1b[ { + 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[ { + const fresh = new TerminalKittyKeyboardModeTracker() + fresh.scanReplay('\x1b[>1u') + expect(fresh.flags).toBe(1) + fresh.scan('\x1b[1uoutput\x1b[ 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) + } +}