Files
vue-tui/docs/superpowers/specs/2026-05-27-kitty-keyboard-design.md
T
Yunfei He 31e3d0b333 docs: final spec fixes from Codex round 7
- Note uppercase test uses Ink's codepoint-65 form (terminal variant)
- Specify ignore?: boolean on Keypress type
- Update late-response description to use ignore flag path

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 15:50:48 +08:00

18 KiB

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

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[?<digits>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

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 to stdout (disable sequence).
  3. Set enabled = false.

Modified: packages/runtime/src/io/parse-keypress.ts

Add a guard at the top of parseKeypress() to recognize and swallow kitty query responses (\x1b[?<digits>u). These are terminal capability responses, not user input — they should never reach useInput handlers.

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:

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:

kittyKeyboard?: KittyKeyboardOptions;

mount() — after createStdinController():

const kittyController = createKittyKeyboardController(stdin, stdout);
kittyController.init(options.kittyKeyboard, interactive);
mountedKittyController = kittyController;

teardown() — after Vue unmount, before terminal restoration:

// 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:

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:

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):

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[?<flags>u \x1b[?1u — terminal supports disambiguate
Enable protocol \x1b[><flags>u \x1b[>1u — enable disambiguateEscapeCodes
Disable protocol \x1b[<u Sent on unmount/dispose

Test Suite

File 1: packages/runtime/src/io/parse-keypress-kitty.test.ts (~57 unit tests)

Tests kitty parsing in isolation — no rendering, no Vue. Each test calls parseKeypress() directly with a CSI u sequence and asserts the returned keypress object.

Helper function kittyKey(codepoint, modifiers?, eventType?, textCodepoints?) constructs CSI u sequences for testing.

Basic character + modifier parsing (11 tests):

  • Simple character 'a' (\x1b[97u)
  • Uppercase with shift (\x1b[65;2u) — note: strict kitty spec says lowercase codepoint (97), but some terminals send uppercase (65). Matches Ink's test.
  • Ctrl modifier (\x1b[97;5u)
  • Alt/option modifier (\x1b[97;3u)
  • Super modifier (\x1b[97;9u)
  • Hyper modifier (\x1b[97;17u)
  • Meta modifier (\x1b[97;33u)
  • Caps lock flag (\x1b[97;65u)
  • Num lock flag (\x1b[97;129u)
  • Combined: ctrl+shift (\x1b[97;6u)
  • Combined: super+ctrl (\x1b[97;13u)

Special keys (7 tests):

  • Escape (codepoint 27)
  • Return/enter (codepoint 13)
  • Tab (codepoint 9)
  • Backspace (codepoint 8)
  • Backspace (codepoint 127)
  • Legacy meta+backspace (0x1b 0x7f)
  • Space (codepoint 32)

Event types (3 tests):

  • Press (eventType 1)
  • Repeat (eventType 2)
  • Release (eventType 3)

Text & unicode (8 tests):

  • Number keys
  • Special character (@)
  • Ctrl+letter via codepoint 1-26
  • Sequence and raw preservation
  • Text-as-codepoints: single, multiple, supplementary unicode
  • Text defaults to character from codepoint

Arrow & function keys (5 tests):

  • Arrow keys with event type (CSI enhanced special key format)
  • Arrow keys with modifiers
  • Home and end keys
  • Tilde-terminated special keys (delete, insert, pageup, f5)
  • Tilde keys with modifiers

Error handling (4 tests):

  • Invalid codepoint above U+10FFFF → safe empty keypress
  • Surrogate codepoint → safe empty keypress
  • Invalid text codepoint → replaced with '?'
  • Malformed modifier 0 → does not set all flags

Query response filtering (2 tests):

  • \x1b[?1u (kitty query response) returns no-op keypress with empty name
  • \x1b[?31u (multi-digit query response) also returns no-op

Legacy fallback (2 tests):

  • Non-kitty sequences fall back to legacy parsing
  • Ctrl+c legacy fallback

isPrintable field (11 tests):

  • True for: regular chars, digits, symbols, emoji, return, space
  • False for: escape, tab, backspace, ctrl+letter, special keys (arrows)

Non-printable key suppression (10 tests):

  • Capslock (57358), printscreen (57361), f13 (57376)
  • Media key (57428 mediaplay)
  • Modifier-only keys (57441 leftshift, 57442 leftcontrol)
  • Keypad keys (57399 kp0)
  • Scrolllock (57359), numlock (57360), pause (57362)
  • Volume keys (lower, raise, mute)

File 2: packages/runtime-tests/integration/pty/input-kitty.test.ts (~17 integration tests)

Tests useInput with kitty protocol sequences through the full PTY pipeline. Uses the same PTY test helper infrastructure as existing input.test.ts.

Kitty modifiers through useInput (5 tests):

  • Super modifier → input='s', key.super=true
  • Hyper modifier → input='h', key.hyper=true
  • CapsLock → key.capsLock=true
  • NumLock → key.numLock=true
  • Super+ctrl → input='s', key.super=true, key.ctrl=true

Event types through useInput (3 tests):

  • Press → key.eventType='press'
  • Repeat → key.eventType='repeat'
  • Release → key.eventType='release'

Special keys through useInput (3 tests):

  • Escape key → key.escape=true, empty input
  • Backspace (codepoint 127) → key.backspace=true, empty input
  • Delete → key.delete=true, empty input

Non-printable keys produce empty input (3 tests):

  • Capslock (57358) → input=''
  • F13 (57376) → input=''
  • Printscreen (57361) → input=''

Text input (3 tests):

  • Space → input=' '
  • Return → input='\r'
  • Ctrl+letter via codepoint 1-26 → input='a', key.ctrl=true

File 3: packages/runtime-tests/integration/kitty-lifecycle.test.ts (~22 integration tests)

Tests the protocol enable/disable/auto-detect flow. Uses fake stdin/stdout streams to verify escape sequences written.

Init/cleanup (3 tests):

  • Writes enable sequence (\x1b[>1u) when mode: 'enabled' and both streams are TTY
  • Writes disable sequence (\x1b[<u) on unmount
  • Not enabled when stdin or stdout is not a TTY

Auto-detection happy path (3 tests):

  • Auto detection enables protocol when terminal responds with \x1b[?1u
  • Auto detection handles synchronous (immediate) query response
  • Auto detection handles Uint8Array response

Auto-detection edge cases (5 tests):

  • Does not enable protocol after unmount (race condition guard)
  • Preserves split UTF-8 input bytes during detection (re-emitted via unshift)
  • Timeout does not leak partial query response (\x1b[?1 without terminator)
  • Timeout preserves query prefix without digits (\x1b[? alone)
  • Ignores response without digits (\x1b[?u — missing flags)

Opt-in behavior (2 tests):

  • No-op when kittyKeyboard is absent from mount options (no sequences written)
  • No-op when kittyKeyboard: { mode: 'disabled' } (no sequences written)

Custom flags (2 tests):

  • Enabled mode with custom flags writes correct bitmask (e.g., flags: ['disambiguateEscapeCodes', 'reportEventTypes'] → \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[?<digits>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