Skip to content

TUI 類別:差分渲染的排程中樞

源码版本v0.73.1

@mariozechner/pi-tuiTUI 類別是終端機 UI 的渲染排程器。它繼承自 Container,自身也是一個元件樹根,但額外承擔三件事:把元件樹渲染成行、和上一幀的行做差分、包一層 DECSET 2026 同步輸出再寫到 terminal。同時它還管 focus、overlay 彈出層、kitty 圖像 ID 回收。整個 pi-mono 的 TUI 介面都跑在它的 doRender 迴圈上。

職責

TUI 做四件事:

  1. 渲染排程:requestRender 不立即繪,而是合併到下一幀,強制 16ms 間隔防止 spinner 抖到 100% CPU。見 packages/tui/src/tui.ts:495-522
  2. 差分比對:doRenderthis.render(width) 的新行和 previousLines 逐行比對,找出 firstChanged/lastChanged,只重繪變化區間。見 packages/tui/src/tui.ts:953-1011
  3. Overlay 合成:彈出層在差分之前合進 newLines,這樣 base 內容變化和彈出層位置變化統一參與 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];
}

資料流

外部 app 改元件狀態 → 元件呼叫 requestRender → 下一幀 doRender:

邊界與失敗

  • 行超寬直接 crash:visibleWidth(line) > width 時寫 crash log 到 ~/.pi/agent/pi-crash.logstop(),因為繼續繪會撕裂後續行。見 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。