TUI クラス:差分レンダリング (differential rendering) のスケジューラ
@mariozechner/pi-tui の TUI クラスはターミナル UI のレンダリングスケジューラ (scheduler) だ。Container を継承し、自身もコンポーネントツリーのルートだが、三つの仕事を追加で担う:コンポーネントツリーを行にレンダリングし、前フレームの行と差分を取り、DECSET 2026 同期出力で包んで terminal に書き出す。あわせて focus、overlay 弾層、kitty 画像 ID の回収も管理する。pi-mono 全体の TUI 画面はすべてこの doRender ループの上で動く。
責務
TUI は四つの仕事をする:
- レンダリングスケジュール:
requestRenderはすぐ描画せず、次フレームにマージする。16ms 間隔を強制し、spinner が CPU を 100% 食いつぶすのを防ぐ。packages/tui/src/tui.ts:495-522参照。 - 差分比較:
doRenderはthis.render(width)の新しい行とpreviousLinesを行ごとに比較し、firstChanged/lastChangedを見つけて変化区間だけ再描画する。packages/tui/src/tui.ts:953-1011参照。 - Overlay 合成:弾層 (overlay) は差分計算の前に
newLinesに合成される。これで base 内容の変化と overlay 位置の変化が統合して diff に関わる。packages/tui/src/tui.ts:758-808参照。 - 同期出力:すべての書き込みを
\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 は毎フレームこれを包む。代償は、古いターミナルがこのシーケンスを知らず無視し、効果は通常のストリーム書き込みに退化することだ。
主要ファイル
packages/tui/src/tui.ts:39-63—Componentインターフェース:render(width)が行配列を返し、handleInputは任意、wantsKeyReleaseが kitty release を届けるかを制御。packages/tui/src/tui.ts:200-234—Container実装。renderは children の行を順に繋ぐ。コンポーネントツリー組み立ての基礎。packages/tui/src/tui.ts:239-272—class TUI extends Container宣言:previousLines、overlayStack、hardwareCursorRowなどの内部状態フィールド。packages/tui/src/tui.ts:758-808—compositeOverlaysは overlay 行を anchor/margin で配置し base 行に重ねる。packages/tui/src/tui.ts:832-874— kitty 画像 ID 回収:diff 区間内に出た画像 ID を収集しdeleteKittyImagesで消す。幽霊画像の残留を防ぐ。packages/tui/src/tui.ts:953-1011—doRender主フロー:render → composite → extract cursor → applyLineResets → fullRender or diff render。packages/tui/src/tui.ts:1145-1230— 差分パスの buffer 構築。\x1b[?2026hから\x1b[?2026lまで。
requestRender は process.nextTick + setTimeout の二層スケジュールで、同一 tick 内の複数リクエストをマージする:
// 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 か差分パスに進む:
// 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 で新しい行にスクロールし、全体を同期出力で包む:
// 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ガード:doRender、scheduleRender、requestRenderはすべてthis.stoppedを検査し、stop()後の非同期コールバックがターミナルに書くのを防ぐ。
まとめ
TUI は「コンポーネントツリー → 行 → 差分 → 同期出力」を一つのパイプラインに串刺しにし、ついでに focus、overlay、kitty 画像 GC も管理する。上流を見ると、コンポーネントはすべて Component インターフェースを実装する。コンポーネントライブラリ:Box/Input/Markdown/SelectList 参照。ユーザ入力側は キーボード解析:kitty プロトコル 参照。最も重いコンポーネントは エディタコンポーネント で、IME、kill-ring、undo を引き受ける。