Appearance
🐣 小a的"辣眼睛"事件
Agent 跑起来了,但它在终端里显示得一塌糊涂——每刷新一次,整个屏幕闪一下,文字抖动,小a自己都看不下去。
用户投诉:"你这界面辣眼睛啊,能不能丝滑点?"
小a委屈:"终端不就这样吗?刷新能不闪?"
老z摇头:"那是你没用对方法。pi 有个专门的包叫 pi-tui,就是解决这个的。它的核武器叫差分渲染——只重画变了的部分,不闪。"
19.1 传统 CLI 为什么闪
先诊断病因。
🧙 传统 CLI 的刷新方式:全屏重画
每次要更新界面,就:
clear清屏(或光标回到左上角)- 把整个界面重新打印一遍
问题:清屏和重画之间有个"空屏"瞬间,人眼能看到 → 闪烁。
而且每次都打印全部内容,即使只改了一个字——浪费、慢。
传统刷新:
清屏 → [空屏瞬间] → 重画全部 → [完整界面]
↑
人眼看到闪烁19.2 差分渲染:只画变的
pi-tui 的解法是差分渲染(differential rendering)。
🧙 差分渲染是什么?
不清屏、不重画全部。而是比较新旧两屏,只更新有差异的那些行。
老z打比方:
- 传统 = 擦掉整块黑板,重写所有内容(闪)
- 差分 = 只擦掉写错的那一行,重写那一行(不闪,快)
大部分内容没变,为什么要重画?只更新差异,又快又不闪。
📄 源码证据
packages/tui/src/tui-main-screen.ts:260(找"变了哪些行")typescript// Find first and last changed lines let firstChanged = -1; let lastChanged = -1; const maxLines = Math.max(newLines.length, this.previousLines.length); 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; // 不断更新最后一个变化的行号 } }
🧙 看懂这个算法:
- 逐行对比新旧两帧,逐行比较(
oldLine !== newLine)- 只记第一个变化行(
firstChanged)和最后一个变化行(lastChanged)- 渲染时只重画这个区间,区间外一行不动
它不追求"哪一行变了就画哪行"的极致,而是"变了的最小连续区间"——实现简单,且性能足够(终端渲染通常比这个瓶颈小得多)。老z点评:工程是平衡,不是炫技。
19.3 同步输出:消除最后的撕裂
光差分还不够——就算只更新几行,这几行也是"一组"更新,如果终端边收边画,可能画到一半被看到(撕裂)。
pi-tui 用一个终端特性解决:同步输出(Synchronized Output)。
📄 源码证据
packages/tui/src/tui-main-screen.ts:178typescriptlet buffer = "\x1b[?2026h"; // Begin synchronized output // ... 把所有更新攒进 buffer ... buffer += "\x1b[?2026l"; // End synchronized output
🧙 这两个神秘序列是什么?
\x1b[?2026h= "开始同步输出"(告诉终端:接下来我要发一批更新,你先攒着别画)\x1b[?2026l= "结束同步输出"(告诉终端:好了,现在一次性画出来)这是终端的 CSI 2026 协议。在 begin 和 end 之间,pi-tui 把所有要更新的内容攒成一个 buffer,然后终端原子地一次性刷新——人眼永远看不到"画到一半"的中间状态。
老z打比方:像快递——差分渲染是"只送变化的包裹"(少),同步输出是"把包裹打包成一箱一次送达"(不撕裂)。两个加起来,又少又稳。
19.4 双屏模式:主屏 vs 备用屏
pi-tui 有两种"渲染器"(屏幕模式),看源码就知道:
📄 源码证据
packages/tui/src/├── tui-main-screen.ts ← 主屏模式 ├── tui-alt-screen.ts ← 备用屏模式(alt screen) └── tui.ts ← 共同的 TUI 接口
🧙 两种屏的区别:
主屏(main screen) 备用屏(alt screen) 行为 内容会滚动(像普通终端) 固定视口,程序自己管滚动 用途 长对话、聊天流 全屏交互界面(编辑器、菜单) 滚动 终端自动 程序控制(支持鼠标/键盘滚) 为什么两种? 因为不同场景需求不同:
- 聊天历史一直在增长 → 主屏(自动滚动)
- 选择菜单/编辑代码 → 备用屏(固定视口,精准控制)
两者实现同一个 TUI 接口(
tui.ts),所以上层代码不用改,换屏模式即可。
19.5 组件化:像搭积木一样拼界面
pi-tui 不只是"画字",它有完整的组件体系。
📄 源码证据
packages/tui/src/components/—— 内置组件pi-tui 提供这些积木:
- Text / TruncatedText:文字(可自动截断)
- Input / Editor:输入框、编辑器
- Markdown:渲染 Markdown
- Loader:加载动画
- SelectList / SettingsList:选择列表
- ScrollView:滚动视图
- Image:终端内显示图片(Kitty/iTerm2 协议)
- Box / Container / VStack / HStack:布局容器
🧙 组件化的好处:
界面是"组件树"——大组件套小组件,各自负责自己的渲染。比如:
Container ├── Text("对话历史") ├── ScrollView │ └── 多个 AssistantMessage 组件 └── HStack ├── Input(输入框) └── Loader(加载中)每个组件实现统一的
render()接口,pi-tui 负责把它们组合、差分、渲染。写 TUI 像写前端组件一样——不用操心"光标移到哪、清哪一行",组件帮你封装了。
19.6 终端能力抽象:Kitty 协议、图形、键位
pi-tui 还要处理终端的"怪癖"——不同终端能力不同。
📄 源码证据
packages/tui/src/terminal.tstypescriptconst DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS = 7; // ... 检测终端是否支持 Kitty 键盘协议
🧙 终端能力的差异:
- 键盘协议:Kitty 协议能区分更多按键组合(Ctrl+Shift+Enter 等),老终端不行
- 图形:Kitty/iTerm2 支持终端内显示图片,别的终端只能显示文字
- 颜色:真彩色 vs 256 色 vs 16 色
pi-tui 的
terminal.ts负责检测终端能力,然后优雅降级——支持图片就显示图片,不支持就显示占位符。不让"能力差异"导致崩溃。
其他细节
pi-tui 还实现了一堆"终端工程细节",让交互更顺手:
| 文件 | 干什么 |
|---|---|
keybindings.ts / keys.ts | 键位绑定(Emacs 风格:Ctrl+A 行首等) |
kill-ring.ts | 剪贴板环(多次 yank 循环粘贴) |
undo-stack.ts | 撤销栈 |
fuzzy.ts | 模糊搜索(快速找文件) |
autocomplete.ts | 自动补全 |
word-navigation.ts | 按词移动(Ctrl+←/→) |
🐣 小a感叹:"原来终端里做好交互,要这么多细节——剪贴板、撤销、模糊搜索……跟做个编辑器差不多了。"
🧙 "对。 这就是为什么 Part III 会警告你『TUI 是大坑,能不做就不做』——这些细节每一个都是工作量。pi-tui 帮 pi 把这些坑都填了,但你自己从零做,成本极高。"
19.7 代价
🧙 代价一:实现复杂
差分渲染要维护"上一帧状态"、比较差异、计算光标移动——逻辑不简单。CSI 2026 依赖终端支持(老终端没有)。
代价二:调试难
TUI 的 bug 很难调——界面闪烁、错位,你看不到中间状态,只能靠日志推断。
代价三:平台差异
不同终端(iTerm2/Windows Terminal/老式终端)行为不一,要写一堆兼容代码。
🐣 小a记下:"所以 pi-tui 是个重资产——做得好很难,但做好了体验质的飞跃。如果我不是做 CLI 产品,根本不该自己写 TUI。"
🧙 "完全正确。 这正是 Part III 的判断:TUI 是给『终端就是产品』的场景准备的,别的场景别碰。"
本章小结
🐣 小a的第十九课
┌──────── pi-tui 不闪的奥秘 ────────┐ │ │ │ • 病因:全屏重画 → 闪烁 │ │ │ │ • 差分渲染:只画变的行 │ │ → 不闪、快 │ │ │ │ • 同步输出 CSI 2026: │ │ \x1b[?2026h ... \x1b[?2026l │ │ → 攒一批更新,原子刷新 │ │ → 消除撕裂 │ │ │ │ • 双屏:主屏(滚动)vs 备用屏 │ │ (视口),同一接口 │ │ │ │ • 组件化:像前端一样拼界面 │ │ Text/Input/Markdown/Scroll... │ │ │ │ • 终端能力:Kitty 协议/图形/颜色 │ │ → 检测+优雅降级 │ │ │ │ • 代价:复杂、难调试、平台差异 │ │ → 非 CLI 产品别碰 │ └──────────────────────────────────┘
关键认知:pi-tui 用"差分渲染 + 同步输出"让终端不闪,用"组件化"让界面好写。但它是重资产——除非你的产品就是 CLI,否则别自己造 TUI。
课后实验
- 看同步输出:打开
tui-main-screen.ts,找到第 178 行的\x1b[?2026h和第 200 行的\x1b[?2026l,理解"begin/end 同步"的成对使用。 - 感受差分:想想如果你自己实现"只更新变化的行",要维护什么状态(上一帧?行哈希?)。体会复杂度。
- 试终端能力:在你的终端里跑
echo $TERM,看它是什么。想想 pi-tui 要为多少种终端写兼容代码。
下一章:前面拆了地基(ai)、运行时(agent-core)、界面(tui)。现在看 pi 怎么把它们组装成最终的编码产品。 → 第 20 章 · pi-coding-agent 源码