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。