Pi 源码拆解(五):pi-tui 的行数组差分渲染

1 分钟阅读
·

本文是「Pi 源码拆解」系列第 5 篇。系列目录:

行数组差分渲染,是把一次终端界面表示为按显示顺序排列的字符串数组:数组中的每个元素对应一行。下一次刷新时,渲染器将新数组与上一帧逐行比较,只清除并重写发生变化的连续行区间,而不重新输出整个界面。pi-tui 将这份数组保存为 previousLines,并把本帧的 newLines 作为比较对象。

终端界面需要在已显示的内容上持续更新,同时保留用户可回看的历史。单纯向 stdout 追加文本无法完成这项工作。流式回答、spinner、输入框编辑和菜单开关都会触发高频刷新;如果每次刷新都清屏并输出完整界面,输出量、闪烁和滚动位置都会失控。若只靠局部光标移动,又必须确认当前终端上的每一行仍与渲染器保存的状态一致。

pi-tui 的组件输出就是这类行数组:每一帧得到 string[],重写从第一个变化行到最后一个变化行的连续区间。算法本身很短,实现的大部分篇幅用来维护它成立的条件:终端宽高变化、历史滚入 scrollback、内容收缩、kitty 图片跨多行显示,以及输入法依赖的硬件光标位置。

行数组差分渲染和虚拟滚动的区别。虚拟滚动解决内容远超视口时只挂载、绘制可见区域附近的项,减少 DOM 节点或组件数量;pi-tui 每帧仍从组件树生成完整的 string[],逐行比较后只把变化区间写回终端,减少的是终端控制序列和输出字节。前者跳过不可见内容的渲染,后者跳过未变化行的输出。

行数组为何能成为渲染边界

packages/tui/src/tui.ts:23-47 定义的 Component 只有一个渲染方法:

export interface Component {
	render(width: number): string[];
	handleInput?(data: string): void;
	wantsKeyRelease?: boolean;
	invalidate(): void;
}

组件接收当前可用宽度,返回若干终端行。容器组件顺序拼接子组件的结果,根组件最终生成整个界面的 string[]。样式以 ANSI 序列直接保留在字符串中,折行、截断和东亚字符宽度处理由组件及 visibleWidth()truncateToWidth() 等工具完成。

这里有一条影响后续算法的约束:普通文本行的可见宽度不能超过终端宽度。TuiMainScreen 在写入前检查这一点,越界时记录全部行并抛错(packages/tui/src/tui-main-screen.ts:412-439)。终端自动折行会让逻辑行与物理屏幕行失去一一对应关系,之后按数组下标移动光标、计算视口和清除旧行都会出错。该检查将宽度错误定位到自定义组件实现处,避免它以偶发的终端错位形式出现。

这也划定了 pi-tui 的职责范围。它没有虚拟 DOM、flexbox 布局引擎或 cell 级缓冲区。以 Ink 为例,Ink 使用 React reconciler 和 Yoga 计算布局,再对屏幕单元格做差分;pi-tui 比较完整字符串行。前者能在一行中只更新一个字符,后者将这一行整体清除后重写。相应地,pi-tui 的组件接口和渲染状态都更小,但组件作者需要自行保证宽度约束。

一帧输出前,先建立可比较的屏幕模型

主屏幕实现的入口是 TuiMainScreen.doRender()packages/tui/src/tui-main-screen.ts:146)。它先将影响终端单元格位置的信息合成为稳定的行数组,再进行比较:

pi-tui 渲染管线

  1. 根组件按终端宽度渲染出 newLines:162-163)。
  2. 若存在 overlay,compositeOverlays() 在比较前将浮层覆盖到基底行上(:165-168;实现在 packages/tui/src/tui.ts:1059-1118)。菜单弹出、隐藏和移动因此只表现为最终行内容变化,不需要独立的浮层绘制路径。
  3. extractCursorPosition() 从行中取出 CURSOR_MARKER:170-171)。标记随后会被移除,避免它影响行比较和实际输出。
  4. applyLineResets() 为每行补充颜色和 OSC 8 超链接的重置序列(:173)。终端按顺序解释控制序列,若一行结束时不复位,后一次短内容覆盖旧内容时,旧行遗留区域可能继承前面的样式或链接状态。

经过这四步,previousLinesnewLines 都表示实际准备写入终端的行,不再是组件的中间结果。差分以这个边界为准,overlay、光标协议和样式状态不必分别参与增量逻辑。

差分只记录一个连续区间

比较循环从第 0 行遍历到两帧中较长数组的末尾(tui-main-screen.ts:260-281)。遇到不相等的行时记录 firstChanged,并持续更新 lastChanged。新数组更长时,渲染器把末尾新增部分作为追加区间处理。

