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。