数据截至 (上游 commit 59a71b235dad)
pi-tui:差分渲染的终端 UI 库
30 秒导读: Pi 是一个跑在终端里的编码 agent。它的界面(输入框、Markdown 输出、选择菜单、加载动画……)不是用别人的框架画的,而是自造了一个 TUI 库
@earendil-works/pi-tui。这一章讲这个库本身:它把整个界面表达成"一组字符串行",每次刷新时只重画和上一帧不同的那几行(差分渲染),从而在流式输出、动画光标 下不闪屏。这是全书最外围、最独立的一章——不依赖前四章,反过来第 04 章的交互模式用的就是这里的组件。
1. 这是什么(零基础也能懂)
一句话定义: pi-tui 是一个用差分渲染画终端界面的独立库——你给它一棵组件树,它负责把树渲染成文本,并聪明地只更新屏幕上变化的部分。
它解决什么问题。 终端本质上是一块字符网格,程序通过写 ANSI 转义序列(以 \x1b[ 开头的控制码,如"光标上移 3 行""清除本行""反色")来控制它。最朴素的刷新办法是每帧清屏 + 重画全部——但这会闪烁,而且当内容有几百行、还夹着流式 token 时,重画量巨大。pi-tui 的价值就是把"重画"变廉价。
为什么一个 agent CLI 要自造 TUI。 有现成的 ink、blessed 等库,但 agent 的界面有几个刁钻需求:
- 输出里混着中文/emoji(宽字符) 和彩色/超链接(ANSI 码),朴素的"按字符数截断"会把界面撑爆或错位。
- LLM 是流式吐字的,界面每秒要刷新很多次,必须避免整屏重绘。
- 要支持终端内嵌图片(截图、图表)、IME 输入(中日韩候选框定位)、Kitty 键盘协议(区分
Shift+Enter和Enter)——这些库大多支持不全。
于是 Pi 把渲染引擎做成一个零业务逻辑、可单独发布的包,自己吃透这些底层细节。
用起来什么样。 使用者组装一棵组件树,交给 TUI 驱动:
// 示意,非源码
const tui = new TUI(new ProcessTerminal()); // 绑定真实终端
const editor = new Editor(tui, theme, options); // 一个多行编辑器组件
tui.addChild(new Markdown("# Hello", theme)); // 一段 Markdown
tui.addChild(editor);
tui.setFocus(editor); // 键盘输入交给 editor
tui.start(); // 进入 raw 模式,开始渲染循环
editor.onSubmit = (text) => { /* 用户按了回车 */ };
一句话直觉/类比。 把它想成终端界面的"React 但只有一个 diff 维度":组件负责 render(width) → string[](把自己画成若干行),TUI 负责比较新旧两组行、只把差异写回屏幕。React diff 的是虚拟 DOM 树,pi-tui diff 的是一维的行数组——终端就是一叠行,这个抽象刚好合身。
2. 顶层全景(它大概怎么转)
这一节讲这个库由哪些部件组成、一帧是怎么流动的。
2.1 部件地图
| 部件 | 职责 | 文件 |
|---|---|---|
TUI 接口 / TuiMainScreen | 渲染循环 + 差分算法 + overlay + 焦点/输入分发(TUI 现为接口,主屏实现拆到 tui-main-screen.ts) | packages/tui/src/tui.ts:291、tui-main-screen.ts:57 |
Component 接口 | 所有组件的契约:render(width)/invalidate() | packages/tui/src/tui.ts:23 |
Terminal 抽象 | 底层终端读写:raw 模式、stdin、尺寸、光标 | packages/tui/src/terminal.ts:60 |
utils.ts | 宽字符与 ANSI 的度量/切片/换行(渲染的数学) | packages/tui/src/utils.ts |
| 组件库 | Box/Text/Markdown/Input/Editor/SelectList/Loader/Image… | packages/tui/src/components/ |
| 输入解析 | 按键 → 语义键名:keys.ts/keybindings.ts/native-modifiers.ts | packages/tui/src/keys.ts |
terminal-image.ts | Kitty/iTerm2 图形协议编码、能力探测 | packages/tui/src/terminal-image.ts |
terminal-colors.ts | OSC 11 背景色 / 颜色主题探测的解析 | packages/tui/src/terminal-colors.ts |
2.2 一帧的数据流
一帧从"有东西变了"开始,到"屏幕上只改了那几行"结束:
组件状态变化 ┌──────────── TUI.doRender() ───────────┐
(setText / 按键 / 动画帧) │ │
│ │ 1. render(width) 收集所有组件的行 │
▼ │ ↓ │
requestRender() ──合并到下一 tick──▶ │ 2. 合成 overlay(菜单/弹层叠上去) │
(16ms 节流) │ ↓ │
│ 3. 抽出光标标记(给 IME 定位) │
│ ↓ │
│ 4. 与 previousLines 逐行比较, │
│ 求出 firstChanged..lastChanged │
│ ↓ │
│ 5. 只把这段行写回终端(移动光标+清行) │
└────────────────┬───────────────────────┘
▼
terminal.write(buffer)
关键点:第 4、5 步是这个库的心脏。它不 diff 组件树,而是把树 render 成一维行数组,再和上一帧的行数组做逐行字符串比较。因为终端天然是一叠行,这个粗糙但极快的 diff 就够用了。
主线走一遍(高层): 用户在编辑器里敲一个字 → Editor.handleInput 改内部文本、调 tui.requestRender() → 下一个事件循环 tick 触发 doRender() → 整棵树重新 render() 出比如 200 行 → 和上一帧比较发现只有编辑器那 1 行变了 → TUI 只把光标移到那行、清行、重写那 1 行。屏幕其余 199 行纹丝不动,没有闪烁。
3. 核心机制之一:差分渲染
它要解决的小问题: 界面每秒刷新很多次,但每次通常只有一两行真的变了(光标闪、spinner 转、流式追加一行)。怎么只更新那部分?
思路/直觉: 保存上一帧渲染出的行数组 previousLines;新一帧渲染出 newLines;从上往下、从下往上找出"第一处不同"和"最后一处不同",只重写这个闭区间。其余的行终端里已经是对的,一个字节都不用发。
3.1 找出变化区间
doRender() 里这段逐行扫描就是 diff 的全部核心(packages/tui/src/tui-main-screen.ts:297 起):
// 真实源码节选,tui-main-screen.ts:298
for (let i = 0; i < maxLines; i++) {
const oldLine = i < this.previousLines.length ? this.previousLines[i] : "";
const newLine = i < newLines.length ? newLines[i] : "";
if (oldLine !== newLine) {
if (firstChanged === -1) firstChanged = i;
lastChanged = i;
}
}
它把"哪几行需要动"缩小成一个区间。随后只对 firstChanged..lastChanged 生成写入序列(tui-main-screen.ts:420 的渲染循环),而不是从头写到尾。注释里点明了动机:"减少闪烁——当只有一行变化时(如 spinner 动画)"。
3.2 光标怎么走到那一行
终端的光标是相对定位的("上移 N 行""下移 N 行"),没有"跳到第 137 行"这种绝对命令(在滚动区里)。所以 TUI 必须自己记住光标现在在第几行(hardwareCursorRow),算出到目标行的差值,发 \x1b[<n>B(下移)或 \x1b[<n>A(上移),再 \r 回到行首(tui-main-screen.ts:190 的 computeLineDiff)。这套"记账"是差分渲染能工作的前提:diff 告诉你改哪行,光标记账告诉你怎么走过去。