Skip to content

🐣 小a的"辣眼睛"事件

Agent 跑起来了,但它在终端里显示得一塌糊涂——每刷新一次,整个屏幕闪一下,文字抖动,小a自己都看不下去。

用户投诉:"你这界面辣眼睛啊,能不能丝滑点?"

小a委屈:"终端不就这样吗?刷新能不闪?"

老z摇头:"那是你没用对方法。pi 有个专门的包叫 pi-tui,就是解决这个的。它的核武器叫差分渲染——只重画变了的部分,不闪。"


19.1 传统 CLI 为什么闪

先诊断病因。

🧙 传统 CLI 的刷新方式:全屏重画

每次要更新界面,就:

  1. clear 清屏(或光标回到左上角)
  2. 把整个界面重新打印一遍

问题:清屏和重画之间有个"空屏"瞬间,人眼能看到 → 闪烁

而且每次都打印全部内容,即使只改了一个字——浪费、慢。

传统刷新:
  清屏 → [空屏瞬间] → 重画全部 → [完整界面]

          人眼看到闪烁

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:178

typescript
let 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.ts

typescript
const 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。


课后实验

  1. 看同步输出:打开 tui-main-screen.ts,找到第 178 行的 \x1b[?2026h 和第 200 行的 \x1b[?2026l,理解"begin/end 同步"的成对使用。
  2. 感受差分:想想如果你自己实现"只更新变化的行",要维护什么状态(上一帧?行哈希?)。体会复杂度。
  3. 试终端能力:在你的终端里跑 echo $TERM,看它是什么。想想 pi-tui 要为多少种终端写兼容代码。

下一章:前面拆了地基(ai)、运行时(agent-core)、界面(tui)。现在看 pi 怎么把它们组装成最终的编码产品。 → 第 20 章 · pi-coding-agent 源码