chore: remove force-added planning docs from branch
These are local design artifacts under .gitignore, not release content. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,387 +0,0 @@
|
||||
# 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 |
|
||||
Reference in New Issue
Block a user