TUI 類別:差分渲染的排程中樞
@mariozechner/pi-tui 的 TUI 類別是終端機 UI 的渲染排程器。它繼承自 Container,自身也是一個元件樹根,但額外承擔三件事:把元件樹渲染成行、和上一幀的行做差分、包一層 DECSET 2026 同步輸出再寫到 terminal。同時它還管 focus、overlay 彈出層、kitty 圖像 ID 回收。整個 pi-mono 的 TUI 介面都跑在它的 doRender 迴圈上。
職責
TUI 做四件事:
- 渲染排程:
requestRender不立即繪,而是合併到下一幀,強制 16ms 間隔防止 spinner 抖到 100% CPU。見packages/tui/src/tui.ts:495-522。 - 差分比對:
doRender把this.render(width)的新行和previousLines逐行比對,找出firstChanged/lastChanged,只重繪變化區間。見packages/tui/src/tui.ts:953-1011。 - Overlay 合成:彈出層在差分之前合進
newLines,這樣 base 內容變化和彈出層位置變化統一參與 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];
}資料流
外部 app 改元件狀態 → 元件呼叫 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。