Skip to content

TUI クラス:差分レンダリング (differential rendering) のスケジューラ

源码版本v0.73.1

@mariozechner/pi-tuiTUI クラスはターミナル UI のレンダリングスケジューラ (scheduler) だ。Container を継承し、自身もコンポーネントツリーのルートだが、三つの仕事を追加で担う:コンポーネントツリーを行にレンダリングし、前フレームの行と差分を取り、DECSET 2026 同期出力で包んで terminal に書き出す。あわせて focus、overlay 弾層、kitty 画像 ID の回収も管理する。pi-mono 全体の TUI 画面はすべてこの doRender ループの上で動く。

責務

TUI は四つの仕事をする:

  1. レンダリングスケジュール:requestRender はすぐ描画せず、次フレームにマージする。16ms 間隔を強制し、spinner が CPU を 100% 食いつぶすのを防ぐ。packages/tui/src/tui.ts:495-522 参照。
  2. 差分比較:doRenderthis.render(width) の新しい行と previousLines を行ごとに比較し、firstChanged/lastChanged を見つけて変化区間だけ再描画する。packages/tui/src/tui.ts:953-1011 参照。
  3. Overlay 合成:弾層 (overlay) は差分計算の前に newLines に合成される。これで base 内容の変化と overlay 位置の変化が統合して diff に関わる。packages/tui/src/tui.ts:758-808 参照。
  4. 同期出力:すべての書き込みを \x1b[?2026h ... \x1b[?2026l で包み、ターミナルにこのフレームをアトミックに表示させ、半フレームのちらつきを防ぐ。packages/tui/src/tui.ts:1145-1230 参照。

設計動機

なぜ terminal.write(component.render(width).join("\n")) と直接書かないのか?ターミナルはデフォルトで行式ストリーム書き込みで、中間状態が何でも描画されてしまう——全画面を描き直すと全画面がちらつき、変化行だけ描くには自分でカーソル位置とスクロールを追跡しなければならない。TUI はこの泥臭い仕事を全部引き受ける:previousLines で行レベル diff を取り、hardwareCursorRow でターミナルの実際のカーソル行を追跡し、viewportTop で内容が表示領域を超えたときのスクロールを扱う。DECSET 2026 (synchronized output) はターミナルベンダが合意した「この区間内の出力はアトミックにリフレッシュしてください」プロトコルで、TUI は毎フレームこれを包む。代償は、古いターミナルがこのシーケンスを知らず無視し、効果は通常のストリーム書き込みに退化することだ。

主要ファイル

requestRenderprocess.nextTick + setTimeout の二層スケジュールで、同一 tick 内の複数リクエストをマージする:

typescript
// packages/tui/src/tui.ts:495-522
requestRender(force = false): void {
  if (force) {
    this.previousLines = [];
    this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
    // ...
    this.renderRequested = true;
    process.nextTick(() => { /* ... doRender() */ });
    return;
  }
  if (this.renderRequested) return;
  this.renderRequested = true;
  process.nextTick(() => this.scheduleRender());
}

doRender は新しい行を取った後、まず overlay を合成し、次にカーソル marker を抽出し、最後に fullRender か差分パスに進む:

typescript
// packages/tui/src/tui.ts:970-980
let newLines = this.render(width);

// Composite overlays into the rendered lines (before differential compare)
if (this.overlayStack.length > 0) {
  newLines = this.compositeOverlays(newLines, width, height);
}

const cursorPos = this.extractCursorPosition(newLines, height);
newLines = this.applyLineResets(newLines);

差分パスは [firstChanged, lastChanged] 区間だけ書き直し、\r\n で新しい行にスクロールし、全体を同期出力で包む:

typescript
// packages/tui/src/tui.ts:1145-1175
let buffer = "\x1b[?2026h"; // Begin synchronized output
buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
// ...move cursor, scroll if needed...
for (let i = firstChanged; i <= renderEnd; i++) {
  if (i > firstChanged) buffer += "\r\n";
  buffer += "\x1b[2K"; // Clear current line
  buffer += newLines[i];
}

データフロー

外部アプリがコンポーネント状態を変える → コンポーネントが requestRender を呼ぶ → 次フレームの doRender:

境界と失敗

  • 行が幅を超えると crash:visibleWidth(line) > width のとき crash log を ~/.pi/agent/pi-crash.log に書いて stop() する。描画を続けると後続行が破れるからだ。packages/tui/src/tui.ts:1180-1200 参照。
  • 幅変化で fullRender トリガ:previousWidth !== width のとき全画面を描き直し scrollback をクリアする。古い内容の残留を防ぐ。
  • kitty 画像残留:diff 区間内の画像 ID は必ず先に deleteChangedKittyImages してから新しい行を描く。さもなくば古い画像がターミナルの画像パネルに残る。
  • force パスはスロットルをバイパス:resize、テーマ切替などのシーンでは requestRender(true) を呼び、previousLines を空にして nextTick で即描画する。
  • stopped ガード:doRenderscheduleRenderrequestRender はすべて this.stopped を検査し、stop() 後の非同期コールバックがターミナルに書くのを防ぐ。

まとめ

TUI は「コンポーネントツリー → 行 → 差分 → 同期出力」を一つのパイプラインに串刺しにし、ついでに focus、overlay、kitty 画像 GC も管理する。上流を見ると、コンポーネントはすべて Component インターフェースを実装する。コンポーネントライブラリ:Box/Input/Markdown/SelectList 参照。ユーザ入力側は キーボード解析:kitty プロトコル 参照。最も重いコンポーネントは エディタコンポーネント で、IME、kill-ring、undo を引き受ける。