mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(terminal): forward Option hotkeys to kitty-keyboard TUIs on compose layouts (#8031)
* fix(terminal): forward Option hotkeys to kitty-keyboard TUIs on compose layouts On macOS layouts where Option composes characters (ABC and all non-US, the effective default), pressing Option+P in a TUI that negotiated the kitty keyboard protocol made xterm's kitty encoder report the composed codepoint (alt+pi, CSI 960;3u) instead of the physical key (alt+p, CSI 112;3u). No TUI binds the composed form, so agent hotkeys like OMP's Alt+P / Alt+M neither fired nor typed anything. Fix: mirror each pane's application-negotiated kitty flags with a scan-based tracker fed only from PTY output (immune to Orca's defensive renderer-side kitty resets on Ctrl+C interrupts and reattach), and have the terminal shortcut policy encode Option chords as kitty CSI-u from the physical key when the pane's app opted in. Dead keys stay exempt so Option composition still works, and panes without kitty negotiation (shells) are unchanged. Alt+Arrow / Alt+Backspace now defer to xterm's native kitty encoding in kitty panes instead of the legacy readline translations. The daemon's headless emulator tracks the same flags and re-arms them via the snapshot rehydrate preamble (CSI = flags ; 1 u), so the behavior survives window reloads and reattaches; PTY exit and cold restore reset the mirror. Validated byte-for-byte against a real omp 16.3.15 in a pty: the policy's emitted CSI-u opens the temporary-model selector (Alt+P) and agent hub (Alt+A), identical to the legacy ESC-prefixed forms it parses. * fix(terminal): harden kitty Option-chord mirror for soft resets, replay redelivery, and non-QWERTY layouts Three review findings on the kitty keyboard mirror, each verified against a live omp 16.3.15 pty session using the real production modules: - DECSTR: xterm's soft reset (CSI ! p) clears its kitty flags and stacks for both screens without switching buffers; the tracker now mirrors that, so a soft-resetting TUI stops receiving kitty-encoded Option chords. - Replay redelivery: relay reconnects can redeliver the retained replay window, and each scan of the app's one-time CSI > u push grew the mirrored stack while the renderer's post-replay reset drained xterm's copy. The TUI's single exit pop (omp emits a bare CSI < u on quit) then landed on a stale frame, leaving Option+B/F/D kitty-encoded in a plain shell. Replay paths now scan with scanReplay(), which applies pushes as idempotent sets so redelivery cannot grow the stack; the live-output funnel and the daemon's once-per-byte emulator keep full stack semantics. - Layout-correct base keys: kitty CSI-u reports must carry the key's unshifted codepoint in the active layout, but the encoder resolved it from a US-QWERTY physical-code table — on Dvorak/Colemak/AZERTY-class layouts (exactly the population whose effective Option-as-Alt default activates this path) the wrong key's chord fired, e.g. Colemak Option+P sent alt+r. A new keyboard-layout module caches Chromium's KeyboardLayoutMap (fetched at terminal setup and on focus-in, like the option-as-alt probe) and the policy resolves through it before the US fallback. Verified live: the layout-resolved bytes open omp's model selector; the US-table bytes do not. * fix(terminal): reset kitty mirror on fresh spawn to cover replaced-PTY late exits The exit handler resets the per-pane kitty keyboard mirror, but a late exit from a replaced PTY takes the stale-transport early return and skips it — so a restart-in-place could leak the old TUI's kitty flags into the fresh shell, kitty-encoding Option chords the shell cannot parse. A fresh spawn is by definition a new process with kitty state at zero, so startFreshSpawn now resets the reused tracker itself, alongside the other per-pane mode state it already clears. Reattach paths are untouched, so a live TUI's mirrored flags still survive reconnects. --------- Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local>
This commit is contained in:
co-authored by
Brennan Benson
parent
d9b1fbbc07
commit
da2c692d31
@@ -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', () => {
|
||||
|
||||
@@ -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('')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user