31e3d0b333
- 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>
388 lines
18 KiB
Markdown
388 lines
18 KiB
Markdown
# 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[?<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
|
|
|
|
```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` 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.
|
|
|
|
```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[?<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 |
|