开源编程 Agent pi 的终端界面为什么不闪:差分渲染在管什么
本文基于 pi v0.82.1(仓库 commit 027a584,2026-07-28)梳理,该项目仍在快速迭代,具体行为以官方文档 https://pi.dev/docs/latest 与仓库最新代码为准。
终端 Agent 的界面会闪、会错位,多数时候不是”刷得太慢”,而是刷新的粒度不对:整屏重画一次,眼睛就看见一次闪;某一行的显示宽度算错一列,之后所有按行号做的光标移动就全部偏掉。 pi 把这两件事收进了自己的终端 UI 库,一边用差分渲染把每帧的写入量压到只剩真正变化的那几行,一边用一道会直接抛错的宽度校验,逼着组件作者别把账算错。下面按仓库里的源码把它拆开看,落点只有一个:如果你要给 pi 写扩展或者自定义工具的渲染,这套机制对你提了哪些不能商量的要求。
站内已经有两篇从通用角度讲终端输出问题的文章,一篇谈终端输出太长该怎么办,一篇谈流式输出乱序,那两篇讲的是不挑工具的判断方法。这一篇反过来,只盯一个具体项目:同样的问题,pi 是在哪个文件、用什么取舍落到实处的。项目采用 MIT 许可证,主仓库在 https://github.com/earendil-works/pi ,截至 2026 年 7 月在 GitHub 上有约 8 万 star,代码可以随手打开对照。
一、终端不是画布,问题从这里开始
浏览器里改一个 DOM 节点,剩下的事交给渲染引擎。终端没有这层东西:你能做的只是往一个字节流里写字符和转义序列,光标往上挪三行、清掉当前行、再写一段文本。屏幕上最终长什么样,取决于你写出去的这串东西被终端逐个解释之后的累积结果。
这带来两个后果。
第一个是刷新代价。想让界面”更新”,最简单的做法是清屏、把整个界面重新输出一遍。逻辑上没毛病,但用户看到的是每秒钟屏幕被擦掉又画上若干次。如果界面里有个转圈的进度符号,每秒要动好几次,整屏重画就意味着整屏闪好几次。
第二个是记账代价。既然只能相对移动光标,程序就必须自己记住”上一次我把光标停在了第几行”。这份账一旦和终端的实际状态对不上,后面每一次移动都在错误的基点上叠加,界面就开始错位——内容被写到不该在的行上,旧内容擦不掉,越滚越乱。
最容易让账本崩掉的,是行宽。终端在一行写满之后会自动折到下一行。你以为写了一行,实际占了两行,程序记的行号就少了一。差分渲染是完全按行号记账的,错一行,后面全错。
pi 的终端 UI 库(包名 @earendil-works/pi-tui,源码在仓库的 packages/tui)就是围绕这两件事组织的。它的 package.json 里 description 一栏写得很直白:带差分渲染的终端 UI 库。
二、差分渲染这一层具体怎么做
核心逻辑在 packages/tui/src/tui.ts 的 TUI 类里。一次渲染的流程大致是这样:
先向组件树要内容。每个组件实现的接口只有四个成员,文档 packages/coding-agent/docs/tui.md 里给的定义是:
interface Component {
render(width: number): string[];
handleInput?(data: string): void;
wantsKeyRelease?: boolean;
invalidate(): void;
}
注意 render 的返回值是字符串数组,一行一个元素。组件不负责往终端写任何东西,也不负责移动光标,它只负责回答”给我这个宽度,你这块内容是哪几行”。TUI 自己继承自 Container,把所有子组件的行拼成一整个 newLines。
拿到新的一整屏内容之后,才轮到差分。TUI 里存了上一次渲染的结果 previousLines,两个数组逐行比较,找出第一处不同 firstChanged 和最后一处不同 lastChanged,然后只重写这个区间。源码里这一段上方的注释把动机写得很清楚:只渲染变化的行,而不是从变化点一路刷到结尾,这样在只有单行变化(比如转圈动画)时能减少闪烁。
写出去的方式也做了处理。所有的光标移动、清行、内容,先拼成一个字符串 buffer,最后一次性 write。而这个 buffer 的头尾分别是 \x1b[?2026h 和 \x1b[?2026l——同步输出的开始与结束标记。支持这个能力的终端会把这一批更新当作一帧整体提交,用户不会看到画到一半的中间状态。
渲染频率也被压住了。组件调用的是 requestRender(),它只是置一个标志位,真正的渲染排在后面。TUI 里有一个私有常量 MIN_RENDER_INTERVAL_MS,值是 16,两次实际渲染之间至少隔这么多毫秒。也就是说,一秒钟内你调一千次 requestRender(),屏幕也不会被刷一千次。
什么时候它会放弃差分,直接整屏重画
差分不是万能的,doRender 里有一串明确的”退回全量重画”的判断,每一条都对应一类真实场景:
- 第一次渲染。没有
previousLines可比,直接全部输出(这一次不清屏,假定屏幕是干净的)。 - 终端宽度变了。宽度一变,所有文本的折行结果都变了,逐行比较毫无意义。
- 终端高度变了。这里有个例外:源码里判断了
TERMUX_VERSION环境变量,因为 Termux 在软键盘弹出和收起时会改变高度,如果每次都全量重画,整个历史会在每次切换键盘时重放一遍。 - 第一处变化落在上一次视口的顶部之上。差分只能改屏幕上还看得见的行,变化点已经滚上去了就够不着。
- 要删除的行数超过一屏高度。
- 内容变短,且开启了收缩时清理(默认关闭,由
setClearOnShrink()或PI_CLEAR_ON_SHRINK环境变量控制)。 - Kitty 图片的预清理会导致滚动。
这些分支的存在本身就说明了一件事:差分渲染不是”更聪明的重画”,而是”在能确定安全的前提下少写一点”。不能确定的时候,它宁可退回去。
全量重画的次数是可以看到的,TUI 暴露了一个 fullRedraws 取值。想知道每一次全量重画是因为什么触发的,把 PI_DEBUG_REDRAW 设成 1,原因会带着前后行数写进日志文件 pi-debug.log;日志目录默认是用户主目录下的 .pi/agent,也可以用 PI_CODING_AGENT_DIR 改。如果要看更底层的东西——实际写进 stdout 的原始 ANSI 流——文档里提到的是 PI_TUI_WRITE_LOG。这类”把内部状态摊出来给人看”的做法,和给 Agent 留可观察日志是一回事,只是对象换成了渲染层。
三、宽度:这一层是硬的,算错就崩
文档里对 render(width) 的要求只有一句话,但用了加粗:每一行都不得超过 width。
超了会怎样?源码里给的不是警告,是终止。在差分写出每一行之前,doRender 会对非图片行调用 visibleWidth(line) 校验,一旦超过终端宽度,它会把所有渲染行连同各自的宽度写进崩溃日志 pi-crash.log,先调用 stop() 把终端状态恢复干净,然后抛出错误。错误信息是这样组装的:
const errorMsg = [
`Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,
"",
"This is likely caused by a custom TUI component not truncating its output.",
"Use visibleWidth() to measure and truncateToWidth() to truncate lines.",
"",
`Debug log written to: ${crashLogPath}`,
].join("\n");
这个选择值得琢磨。宽度溢出如果不管,表现是界面缓慢地、间歇性地错位,用户报上来的现象是”有时候会乱”,排查成本极高。直接崩掉,加上一份写明是哪一行、宽度多少、所有行的内容都在里面的日志,反而是最省事的。
为什么算宽度这么容易错?因为字符串长度和显示宽度是两码事。packages/tui/src/utils.ts 里的 visibleWidth 做了三件 str.length 不会做的事:把 ANSI 转义序列剥掉(它们不占显示列)、按字素簇而不是码点分段、对东亚宽字符按两列计算。一个汉字占两列,一个 emoji 可能占两列且由多个码点组成,一段带颜色的文本里有一大半字符根本不显示。用长度当宽度,中文界面必错。
配套的工具也在同一个文件里:truncateToWidth 按显示宽度截断,wrapTextWithAnsi 按显示宽度折行并且保留 ANSI 样式,sliceByColumn 按列切片。文档里的原则是:量宽度用 visibleWidth,截断用 truncateToWidth。
还有一个容易漏的细节:样式不跨行。TUI 在每一行渲染完之后会追加一次完整重置,源码里这个常量叫 SEGMENT_RESET:
private static readonly SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
前半段是 SGR 重置,后半段关闭 OSC 8 超链接。这意味着你在第一行开的颜色,到第二行就没了。文档给的做法是每行重新上色,或者直接用 wrapTextWithAnsi(),它会给折出来的每一行都补上样式。
四、光标、覆盖层和输入层:差分之外的三块拼图
光标。 pi 的界面里那个闪的光标,默认不是终端的硬件光标——硬件光标是隐藏的,界面上看到的是组件用反显画出来的假光标。这样做渲染上更自由,但有个问题:输入法的候选窗跟随的是硬件光标。中日韩用户打字时,候选框会飘到屏幕上一个莫名其妙的位置。
解决办法是一个零宽标记。tui.ts 里定义了:
export const CURSOR_MARKER = "\x1b_pi:c\x07";
这是一条 APC 序列,终端会忽略它,也不占显示宽度。实现了 Focusable 接口的组件在获得焦点时,把这个标记插在假光标的位置上;TUI 在可视区里从下往上扫,找到标记后算出它前面那段文本的显示宽度作为列号,把标记从这一行里剥掉,再把硬件光标挪过去。硬件光标默认仍然是隐藏的——有些终端在光标隐藏时也能正确定位输入法候选窗,有些不能,后者可以用 setShowHardwareCursor(true) 或者 PI_HARDWARE_CURSOR=1 把它显示出来。
文档里特意提醒了一种情况:如果你写的是一个容器组件,里面套了 Input 或 Editor,容器必须自己实现 Focusable 并把 focused 透传给子组件,否则中文输入法的候选窗位置就不对。
覆盖层。 对话框、选择菜单这类东西在 pi 里叫 overlay。它们并不是另开一块屏幕,而是在差分比较之前就被合成进了那一整屏内容里:compositeOverlays 先按配置解析出每个覆盖层的宽度、行、列,渲染出各自的行,再用 compositeLineAt 把它们按列拼进对应的基础行。合成完的结果照样走同一套逐行比较。
这个设计的好处是覆盖层不需要另一套刷新机制,坏处是它对宽度更敏感——所以 compositeLineAt 的最后有一段防御性代码,注释里直接写明这是防止宽度溢出把 TUI 弄崩的最后一道保险:算完结果还要再量一次 visibleWidth,超了就强制截断。覆盖层的选项里还有一个 visible 回调,接收终端宽高,返回 false 时这个覆盖层就不渲染,用来在窄终端上直接把侧边面板藏掉。
输入层。 packages/tui/src/terminal.ts 里的 ProcessTerminal 管的是另一半:raw 模式、括号粘贴模式、键盘协议协商、退出时的清理。有几处细节挺能说明这一层有多少坑:
键盘协议协商用的是一条组合查询,常量拼出来是先请求想要的 Kitty 标志位(7,含义是消歧义转义码、上报按键事件类型、上报替代键),再查询当前状态,末尾附一条设备属性查询当哨兵。不认识 Kitty 键盘协议的终端不会回应前两条,但会回应最后那条,程序据此立刻回退到 modifyOtherKeys,不用干等一个启动超时。
退出路径同样有讲究。drainInput 会先关掉 Kitty 键盘协议,再把 stdin 里残留的输入排干,注释说明这是为了防止慢速 SSH 连接上迟到的按键释放事件泄漏给父 shell。stop() 里还会先 pause 掉 stdin 再恢复 raw 模式,注释写的是修复一个竞态:缓冲区里的 Ctrl+D 可能在退出 raw 模式之后被重新解释,在 SSH 上把父 shell 给关了。
还有一条:非 Windows 平台在 start() 时会给自己发一次 SIGWINCH。原因是进程被挂起(比如 Ctrl+Z)期间的窗口尺寸变化信号会丢失,恢复之后拿到的行列数可能是陈旧的。
组成部分速查
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
TUI 类 | 渲染主循环、逐行比较、全量重画判定、光标记账、覆盖层合成 | packages/tui/src/tui.ts | 排查闪烁、错位、意外整屏重画时 |
Component 接口 | 组件与渲染层的唯一契约(render / handleInput / invalidate) | packages/tui/src/tui.ts | 写任何自定义界面的第一步 |
Terminal 接口与 ProcessTerminal | raw 模式、键盘协议协商、粘贴模式、尺寸、退出清理 | packages/tui/src/terminal.ts | 按键识别不对、退出后终端状态异常时 |
| 宽度与切片工具 | visibleWidth、truncateToWidth、wrapTextWithAnsi、sliceByColumn | packages/tui/src/utils.ts | 每次拼装要输出的行时 |
| 现成组件 | 文本、盒子、选择列表、设置列表、编辑器、Markdown、图片等 | packages/tui/src/components/ | 动手自己写之前先来这里翻一遍 |
| 扩展侧文档 | 组件系统说明、常用模式、主题色名、关键规则 | packages/coding-agent/docs/tui.md | 写扩展或自定义工具渲染时 |
| 渲染回归测试 | 渲染与收缩行为、覆盖层边界、样式泄漏等用例 | packages/tui/test/ | 想确认某个行为是不是有意为之时 |
五、边界与代价:这套设计明确不管什么
它不是全屏应用。 pi 的界面是在主屏缓冲区上按行追加和改写的,不切换到备用屏。好处是历史留在滚动缓冲里,用户可以往上滚着看之前的对话。代价是很多整屏动画式的做法在这里不适用,而且除首次渲染那一次之外,全量重画都会连滚动缓冲一起清掉。
它没有布局引擎。 Container 做的事就是把子组件的行按顺序垂直堆起来,没有 flex,没有 grid,没有百分比宽度(覆盖层的定位选项是个例外,那是单独一套)。给定宽度之内怎么排,是组件自己的事。想要多列布局,你得自己算列宽、自己拼行。
它按行比较,不做单元格级别的比较。 一行里改一个字符,这一行还是要整行重写。换来的是实现足够简单、行为足够可预测——考虑到还要处理 ANSI 样式、宽字符、超链接、图片,单元格级的比较会让复杂度上一个量级。
它对滚出视口的内容无能为力。 变化点一旦跑到上一次视口的上方,就只能整屏重画。这也意味着”修改很久以前输出的某一行”这种需求,在这套模型里代价是最高的。
它不保证跨终端表现一致。 同步输出、Kitty 键盘协议、Kitty 图片、终端配色查询,全都是能力探测加优雅降级。探测不到就走退路,退路的体验不一定和原路一样。图片这类能力更是只在部分终端里存在。
它不理解你的内容。 它不知道你这一屏里哪块是对话、哪块是工具输出、哪块该重排。所有语义判断都留在组件层。你把内容组织成什么样,它就照什么样比较和写出去。
它也不替你兜宽度错误。 这一条前面说过,这里再强调一次:它选择的是崩溃加日志,不是静默截断。
六、上手与避坑清单
用 str.length 当宽度。 会踩是因为在纯英文界面下它一直是对的,直到某个工具返回里出现中文或 emoji。后果不是显示难看,是直接抛错并写崩溃日志。改法:量宽度一律用 visibleWidth,截断用 truncateToWidth,需要折行用 wrapTextWithAnsi。
多行文本只在开头上一次色。 会踩是因为在别的场景里 ANSI 样式确实会一直延续到显式重置。但这里每一行末尾都被 TUI 追加了完整重置,样式跨不过行边界。改法:逐行上色,或者用 wrapTextWithAnsi 让它替你补。
把主题色预先烘焙进字符串然后缓存。 会踩是因为清缓存的直觉是”清掉渲染结果就行”。但主题切换时 TUI 会对所有组件调 invalidate(),如果你把带颜色的字符串存进了子组件,父组件清掉的只是子组件的渲染缓存,那串带着旧主题转义码的内容还在。文档给的模式是在 invalidate() 里先 super.invalidate(),再重建内容。
改完状态忘了请求重绘。 会踩是因为渲染是被请求驱动的,不是轮询的。你在 handleInput 里改了选中项,界面不会自己动。改法:状态变化之后调 tui.requestRender(),这是文档”关键规则”里单列的一条。
容器里套了输入组件却没做焦点透传。 会踩是因为用英文键盘测试时完全看不出问题——假光标画在正确的位置,看着一切正常。只有开输入法打中文时,候选框才会飘到别处。改法:容器实现 Focusable,在 focused 的 setter 里把值同步给子组件。
留着覆盖层组件的旧引用想再显示一次。 会踩是因为这个引用在语法上完全有效,只是对象已经被丢弃了。文档里明确写了覆盖层关闭时组件会被释放,正确做法是把”显示”这件事包成一个函数,需要再显示就再调一次,让它重新创建实例。
界面偶发闪一下,查不出原因。 会踩是因为闪烁往往和特定的终端尺寸、特定的内容长度绑定,复现条件苛刻。改法:把 PI_DEBUG_REDRAW 设成 1,每次全量重画的原因、前后行数、终端高度都会落到日志里;对着前面第二节那张触发条件清单,基本能对上号。这个思路和给 Agent 框架做调试是通的——先让系统告诉你它为什么做了那个决定,再去改。
自己写一个已经有的组件。 会踩是因为需求听起来很简单:“弹个列表让用户选一下。“文档里的态度很直接:选择列表、设置列表、带取消的加载框这几个覆盖了绝大多数场景,别重写。真要自定义工具的渲染结果,先看看现成的文本和 Markdown 组件够不够用,这一层的思路和工具返回值该怎么设计是连着的——渲染只是返回值设计的下游。
收个尾
如果你正准备给 pi 写点界面上的东西,按这个顺序自查一遍会省不少事:render(width) 返回的每一行,宽度是不是都用 visibleWidth 量过;有没有缓存,缓存能不能被 invalidate() 完整清掉(包括预先烘焙的颜色);状态变化之后有没有调 requestRender();如果里面有输入框,焦点有没有透传下去;多行样式是不是每行都重新上了。
接下来该读哪个文件,取决于你卡在哪一层。想搞清楚界面为什么这么刷,读 packages/tui/src/tui.ts 的 doRender,那一个方法基本就是全部答案;想搞清楚按键为什么识别不对或者退出后终端状态不干净,读 packages/tui/src/terminal.ts;想知道某个行为是有意设计还是碰巧如此,去 packages/tui/test/ 里翻,那里的用例名字起得很直白。而如果你只是想赶紧把一个选择框做出来,packages/coding-agent/docs/tui.md 的常用模式那几节,直接抄就行。
本篇属于一个把开源编程 Agent 项目 pi 逐层拆开讲的系列,整体地图见开源编程 Agent pi 是什么;沿着这条线往下,还可以看 开源编程 Agent pi 的本地模型接入 和 终端编程 Agent 选型五条线。