设两帧行数组分别为 (P) 和 (N),比较范围为 (0 \ldots \max(|P|, |N|)-1)。若存在差异,渲染区间为:

[ [firstChanged, lastChanged] = [\min{i \mid P_i \ne N_i}, \max{i \mid P_i \ne N_i}] ]

下图将比较和写回过程展开。它省略了宽高变化、kitty 图片和视口越界等完整重绘条件,这些条件在下一节说明。

pi-tui 行数组差分算法

算法从两帧的较长长度开始扫描。下标超过任一数组长度时,代码将该侧视为 "",因此新增行和删除行与普通文本变化进入同一比较分支。第一次不相等时设置 firstChanged,之后继续扫描而不提前结束,直到遍历完成后由最后一个不相等的下标确定 lastChanged。最终得到一个连续区间,即使其中夹有未变化行也会一并重写。

例如,上一帧为 ["用户:解释 diff", "处理中 ⠋", ""],下一帧为 ["用户:解释 diff", "处理中 ⠙", "回答:逐行比较"]。第 1 行和第 2 行发生变化,渲染器得到 [1, 2],移动至第 1 行后依次清除并重写两行。若只改变 spinner,区间就是 [1, 1]。若新数组缩短,缺失的一侧按空字符串比较,渲染器清除旧数组中多出的终端行。

该过程的比较成本是 (O(\max(|P|, |N|)))。实现选择一个连续区间,不计算多段变化的最短写入集合。终端控制序列需要移动光标、清行和处理滚屏,多段细粒度写入会增加状态转换;对 coding agent 的常见负载,连续区间通常已经很短。流式文本追加、工具执行状态和输入编辑大多集中在界面底部。

有变化时,渲染器将光标移至目标行,对区间中的每一行输出 \x1b[2K 清除整行,再写入新内容(:354-442)。若新内容较短,还会清除旧数组多出的行并将光标移回新内容末尾(:447-461)。整个字节序列在内存中拼装后通过一次 terminal.write() 写出(:463-495),ProcessTerminal.write() 最终调用一次 process.stdout.write()packages/tui/src/terminal.ts:454-463)。

