Skip to content

Parseo de teclado: protocolo kitty

源码版本v0.73.1

keys.ts (unas 1400 líneas) y stdin-buffer.ts (411 líneas) de @mariozechner/pi-tui traducen el byte stream de stdin de la terminal a un key id estructurado. Las dos cosas se separan: StdinBuffer corta el byte stream (que puede llegar fragmentado) por secuencias de escape completas, y parseKey reconoce la secuencia cortada como un key id tipo "ctrl+c" / "shift+enter" / "up". El kitty keyboard protocol es el núcleo: soporta release/repeat, versiones shifted y baseLayoutKey para layouts no latinos.

Responsabilidades

keys.ts + stdin-buffer.ts hacen tres cosas:

  1. Segmentación de secuencias: StdinBuffer.process acumula stdin y corta por integridad CSI/OSC/DCS/APC; lo incompleto se queda en buffer para la próxima vez. Ver packages/tui/src/stdin-buffer.ts:251-312.
  2. Parseo de key id: parseKey(data) prioriza kitty CSI-u, luego modifyOtherKeys, y por último fallback a secuencias legacy. Ver packages/tui/src/keys.ts:1251-1326.
  3. Detección release/repeat: isKeyRelease/isKeyRepeat usan el campo event type de kitty (:2/:3) para detectar; los componentes deciden si lo reciben vía wantsKeyRelease. Ver packages/tui/src/keys.ts:505-577.

Motivación de diseño

¿Por qué hace falta StdinBuffer? Porque los bordes del evento data de stdin no coinciden con los bordes de las secuencias de escape. Una secuencia SGR de mouse \x1b[<35;20;5M puede llegar en tres partes: \x1b, [<35, ;20;5M. Si se pasa directamente a parseKey, el primer \x1b se reconoce como ESC y el resto [<35 se inserta como caracteres. StdinBuffer usa isCompleteSequence para decidir si el buffer actual es una secuencia completa; si no, espera al siguiente process. Un timeout de 10ms evita que un ESC huérfano (sin continuación nunca) bloquee la entrada para siempre.

¿Por qué priorizar el protocolo kitty? Porque las secuencias legacy son limitadas: ctrl+a y A son el mismo byte en ASCII (0x01 vs 0x41); shift+enter no tiene secuencia legacy. kitty CSI-u codifica explícitamente codepoint, modifier, event type, versión shifted y base layout key, y distingue cualquier combinación. Tras setKittyProtocolActive(true), parseKey interpreta \x1b\r como shift+enter (mapeo kitty) en vez del alt+enter legacy.

Archivos clave

isKeyRelease juzga release por sufijos :3u/:3~ etc.; el contenido de bracketed paste aunque incluya :3F no se trata como release:

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 tres niveles de fallback: kitty CSI-u primero, si no modifyOtherKeys, y al final tabla legacy:

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 acumula el buffer; bracketed paste va aparte a pasteBuffer, lo demás pasa por 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;
}

Flujo de datos

stdin → StdinBuffer → secuencia completa → parseKey → key id → handleInput del componente:

Límites y fallos

  • Secuencias de release/repeat falsas dentro de bracketed paste: una MAC Bluetooth 90:62:3F:A5 contiene :3F y al pegar no debería tratarse como release. isKeyRelease/isKeyRepeat comprueban al inicio \x1b[200~ y devuelven false. Ver packages/tui/src/keys.ts:528-534.
  • ESC huérfano con timeout: un \x1b solo puede ser la tecla ESC o el inicio de una secuencia; StdinBuffer espera 10ms y si no llega nada más lo trata como ESC. Ver packages/tui/src/stdin-buffer.ts:259-262.
  • Ambigüedad de \x08 en Windows Terminal: \x08 es ctrl+backspace en Windows Terminal y backspace en otras; isWindowsTerminalSession() lo distingue. Ver packages/tui/src/keys.ts:1287-1288.
  • alt+letter legacy: \x1b<letter> con kitty activo no se interpreta como alt+letter (kitty lo expresa con CSI-u), sólo se va por alt+${char} en modo legacy.
  • Byte suelto > 127 a ESC + (byte-128): compatible con el mapeo alt de high byte del viejo parseKeypress. Ver packages/tui/src/stdin-buffer.ts:274-283.

Resumen

keys.ts + stdin-buffer.ts son la capa de parseo de entrada de terminal: StdinBuffer corta secuencias completas, parseKey reconoce el key id, el protocolo kitty aporta información rica (release/repeat/shifted), y las secuencias legacy son fallback. El key id se pasa al handleInput de la clase TUI, que al final enruta al handleInput del editor o de la biblioteca de componentes.