Skip to content

Keyboard Parsing: The Kitty Protocol

源码版本v0.73.1

keys.ts (about 1400 lines) and stdin-buffer.ts (411 lines) in @mariozechner/pi-tui translate the terminal stdin byte stream into structured key ids. The work is split: StdinBuffer slices the byte stream — which may arrive in fragments — into complete escape sequences, and parseKey identifies each complete sequence as a key id like "ctrl+c" / "shift+enter" / "up". The kitty keyboard protocol is central: it supports key release/repeat, shifted variants, and a baseLayoutKey for non-Latin layouts.

Responsibilities

keys.ts + stdin-buffer.ts do three things:

  1. Sequence slicing: StdinBuffer.process accumulates stdin and slices by CSI/OSC/DCS/APC completeness. Incomplete data stays in the buffer for the next call. See packages/tui/src/stdin-buffer.ts:251-312.
  2. Key id parsing: parseKey(data) tries kitty CSI-u first, then modifyOtherKeys, then falls back to legacy sequences. See packages/tui/src/keys.ts:1251-1326.
  3. Release/repeat detection: isKeyRelease/isKeyRepeat look at the kitty event type field (:2/:3). Components opt in to release events via wantsKeyRelease. See packages/tui/src/keys.ts:505-577.

Design Motivation

Why do we need StdinBuffer? Because the boundaries of stdin's data events do not line up with escape-sequence boundaries. A mouse SGR sequence like \x1b[<35;20;5M can arrive in three chunks: \x1b, [<35, ;20;5M. If you feed those straight to parseKey, the first \x1b gets identified as ESC, and the following [<35 is inserted as ordinary characters. StdinBuffer uses isCompleteSequence to decide whether the current buffer holds a complete sequence; if not, it waits for the next process call. A 10ms timeout is the safety net so an orphan ESC that never gets a continuation cannot stall input forever.

Why prefer the kitty protocol? Because legacy sequences are not very expressive. ctrl+a and A are the same byte in ASCII (0x01 vs 0x41), and shift+enter has no legacy sequence at all. kitty CSI-u uses \x1b[<codepoint>;<mod>:<event>u to explicitly encode codepoint, modifier, event type, shifted variant, and base layout key, so it can distinguish any combination. Once setKittyProtocolActive(true) is called, parseKey reads \x1b\r as shift+enter (the kitty mapping) instead of the legacy alt+enter.

Key Files

isKeyRelease uses suffixes like :3u/:3~ to detect release events. Bracketed paste content is never treated as a release even if it contains :3F:

typescript
// packages/tui/src/keys.ts:527-551
export function isKeyRelease(data: string): boolean {
  if (data.includes("\x1b[200~")) {
    return false;
  }
  if (
    data.includes(":3u") ||
    data.includes(":3~") ||
    data.includes(":3A") ||
    // ...
    data.includes(":3F")
  ) {
    return true;
  }
  return false;
}

parseKey falls back in three levels: kitty CSI-u first, then modifyOtherKeys, then the legacy table:

typescript
// packages/tui/src/keys.ts:1251-1271
export function parseKey(data: string): string | undefined {
  const kitty = parseKittySequence(data);
  if (kitty) {
    return formatParsedKey(kitty.codepoint, kitty.modifier, kitty.baseLayoutKey);
  }

  const modifyOtherKeys = parseModifyOtherKeysSequence(data);
  if (modifyOtherKeys) {
    return formatParsedKey(modifyOtherKeys.codepoint, modifyOtherKeys.modifier);
  }

  // Mode-aware legacy sequences
  if (_kittyProtocolActive) {
    if (data === "\x1b\r" || data === "\n") return "shift+enter";
  }

StdinBuffer.process accumulates the buffer. Bracketed paste goes through a separate pasteBuffer; everything else goes through extractCompleteSequences:

typescript
// packages/tui/src/stdin-buffer.ts:290-312
this.buffer += str;

if (this.pasteMode) {
  this.pasteBuffer += this.buffer;
  this.buffer = "";
  const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END);
  if (endIndex !== -1) {
    const pastedContent = this.pasteBuffer.slice(0, endIndex);
    // ...
    this.emit("paste", pastedContent);
  }
  return;
}

Data Flow

stdin -> StdinBuffer -> complete sequences -> parseKey -> key id -> component handleInput:

Edges and Failures

  • Pseudo release/repeat sequences inside a bracketed paste: a Bluetooth MAC like 90:62:3F:A5 contains :3F and must not be read as a key release during paste. isKeyRelease/isKeyRepeat check for \x1b[200~ at the start and return false. See packages/tui/src/keys.ts:528-534.
  • Orphan ESC timeout: a lone \x1b could be either the ESC key or the start of a sequence. StdinBuffer waits 10ms; if nothing follows, it treats it as ESC. See packages/tui/src/stdin-buffer.ts:259-262.
  • Windows Terminal's \x08 ambiguity: \x08 is ctrl+backspace in Windows Terminal but backspace everywhere else. isWindowsTerminalSession() tells them apart. See packages/tui/src/keys.ts:1287-1288.
  • legacy alt+letter: \x1b<letter> is not read as alt+letter when kitty is active (kitty uses CSI-u for that). It only goes through alt+${char} in legacy mode.
  • Single bytes above 127 become ESC + (byte-128): this preserves the high-byte alt mapping of the old parseKeypress. See packages/tui/src/stdin-buffer.ts:274-283.

Summary

keys.ts + stdin-buffer.ts are the parsing layer for terminal input. StdinBuffer slices complete sequences, parseKey resolves key ids, the kitty protocol supplies higher-dimensional info like release/repeat/shifted, and legacy sequences serve as the fallback. The resolved key id is fed to the handleInput of TUI Class, which routes it to the handleInput of either the Editor Component or one of the Component Library components.