写入包在 CSI 2026 同步输出序列 \x1b[?2026h\x1b[?2026l 中。支持该序列的终端会在结束序列到达时再呈现整段更新,避免用户看到清行完成但新内容尚未写完的中间状态。不支持该控制序列的终端会按自身的未知控制序列处理方式继续输出,因此差分逻辑不依赖这一能力才能运行。

firstChanged 未被设置时,文本区不写任何内容。输入框中左右移动光标属于这条路径:光标标记已经在比较前剥离,两帧行文本相同,渲染器仅更新硬件光标位置(:289-295)。这使文本内容与输入光标的移动拥有不同的更新成本。

requestRender() 还合并高频状态更新。首次请求会安排渲染,后续请求复用同一个待执行任务;相邻帧以 16 ms 为最小间隔(packages/tui/src/tui.ts:745-786,常量在 :332)。流式响应中的多个 token 更新因此可以合并为一次屏幕提交。

增量更新的正确性条件

行数组比较只能说明两个字符串数组的差异,不能保证终端物理屏幕仍能被该区间正确覆盖。TuiMainScreen 为此保存了 previousWidthpreviousHeightpreviousViewportTopmaxLinesRendered 和实际硬件光标所在行。它们共同描述上一帧的终端状态。

以下情况会改走 fullRender(),并可通过 PI_DEBUG_REDRAW=1 记录原因(tui-main-screen.ts:175-257):

  • 首帧没有 previousLines,直接输出完整内容,不清除已有终端历史(:228-232)。
  • 宽度变化后,所有依赖宽度的折行结果可能改变,旧数组下标不再对应新行位置(:235-240)。
  • 高度变化会改变可见视口与内容行的对应关系,默认清屏并重绘(:242-249)。Termux 例外:软键盘开关也会改变高度,完整重绘会重复输出整段历史,因此 TERMUX_VERSION 存在时跳过该路径。
  • 启用 clearOnShrink 后,内容长度小于此前工作区高度时完整重绘,以清除空白区域(:251-258)。该选项默认由 PI_CLEAR_ON_SHRINK 控制,默认值为关闭,避免频繁收缩内容带来的额外刷新。
  • 第一个变化行位于上一帧视口顶部之前。该行已经进入 scrollback,增量写入无法再定位到它(:346-352)。
  • 删除行会使视口顶端上移,或待清除行数大于终端高度。逐行移动和清除在这些条件下可能触发滚屏,因而改用完整重绘(:297-343)。

这些分支用于恢复渲染器保存的状态与终端屏幕之间的对应关系。渲染器只有在能够确认目标行仍处于可控区域时才做增量写入;一旦宽度、视口或滚屏改变了该条件,就用完整输出重新建立 previousLines 与物理屏幕的对应关系。

主屏幕使历史成为终端职责

默认实现 TuiMainScreen 写入终端主屏幕。内容超过一屏时,顶部行会自然进入 scrollback。TuiAltScreen 同时存在,它使用 \x1b[?1049h 进入 alternate screen,提供应用管理的滚动视口、鼠标和选择能力(packages/tui/src/tui-alt-screen.ts:35-100)。coding agent 在 regular 模式使用主屏幕,--ui-mode fullscreen--alt 选择全屏模式(packages/coding-agent/src/cli/args.ts:180-195;创建位置在 modes/interactive/interactive-mode.ts:340-345)。

这项选择改变了差分渲染的边界。主屏幕模式中,已经滚出可见区的聊天记录交由终端保存,用户直接使用终端提供的滚动、搜索和复制能力。渲染器只维护当前工作区域及其邻近内容。代价是过去的行不能再被局部修改,因此才有 firstChanged < previousViewportTop 时的完整重绘分支。

退出主屏幕模式时,beforeTerminalStop() 会先将光标移到已渲染内容之后,再写入换行(tui-main-screen.ts:67-75)。shell 提示符由此落在 TUI 输出末尾,避免覆盖最后一行内容。

两类破坏“一行对应一行”的数据

行数组模型依赖逻辑行与终端物理行的对应关系。kitty 图片和硬件光标分别从两个方向打破了这个假设。

kitty 图片需要按占用行扩展差分区间

kitty 图像协议的图像数据写在带 \x1b_G 的起始行中,但图片可占用 r= 指定的多行。后续保留行可能是空字符串,因此字符串数组中的一个图像起始行不能简单视为一个物理行。

parseKittyImageHeader() 解析图像 id 与占行数(tui-main-screen.ts:14-40)。若变化区间碰到旧图或新图,expandChangedRangeForKittyImages() 将整个图片区块纳入区间(:109-130);重写前删除区间内旧图像(:132-144);输出时先清除所有保留行,回移到图像起始行写入数据,再移动回图片区块末尾(:401-410)。如果预清除图片区块会跨过当前视口底部,代码改走完整重绘(:391-399)。

处理重点在于维持差分边界的封闭性。图区块不能只重绘首行,也不能让占位行留在区间之外,否则终端中会同时残留旧图像和新文本。

光标位置通过渲染输出传递

输入法候选窗口依赖终端硬件光标位置,而编辑器的文本选择、光标和横向滚动都封装在组件内部。pi-tui 约定焦点组件在光标位置嵌入 CURSOR_MARKER,渲染器无需读取编辑器内部状态。这个标记是一段零宽 APC 序列:\x1b_pi:c\x07packages/tui/src/tui.ts:57-79)。

渲染器从标记前文本计算可见列宽,删除标记,再以 \x1b[{col}G 将硬件光标移动到绝对列(extractCursorPosition() 位于 tui.ts:1149-1167,定位逻辑位于 tui-main-screen.ts:520-551)。该协议只要求组件在输出中声明光标位置,渲染器不需要依赖具体编辑器类型。它也是上一节“文本未变只移动光标”能够成立的前提。

扩展 UI 复用同一条渲染路径

pi 的内置工具和扩展工具使用相同的 ToolDefinition。其中 renderCallrenderResult 都返回 Componentpackages/coding-agent/src/core/extensions/types.ts:449-498)。工具调用卡片、工具结果和扩展自定义展示因此都通过 render(width): string[] 进入同一套合成、差分与终端兼容逻辑。

编辑器接口同样继承 Component。自动补全提供候选数据,默认编辑器将候选放入 SelectList 组件。扩展若替换编辑器,只需遵守行宽约束,并在需要输入法定位时输出 CURSOR_MARKER。渲染器没有为内置组件保留专用通道,这使扩展 UI 的行为边界与内置 UI 保持一致。

结论

pi-tui 以行数组作为组件和终端之间的提交格式。相邻帧的逐行比较只解决“哪些文本不同”,TuiMainScreen 的其余状态和分支负责回答“这些不同能否安全地写回当前终端”。

这种模型适合以追加输出为主的 coding agent 界面:变化通常集中在底部,历史记录交由 scrollback 保存,单行整体重写的代价可以接受。它不适合要求任意位置高频细粒度更新的终端应用,单个字符变化仍会触发整行写入,历史区域中的回写也会退化为完整重绘。源码将这些限制直接编码为宽度检查、视口边界和完整重绘分支,差分渲染的复杂度集中在可验证的终端状态条件上。

下一篇讨论 pi-ai 如何处理 37 家 provider 的协议、错误格式与兼容性差异。


485 字 · 67 段落
xi ming

Written by xi mingFollow onGitHub