Skip to content

エディタコンポーネント:入力、IME、kill-ring、undo

源码版本v0.73.1

Editor@mariozechner/pi-tui で最も重いコンポーネント——単一ファイル 2292 行で、複数行テキスト編集の泥臭い仕事を全部担う:キーボード入力のディスパッチ、IME 候補ウィンドウの位置決め、bracketed paste 処理、Emacs 風 kill/yank、fish 風 undo coalescing、スラッシュコマンドと記号トリガによる autocomplete。InteractiveMode はこれを主入力ボックスとして使い、拡張も EditorComponent インターフェースで実装を差し替えられる(vim/emacs モード)。

責務

Editor は四つの仕事をする:

  1. 入力ディスパッチ:handleInput(data) は raw ターミナルのバイトストリーム入口で、jump mode、bracketed paste、undo、autocomplete ナビゲーション、通常文字の順に処理する。packages/tui/src/components/editor.ts:534-630 参照。
  2. 複数行編集:addNewLine がカーソル位置で行を切り、insertTextAtCursorInternal がペースト/補完で挿入される複数行テキストを処理する。packages/tui/src/components/editor.ts:1152-1175packages/tui/src/components/editor.ts:980-1023 参照。
  3. kill-ring / undo:Emacs の kill/yank に fish 風 undo coalescing を組み合わせる。kill は連続削除で蓄積でき、yank 後に yank-pop で ring を循環する。packages/tui/src/components/editor.ts:1817-1896packages/tui/src/kill-ring.ts:1-50 参照。
  4. IME カーソル位置決め:render がカーソル位置の前に CURSOR_MARKER(零幅 APC シーケンス)を挿入する。TUI がこれを抽出してハードウェアカーソルをその位置に動かし、IME 候補ウィンドウがカーソルに寄り添う。packages/tui/src/components/editor.ts:474-501 参照。

設計動機

なぜ Node.js の readline を使わないのか?IME サポートも複数行も undo も autocomplete フックもないからだ。Editor はこれらを同じコンポーネントに作り込んでいて、重要な決断が三つある。第一に undo coalescing:連続して入力された文字はデフォルトで一つの undo ユニットにまとめられ(空白が新ユニットのトリガ)、文字ごとに snapshot を push して undo がうるさくなるのを防ぐ。第二に kill-ring 蓄積:連続した deleteWordBackwards は削除テキストを同じ ring 項目に詰む。逆方向削除は prepend、順方向は append で、yank 一回でまとめて復元できる。第三に paste marker:巨大なペーストはそのままテキストに流し込まず、[paste #N +M lines] プレースホルダに置換する。展開時に getExpandedText を呼び出し、数千行のペーストでレンダリングが死ぬのを防ぐ。

主要ファイル

handleInput は入ってきたらまず bracketed paste の開始/終了 marker を検出し、ペースト中は \x1b[201~ を受け取るまで pasteBuffer に蓄積する:

typescript
// packages/tui/src/components/editor.ts:559-582
if (data.includes("\x1b[200~")) {
  this.isInPaste = true;
  this.pasteBuffer = "";
  data = data.replace("\x1b[200~", "");
}
if (this.isInPaste) {
  this.pasteBuffer += data;
  const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
  if (endIndex !== -1) {
    const pasteContent = this.pasteBuffer.substring(0, endIndex);
    if (pasteContent.length > 0) this.handlePaste(pasteContent);
    this.isInPaste = false;
    // ...
  }
}

undo coalescing は連続する word 文字を一つの undo ユニットにまとめ、空白だけ独立したユニットにする:

typescript
// packages/tui/src/components/editor.ts:1027-1037
// - Consecutive word chars coalesce into one undo unit
// - Space captures state before itself (so undo removes space+following word together)
// - Each space is separately undoable
if (!skipUndoCoalescing) {
  if (isWhitespaceChar(char) || this.lastAction !== "type-word") {
    this.pushUndoSnapshot();
  }
  this.lastAction = "type-word";
}

yank は kill-ring のスタックトップを取り出して挿入し、yank-pop は yank の直後にしか使えない。前回の yank テキストを消してから ring を回す:

typescript
// packages/tui/src/components/editor.ts:1832-1848
private yankPop(): void {
  if (this.lastAction !== "yank" || this.killRing.length <= 1) return;
  this.pushUndoSnapshot();
  this.deleteYankedText();
  this.killRing.rotate();
  const text = this.killRing.peek()!;
  this.insertYankedText(text);
  this.lastAction = "yank";
}

データフロー

raw stdin → Editor.handleInput → 状態変更 → onChange → 外部 requestRender:

境界と失敗

  • tmux extended-keys-format=csi-u が制御バイトを再エンコード:handlePaste は正規表現で \x1b[<cp>;5u を元のバイトに戻し、newline が ESC + [106;5u` としてエディタに漏れ込むのを防ぐ。packages/tui/src/components/editor.ts:1091-1101 参照。
  • 巨大ペーストは marker で表現:閾値超えのテキストは [paste #N +M lines] に置換され、本体は pasteRegistry に格納される。getExpandedText で展開する。レンダリングは marker だけを描き、詰まらない。
  • undo はペーストをまたいでアトミック:handlePaste の入口で一度だけ pushUndoSnapshot し、ペースト後の単独 undo でペースト前の状態に戻れる。
  • jump mode は次キーを横取り:jump に入ると次の printable 文字はテキストに挿入されず jumpToChar を発火する。ctrl 文字は jump をキャンセルする。packages/tui/src/components/editor.ts:538-556 参照。
  • submit セマンティクスは設定可能:shouldSubmitOnBackslashEnter は keybindings の tui.input.submitentershift+enter かによって \n を提出とみなすかを決める。packages/tui/src/components/editor.ts:1177-1188 参照。

まとめ

Editor は一通りのターミナルエディタ:入力ディスパッチ、IME、kill-ring、undo、autocomplete が一つのクラスに詰まっている。TUI の差分レンダリングと CURSOR_MARKER 仕組みでハードウェアカーソルを位置決めする。TUI クラス:差分レンダリングのスケジューラ 参照。キーボードのバイトストリームがどう key id に解析されるかは キーボード解析:kitty プロトコル 参照。autocomplete のドロップダウンは SelectList を使う。コンポーネントライブラリ 参照。