キーボード解析:kitty プロトコル
@mariozechner/pi-tui の keys.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 は三つの仕事をする:
- シーケンス分割:
StdinBuffer.processは stdin を蓄積し、CSI/OSC/DCS/APC の完全性で切り分ける。不完全なものは buffer に残して次回を待つ。packages/tui/src/stdin-buffer.ts:251-312参照。 - key id 解析:
parseKey(data)は kitty CSI-u を優先し、次に modifyOtherKeys、最後に legacy シーケンスにフォールバックする。packages/tui/src/keys.ts:1251-1326参照。 - 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 は通常文字として挿入されてしまう。StdinBuffer は isCompleteSequence で現在の buffer が完全なシーケンスかを判定し、不完全なら次の process を待つ。10ms のタイムアウトで、末尾が永遠に来ない孤立 ESC が入力を塞ぐのを防ぐ。
なぜ kitty プロトコルを優先するのか?legacy シーケンスは表現力が限界だからだ——ctrl+a と A は 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 とはしない。
主要ファイル
packages/tui/src/keys.ts:25-40— グローバル_kittyProtocolActive状態。ProcessTerminalが kitty サポートを検出したらsetKittyProtocolActive(true)を呼ぶ。packages/tui/src/keys.ts:505-585—KeyEventType型 +isKeyRelease/isKeyRepeat/parseEventType。packages/tui/src/keys.ts:587-695—parseKittySequenceは\x1b[<cp>:<shifted>:<base>;<mod>:<event>uを解析しParsedKittySequenceを返す。packages/tui/src/keys.ts:1332-1398—KITTY_CSI_U_REGEXとdecodeKittyPrintable:flag 1 active 時、CSI-u シーケンスを printable char に戻す。packages/tui/src/keys.ts:1251-1326—parseKey主入口:kitty → modifyOtherKeys → legacy の三段フォールバック。packages/tui/src/stdin-buffer.ts:29-150—isCompleteSequence+isCompleteCsi/Osc/Dcs/ApcSequence完全性判定。packages/tui/src/stdin-buffer.ts:192-250—extractCompleteSequencesが buffer から完全シーケンスを切り出し、残りはremainderとして次回に残す。packages/tui/src/stdin-buffer.ts:251-340—class StdinBuffer:process(data)、bracketed paste パス、10ms タイムアウト。
isKeyRelease はサフィックス :3u/:3~ などで release イベントを判定する。bracketed paste 内容中に :3F が含まれていても release とみなさない:
// 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 表を引く:
// 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 に流す:
// 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 シーケンス:Bluetooth 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 シーケンスはフォールバックとして機能する。解析された key id は TUI クラス の handleInput に渡され、最終的に エディタコンポーネント か コンポーネントライブラリ の handleInput にルーティングされる。