# Kitty Keyboard Protocol Support ## Summary Add kitty keyboard protocol support to vue-tui, matching Ink's implementation. This covers three layers: protocol lifecycle (enable/disable/auto-detect), useInput Key interface extension, and comprehensive test backfill. Parser support covers the default `disambiguateEscapeCodes` and `reportEventTypes` flags; advanced flags are accepted but experimental. The kitty keyboard protocol is an opt-in terminal enhancement that provides disambiguated key events, additional modifiers (super, hyper, capsLock, numLock), event types (press/repeat/release), and text-as-codepoints fields. vue-tui already has the parsing layer (parse-keypress.ts) but lacks the terminal handshake and useInput integration. Reference: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ ## Current State | Layer | Status | Details | |-------|--------|---------| | Parsing (parse-keypress.ts) | Complete for default flags | Decodes CSI u sequences, kitty modifiers, event types, text-as-codepoints. Advanced flag forms (alternate keys, associated text edge cases) not fully covered. | | useInput Key interface | Partial | Exposes ctrl/shift/meta but not super/hyper/capsLock/numLock/eventType | | Protocol lifecycle | Missing | No enable/disable sequences, no auto-detect, no MountOptions field | | Tests | 1 of ~85 | Only Ctrl+C via kitty codepoint-3 form exists | ## Architecture ### New file: `packages/runtime/src/io/kitty-keyboard.ts` Self-contained module with types, constants, query/response matching, and lifecycle controller. #### Types & Constants ```ts export const kittyFlags = { disambiguateEscapeCodes: 1, reportEventTypes: 2, reportAlternateKeys: 4, reportAllKeysAsEscapeCodes: 8, reportAssociatedText: 16, } as const; export type KittyFlagName = keyof typeof kittyFlags; export type KittyKeyboardOptions = { mode?: 'auto' | 'enabled' | 'disabled'; flags?: KittyFlagName[]; }; export function resolveFlags(flags: KittyFlagName[]): number; ``` **Parser support note**: The current parse-keypress.ts parser fully supports `disambiguateEscapeCodes` (the default and most useful flag). The `reportEventTypes` flag is also supported (press/repeat/release). The `reportAlternateKeys`, `reportAllKeysAsEscapeCodes`, and `reportAssociatedText` flags are accepted in the options but the parser may not handle all edge forms they produce (e.g., colon-separated alternate key fields). These flags are exposed for forward compatibility but users should treat them as experimental until parser coverage is verified. #### Query/Response Matching Functions for detecting terminal responses to the `\x1b[?u` capability query: - `matchKittyQueryResponse(buffer, startIndex)` — detects `\x1b[?u` pattern in a byte buffer. Returns `{state: 'complete', endIndex}` or `{state: 'partial'}` or `undefined`. - `hasCompleteKittyQueryResponse(buffer)` — scans entire buffer for any complete response. - `stripKittyQueryResponsesAndTrailingPartial(buffer)` — removes complete responses and trailing partial sequences, returns remaining bytes to re-emit to the input pipeline. A "partial" sequence is `\x1b[?` followed by at least one digit but no terminator (`\x1b[?1` without `u`). The prefix `\x1b[?` alone (no digits) is NOT considered partial — it's not a query response at all and is preserved in the output. These operate on `number[]` byte buffers because terminal responses can arrive as raw bytes (Uint8Array) and may be interleaved with user input. #### Lifecycle Controller ```ts export function createKittyKeyboardController( stdin: NodeJS.ReadStream, stdout: NodeJS.WriteStream, ): KittyKeyboardController; interface KittyKeyboardController { init(options: KittyKeyboardOptions | undefined, interactive: boolean): void; dispose(): void; readonly isEnabled: boolean; } ``` **init(options, interactive):** 1. If options not provided or `mode === 'disabled'` — no-op. 2. Resolve flags (default: `['disambiguateEscapeCodes']`). 3. `mode === 'enabled'` — force-enable if both stdin and stdout are TTYs. Write `\x1b[>${resolvedFlags}u`. 4. `mode === 'auto'` (default) — require `interactive === true` + both TTYs, then call `confirmKittySupport()`. **confirmKittySupport():** 1. Create `responseBuffer: number[]`. 2. Attach `data` listener to stdin (before writing query, to catch sync responses). 3. Write `\x1b[?u` to stdout. 4. Set 200ms timeout. 5. On data: push bytes to buffer. If `hasCompleteKittyQueryResponse(buffer)` → cleanup + enable. 6. On timeout: cleanup only (no enable). 7. Cleanup: remove listener, clear timeout, strip query responses from buffer, re-emit remaining bytes via `stdin.unshift(Uint8Array.from(remaining))`. 8. Guard: don't enable if already disposed (handles unmount-during-detection race). **Raw mode ownership**: The controller does NOT acquire or release raw mode. It attaches a temporary `data` listener to stdin for detection, matching Ink's approach. Raw mode is managed exclusively by `createStdinController` / `useInput`. In practice, if useInput hasn't enabled raw mode yet, the terminal query response may be buffered by the kernel's line discipline and detection times out — this is acceptable because `mode: 'auto'` gracefully degrades to "no kitty support" on timeout. Forced mode (`mode: 'enabled'`) bypasses detection entirely. **dispose():** 1. Cancel in-progress detection (call stored cleanup function). 2. If protocol was enabled, write `\x1b[u`). These are terminal capability responses, not user input — they should never reach useInput handlers. ```ts const kittyQueryResponseRe = /^\x1b\[\?\d+u$/; export function parseKeypress(s: string): Keypress { if (kittyQueryResponseRe.test(s)) { return { name: '', sequence: s, raw: s, ctrl: false, shift: false, meta: false, ignore: true }; } // ... rest of existing logic } ``` The `ignore: true` flag tells useInput to skip this keypress entirely — the user handler is NOT called. The `Keypress` type must be extended with `ignore?: boolean`. This handles both scenarios: (1) late responses after detection timeout, (2) responses during the dual-listener race window. **useInput** must check for `ignore` before calling the handler: ```ts function listener(data: string) { const keypress = parseKeypress(data); if (keypress.ignore) return; // ... rest of existing logic } ``` ### Modified: `packages/runtime/src/render.ts` **MountOptions** — add field: ```ts kittyKeyboard?: KittyKeyboardOptions; ``` **mount()** — after `createStdinController()`: ```ts const kittyController = createKittyKeyboardController(stdin, stdout); kittyController.init(options.kittyKeyboard, interactive); mountedKittyController = kittyController; ``` **teardown()** — after Vue unmount, before terminal restoration: ```ts // In teardown(), after originalUnmount() and before writer.done() / cursor restore: mountedKittyController?.dispose(); ``` Order matches Ink: final render → restore console → React/Vue unmount → cancel kitty detection → disable kitty protocol → exit alt screen → restore cursor → done. ### Modified: `packages/runtime/src/composables/useInput.ts` **Key interface** — add 5 fields: ```ts export interface Key { // ... existing fields unchanged ... super: boolean; hyper: boolean; capsLock: boolean; numLock: boolean; eventType?: 'press' | 'repeat' | 'release'; } ``` **listener() function** — after building the Key object, add kitty modifier mapping: ```ts const key: Key = { // ... existing fields ... super: keypress.super ?? false, hyper: keypress.hyper ?? false, capsLock: keypress.capsLock ?? false, numLock: keypress.numLock ?? false, eventType: keypress.eventType, }; ``` **Input string logic** — replace the current logic with kitty-aware branching (matching Ink): ```ts let input: string; if (keypress.isKittyProtocol) { if (keypress.isPrintable) { input = keypress.text ?? keypress.name; } else if (keypress.ctrl && keypress.name.length === 1) { input = keypress.name; } else { input = ''; } } else if (keypress.ctrl) { input = keypress.name ?? ''; } else { input = keypress.sequence; } if (!keypress.isKittyProtocol && nonAlphanumericKeys.includes(keypress.name)) { input = ''; } ``` The key change: when kitty protocol is active, non-printable keys (capslock, media keys, F13+, modifier-only keys) produce empty input instead of leaking raw escape sequences. The `nonAlphanumericKeys` filter only applies to legacy sequences. ### Exports Re-export from package entry point: - `KittyKeyboardOptions` type - `KittyFlagName` type - `kittyFlags` constant (matching Ink's exports) ## Escape Sequences Reference | Purpose | Sequence | Example | |---------|----------|---------| | Query terminal support | `\x1b[?u` | Sent to stdout during auto-detect | | Terminal response | `\x1b[?u` | `\x1b[?1u` — terminal supports disambiguate | | Enable protocol | `\x1b[>u` | `\x1b[>1u` — enable disambiguateEscapeCodes | | Disable protocol | `\x1b[1u`) when `mode: 'enabled'` and both streams are TTY - Writes disable sequence (`\x1b[3u`) - Auto mode with custom flags passes them through to enable sequence **Invalid response handling (3 tests):** - Preserves invalid query-like escape sequence (wrong terminator) - Non-query bytes interleaved with response are re-emitted - Response `\x1b[?0u` (zero flags) — still treated as valid support confirmation **Split response (1 test):** - Query response split across two stdin data chunks — bytes reassembled correctly **Late response after timeout (1 test):** - Terminal responds after 200ms timeout — protocol not enabled (late response bytes flow to normal input pipeline where parseKeypress marks them with `ignore: true`, producing zero useInput handler calls) **Query response suppression in useInput (1 test):** - `\x1b[?1u` arriving at useInput (late response or race) produces zero handler calls (ignore flag) ## Implementation Notes - The kitty-keyboard.ts `kittyModifiers` constant already exists in parse-keypress.ts. The new module only needs the flag constants and lifecycle logic; it imports nothing from parse-keypress.ts. - The `isKittyProtocol`, `isPrintable`, `text`, `super`, `hyper`, `capsLock`, `numLock`, `eventType` fields are already set by `parseKittyKeypress()` in parse-keypress.ts. No changes needed to the parser. - Auto-detect's stdin `data` listener is temporary (removed after detection completes or times out). It runs during the brief init window. See the race condition note below for the overlap scenario with useInput's listener. - The `stdin.unshift()` call to re-emit non-query bytes pushes them back to the front of the readable stream. After the kitty detection listener is removed, the re-emitted bytes are picked up by the normal input pipeline (createStdinController's handleData) on the next read. - **Known race condition (matches Ink)**: The detection `data` listener and useInput's `data` listener can briefly coexist if a component mounts and calls `acquireRawMode()` within the 200ms detection window. In this scenario: (a) query response bytes are filtered by the `ignore` flag in parseKeypress, so they never reach user handlers, (b) user input bytes arriving during detection may be delivered twice — once by useInput's listener and once after `unshift()`. This same race exists in Ink's implementation. In practice, most terminals respond to the query synchronously or within a few ms, so detection completes before useInput effects fire. The mitigation is ordering: `kittyController.init()` runs before `originalMount()`, so detection starts before Vue components mount. If the dual-delivery race proves problematic in practice, the fix is to integrate detection into the stdin controller's input pipeline (single listener, no duplication). ## Files Changed | File | Change | |------|--------| | `packages/runtime/src/io/kitty-keyboard.ts` | **New** — types, constants, query matchers, lifecycle controller | | `packages/runtime/src/io/parse-keypress.ts` | Add query response filter (`\x1b[?u` → keypress with `ignore: true`) | | `packages/runtime/src/render.ts` | Add `kittyKeyboard` to MountOptions, wire controller in mount/teardown | | `packages/runtime/src/composables/useInput.ts` | Extend Key interface, add kitty-aware input logic | | `packages/runtime/src/index.ts` | Re-export KittyKeyboardOptions, KittyFlagName, kittyFlags | | `packages/runtime/src/io/parse-keypress-kitty.test.ts` | **New** — 57 unit tests | | `packages/runtime-tests/integration/pty/input-kitty.test.ts` | **New** — 17 integration tests | | `packages/runtime-tests/integration/kitty-lifecycle.test.ts` | **New** — 22 integration tests |