Skip to content

鍵盤解析:kitty 協定

源码版本v0.73.1

@mariozechner/pi-tuikeys.ts(約 1400 行)和 stdin-buffer.ts(411 行)負責把終端機 stdin 位元組流翻譯成結構化的 key id。兩件事分開做:StdinBuffer 把可能分片到達的位元組流按完整轉義序列切開,parseKey 把切好的序列識別成 "ctrl+c" / "shift+enter" / "up" 這樣的 key id。kitty keyboard protocol 是核心:支援 key release/repeat、shifted 版本、非拉丁佈局的 baseLayoutKey。

職責

keys.ts + stdin-buffer.ts 做三件事:

  1. 序列切分:StdinBuffer.process 累積 stdin,按 CSI/OSC/DCS/APC 完整性切分,不完整的留 buffer 等下次。見 packages/tui/src/stdin-buffer.ts:251-312
  2. key id 解析:parseKey(data) 優先 kitty CSI-u,然後 modifyOtherKeys,再回退 legacy 序列。見 packages/tui/src/keys.ts:1251-1326
  3. release/repeat 偵測:isKeyRelease/isKeyRepeat 用 kitty event type 欄位(:2/:3)判斷,元件透過 wantsKeyRelease 決定是否接收。見 packages/tui/src/keys.ts:505-577

設計動機

為什麼需要 StdinBuffer?因為 stdin 的 data 事件邊界和轉義序列邊界不一致。滑鼠 SGR 序列 \x1b[<35;20;5M 可能分三次到達:\x1b[<35;20;5M。如果直接餵給 parseKey,第一個 \x1b 會被識別成 ESC,後面的 [<35 當成普通字元插入。StdinBufferisCompleteSequence 判斷當前 buffer 是不是完整序列,不完整就等下一次 process。10ms 逾時兜底防止永遠等不到結尾的孤兒 ESC 卡死輸入。

為什麼優先 kitty 協定?因為 legacy 序列表達力有限——ctrl+aA 在 ASCII 裡是同一個位元組(0x01 vs 0x41),shift+enter 在 legacy 下根本沒序列。kitty CSI-u 用 \x1b[<codepoint>;<mod>:<event>u 顯式編碼 codepoint、modifier、event type、shifted 版本、base layout key,能區分任意組合。setKittyProtocolActive(true)parseKey 會把 \x1b\r 解讀成 shift+enter(kitty mapping)而非 legacy 的 alt+enter

關鍵檔案

isKeyRelease 用後綴 :3u/:3~ 等判斷 release 事件,bracketed paste 內容即使含 :3F 也不當 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 三級回退:kitty CSI-u 優先,失敗再試 modifyOtherKeys,最後查 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 累積 buffer,bracketed paste 單獨走 pasteBuffer,非 paste 走 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;
}

資料流

stdin → StdinBuffer → 完整序列 → parseKey → key id → 元件 handleInput:

邊界與失敗

  • bracketed paste 內的偽 release/release 序列:藍牙 MAC 90:62:3F:A5:3F,貼上時不能被當成 key release。isKeyRelease/isKeyRepeat 開頭檢查 \x1b[200~ 直接回傳 false。見 packages/tui/src/keys.ts:528-534
  • 孤兒 ESC 逾時:單獨一個 \x1b 既可能是 ESC 鍵,也可能是序列開頭,StdinBuffer 等 10ms 沒下文才當 ESC。見 packages/tui/src/stdin-buffer.ts:259-262
  • Windows Terminal 的 \x08 歧義:\x08 在 Windows Terminal 是 ctrl+backspace,其他終端機是 backspace,isWindowsTerminalSession() 區分。見 packages/tui/src/keys.ts:1287-1288
  • legacy alt+letter:\x1b<letter> 在 kitty active 時不解讀成 alt+letter(因為 kitty 用 CSI-u 表達),只有在 legacy 模式下才走 alt+${char}
  • 單位元組 > 127 轉成 ESC + (byte-128):相容舊 parseKeypress 的高位元組 alt 映射。見 packages/tui/src/stdin-buffer.ts:274-283

小結

keys.ts + stdin-buffer.ts 是終端機輸入的解析層:StdinBuffer 切完整序列,parseKey 識別 key id,kitty 協定提供 release/repeat/shifted 等高維資訊,legacy 序列做 fallback。解析出來的 key id 餵給 TUI 類別handleInput,最終路由到 編輯器元件元件庫handleInput