Keyboard Parsing: The Kitty Protocol
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:
- Sequence slicing:
StdinBuffer.processaccumulates stdin and slices by CSI/OSC/DCS/APC completeness. Incomplete data stays in the buffer for the next call. Seepackages/tui/src/stdin-buffer.ts:251-312. - Key id parsing:
parseKey(data)tries kitty CSI-u first, then modifyOtherKeys, then falls back to legacy sequences. Seepackages/tui/src/keys.ts:1251-1326. - Release/repeat detection:
isKeyRelease/isKeyRepeatlook at the kitty event type field (:2/:3). Components opt in to release events viawantsKeyRelease. Seepackages/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
packages/tui/src/keys.ts:25-40— global_kittyProtocolActivestate;ProcessTerminalcallssetKittyProtocolActive(true)after detecting kitty support.packages/tui/src/keys.ts:505-585—KeyEventTypetype +isKeyRelease/isKeyRepeat/parseEventType.packages/tui/src/keys.ts:587-695—parseKittySequenceparses\x1b[<cp>:<shifted>:<base>;<mod>:<event>uand returns aParsedKittySequence.packages/tui/src/keys.ts:1332-1398—KITTY_CSI_U_REGEXanddecodeKittyPrintable: when flag 1 is active, the CSI-u sequence is decoded back to a printable char.packages/tui/src/keys.ts:1251-1326—parseKeymain entry: three-level fallback kitty -> modifyOtherKeys -> legacy.packages/tui/src/stdin-buffer.ts:29-150—isCompleteSequence+isCompleteCsi/Osc/Dcs/ApcSequencecompleteness checks.packages/tui/src/stdin-buffer.ts:192-250—extractCompleteSequencesslices complete sequences out of the buffer and leaves aremainderfor next time.packages/tui/src/stdin-buffer.ts:251-340—class StdinBuffer:process(data), the bracketed paste path, and the 10ms timeout.
isKeyRelease uses suffixes like :3u/:3~ to detect release events. Bracketed paste content is never treated as a release even if it contains :3F:
// 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:
// 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:
// 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:A5contains:3Fand must not be read as a key release during paste.isKeyRelease/isKeyRepeatcheck for\x1b[200~at the start and return false. Seepackages/tui/src/keys.ts:528-534. - Orphan ESC timeout: a lone
\x1bcould be either the ESC key or the start of a sequence.StdinBufferwaits 10ms; if nothing follows, it treats it as ESC. Seepackages/tui/src/stdin-buffer.ts:259-262. - Windows Terminal's
\x08ambiguity:\x08isctrl+backspacein Windows Terminal butbackspaceeverywhere else.isWindowsTerminalSession()tells them apart. Seepackages/tui/src/keys.ts:1287-1288. - legacy alt+letter:
\x1b<letter>is not read asalt+letterwhen kitty is active (kitty uses CSI-u for that). It only goes throughalt+${char}in legacy mode. - Single bytes above 127 become ESC + (byte-128): this preserves the high-byte alt mapping of the old
parseKeypress. Seepackages/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.