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