Pi 为什么不用每次重画整个终端
逐段拆开 requestRender 调度、组件行生成、overlay 合成、previousLines 比较、viewport 回退和硬件光标定位,说明 Pi 的差分渲染何时只写变化行、何时必须全量清屏。
第 44 章最后,Editor 每处理一次输入都让 TUI requestRender();流式 assistant delta、工具 partial result、spinner 和图片转换也会这样做。如果一次调用等于一次 clearScreen(),用户看到的会是频繁闪烁,终端 scrollback 也会被不断重置。Pi 的做法不是让每个组件自己更新屏幕,而是每次重新得到完整的“理想行数组”,然后只把最终结果中改变的区间写给终端。
这套差分与浏览器 virtual DOM 不是同一种抽象。它不比较组件类型、props 或节点身份,只比较包含 ANSI、OSC 和 Kitty graphics sequence 的最终字符串行。组件可以全部重算,终端写入仍然很小;反过来,一处上游换行会让后续行字符串整体移位,差分区间也会随之扩大。
requestRender 先合并突发更新
普通 requestRender() 只设置 renderRequested,若已有请求则直接返回。下一 tick 进入 scheduleRender() 后,TUI 根据上次渲染时间补足 MIN_RENDER_INTERVAL_MS = 16,定时器到期才执行 doRender();渲染期间若又收到请求,结束后再排下一次。快速 token delta 因而会被合并,而不是逐 token 同步阻塞 stdout。
requestRender(true) 是不同的合同。它清空 previousLines,把 previous width/height 置为 -1,重置 cursor、high-water mark 与 viewport,并取消现有 timer。它用于 session/branch 大幅替换等无法信任旧屏幕映射的场景,不是普通组件更新的“更快版本”。
差分之前,先得到一份规范化画面
doRender() 读取当前 terminal columns/rows,调用根 Container 生成所有普通组件行。overlay stack 随后合成,因此 overlay 移动和隐藏与 chat 文本变化走同一套行比较。接着 TUI 从当前可见 viewport 由下向上寻找 CURSOR_MARKER,记录 row/column 并从文本中删除 marker;最后为非图片行规范化终端输出、追加样式与 hyperlink reset。
这几步的顺序不能调换。先 diff 再合成 overlay,会漏掉弹窗变化;先追加 reset 再找 marker,硬件光标列计算会混入额外控制序列;只按字符串 index 算 column,又会把中文宽字符和零宽 ANSI 当作普通字符。最终基线必须与真正写入终端的规范化行保持一致。
flowchart TD
accTitle: Pi 差分渲染的一轮计算
accDescr: render request 被调度后,TUI 先生成完整新画面,合成覆盖层并提取光标,再比较新旧行;安全时只写变化区间,不安全时回退到 full redraw,最后保存新的行与视口基线。
REQ["requestRender"] --> SCHEDULE["coalesce and schedule"]
SCHEDULE --> BASE["render component tree"]
BASE --> OVERLAY["composite overlays"]
OVERLAY --> CURSOR["extract CURSOR_MARKER"]
CURSOR --> NORMALIZE["normalize + line reset"]
NORMALIZE --> DIFF["compare previousLines"]
DIFF -->|safe| RANGE["rewrite firstChanged..lastChanged"]
DIFF -->|unsafe| FULL["clear + full render"]
RANGE --> SAVE["save lines / viewport / cursor"]
FULL --> SAVE
firstChanged 和 lastChanged 决定写多少
正常路径把 newLines 与 previousLines 按相同 row 比较,记录第一和最后一个不同位置;纯追加则从旧数组长度开始。没有任何变化时,不写文本,只按新 marker 调整硬件光标。发生变化时,TUI 先为可能跨多行的 Kitty image 扩展区间,再计算 append fast path。
可局部更新时,输出 buffer 由 synchronized-output begin sequence 开始,先删除变化范围内旧 Kitty image,移动 cursor 到目标行,把列归零,再对 firstChanged..renderEnd 逐行 erase line + new content。注释明确说明它不会从第一处变化一路重画到末尾,而是停在 lastChanged;spinner 只改中间一行时,header 和 footer 都不写。整段 buffer 一次交给 terminal.write(),最后以 synchronized-output end sequence 收口,支持该协议的终端能把多条 cursor/erase/write 作为一次视觉更新展示。
写完后,TUI 更新逻辑 cursor row、实际 hardware cursor row、maxLinesRendered、previousViewportTop、previousLines、image ids 与 width/height。下一轮 cursor movement 依赖的是这份已提交状态,不是组件内部认为自己画到了哪里。
full redraw 是正确性回退,不是失败
第一次 render 不清屏,直接输出全部内容;之后以下情况会进入 fullRender(true):
- 终端宽度改变,所有 wrap 边界都可能变化;
- 终端高度改变且不是 Termux 特例,viewport 映射需要重算;
clearOnShrink开启、内容低于历史工作区且没有 overlay,需要清掉旧空行;- 第一处变化已经在旧 viewport 上方,当前硬件 cursor 无法触达 scrollback;
- 删除太多行会越过 viewport,或 Kitty image 的预清理可能造成滚动;
- 调用方显式请求 force render。
full render 使用 CSI 2J 清屏、home 和 CSI 3J 清 scrollback,并先删除旧 Kitty image ids。代价更高,却能重新建立屏幕坐标与 previousViewportTop 的一致关系。把所有 full redraw 都视为性能 bug,会诱使实现对不可见历史做无法保证正确的 cursor manipulation。
Termux 的高度变化是刻意例外:软件键盘开关会频繁改变 rows,若每次重放全部 history,体验更差。因此该环境跳过 height-triggered full redraw,但仍更新 viewport tracking。这不是“高度从不重要”,而是平台行为经过专门权衡。
行宽和图片让终端差分比字符串 diff 更难
普通行写出前,TUI 检查 visibleWidth(line) <= terminal width。越界时会记录所有新行到 crash log、停止并抛出说明,要求 custom component 使用 visibleWidth() 与 truncateToWidth()。终端自动换行会改变物理 cursor row;若允许一行悄悄溢出,下一轮所有相对移动都会建立在错误坐标上。
Kitty image 更复杂:一个 graphics placement sequence 可能在数组中占第一行,同时为图片高度保留多行。差分区间要把受影响的 reserved rows 与 image line 一起扩展;移动或替换图片前必须按 id 删除旧 placement。若预清理会触发 scroll,宁可 full redraw。图片支持因此不是在字符串末尾塞一段 escape code,而是参与 viewport 和 changed-range 计算。
硬件 cursor 则是另一份位置。逻辑 cursor 通常停在渲染内容结尾,IME 需要的硬件 cursor 可以移动到 Editor marker 所在行列。TUI 在写完文本后单独 positionHardwareCursor(),下一轮计算相对移动时使用 hardware cursor row。忽略这两个 cursor 的差别,容易复现“文字正确但输入法候选框或下一次局部更新错位”。
用 spinner 和 resize 看两条渲染路径
下面的固定测试通过 headless xterm 观察最终 viewport,并用 fullRedraws 与输出中的 clear sequence 区分局部和全量路径:
repo="${PI_RELEASE_SOURCE_DIR:-/path/to/pi-0.83.0}"
cd "$repo"
node --test --test-reporter=spec packages/tui/test/tui-render.test.ts
其中 spinner case 只反复修改中间的 Working 行,断言 header/footer 保持不变;resize cases 则确认普通终端 width/height 变化会增加 full redraw 计数,Termux 高度变化不会清屏;图片 cases 检查旧 image id 在新 placement 之前删除,并在可能滚动时回退全量渲染。这些断言比截一张“看起来没闪”的终端图更强,因为它们直接区分了写入策略。
差分渲染只知道当前组件返回了哪些行,不知道这些行来自模型 token、工具 stdout 还是 retry status。第 46 章回到 InteractiveMode:看 AgentSessionEvent 怎样创建和更新组件,以及为什么屏幕上的 tool block 只是执行事件的投影,不是工具运行本身。