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 比较完整字符串行。Ink 可以在一行中只更新一个字符;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 与物理屏幕的对应关系。

主屏幕模式使用终端的 scrollback 保存历史

默认实现 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)。

主屏幕模式中,已经滚出可见区的聊天记录由终端保存,用户可使用终端提供的滚动、搜索和复制能力。渲染器只维护当前工作区域及其邻近内容。已经进入 scrollback 的行不能局部修改,因此当 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 的协议、错误格式与兼容性差异。


479 字 · 67 段落
ximing

Follow onGitHub

相关文章