Appearance
5.1 TUI 与差分渲染
@pi/tui 是一个完全自研的终端 UI 库(不用 React/Ink),只依赖 marked(Markdown)和 get-east-asian-width(CJK 宽度)两个小工具。它把"终端全屏"抽象成"组件树 + 布局 + 差分绘制"。这一节深入拆解它的核心逻辑。
整体架构:三层流水线
① 组件层(Component) render(width) → 若干行文本(含 ANSI 颜色)
│
▼
② 布局层(Layout) 计算每个组件在屏幕上的矩形(位置/尺寸/裁剪)
│
▼
③ 渲染层(Screen) 合成整屏 → 差分:只重写变化的行① 组件层:Component
Component 接口(packages/tui/src/tui.ts:23):
ts
export interface Component {
render(width: number): string[]; // 纯函数:给宽度,返回若干行文本
handleInput?(data: string): void; // 可选:有焦点时处理键盘输入
wantsKeyRelease?: boolean; // 是否需要 key release 事件(Kitty 协议)
invalidate(): void; // 失效缓存,强制从头重渲染
}关键:组件是"纯"的。给定 width,输出若干行带 ANSI 颜色的文本。它不直接往终端写,只是把"我想显示成什么"汇报给上层。这种纯粹性让它能被缓存、被差分。
光标标记(CURSOR_MARKER)
TUI 用零宽转义序列在渲染输出里标记硬件光标位置(供 IME 候选窗口定位):
ts
export const CURSOR_MARKER = "\x1b_pi:c\x07"; // APC 序列,终端会忽略有焦点的组件在光标位置输出这个标记,TUI 渲染后找到并剥掉它,再把硬件光标移到那里(isFocusable + Focusable.focused)。
② 布局层:Layout
packages/tui/src/layout.ts 把组件树排布到屏幕矩形。核心数据结构 LayoutBox:
ts
interface LayoutBox {
component: Component; // 谁
rect: LayoutRect; // 在哪(x/y/width/height)
clip: LayoutRect; // 裁剪区(与父级求交)
children: LayoutBox[]; // 子树
parent?: LayoutBox;
layer: number; // 层级
lines?: string[]; // 已渲染的行(缓存)
scrollView?: ScrollView;
}递归布局:layoutComponent
layoutComponent()(layout.ts:100)递归地给每个组件算矩形。它支持几种布局节点:
scroll:滚动视图。子组件按scrollTop平移,计算内容高度与视口高度,生成滚动条。vstack:垂直堆叠。按basis(内禀高度)分配每个子项的高度 +gap间距(allocateStackSizes)。hstack:水平排列。按内禀宽度分配宽度,支持align(stretch/center/end)。
裁剪(clip)
每个 LayoutBox 的 clip 是它和父级裁剪区的交集(updateClips + intersect)。组件内容超出自身区域的部分会被裁掉,这是滚动、重叠组件正确显示的基础。
渲染缓存(renderCached)
ts
function renderCached(context, component, width): string[] {
const widths = context.renderCache.get(component) ?? new Map();
let lines = widths.get(width);
if (!lines) {
lines = component.render(width); // 只有宽度变了才真正调 render
widths.set(width, lines);
}
return lines;
}按"组件 + 宽度"缓存:宽度没变就不重渲染组件,省去无谓的 render() 调用。
③ 渲染层:差分绘制
这是 TUI 最亮眼的部分(tui-main-screen.ts)。doRender()(:180)是核心。
完整渲染管线
doRender():
1. width/height = 获取终端列数/行数
2. newLines = this.render(width) // 渲染所有组件 → 新帧行数组
3. 若有过浮层 → compositeOverlays(newLines) // 合成浮层
4. cursorPos = 提取 CURSOR_MARKER 位置
5. newLines = applyLineResets(newLines)
6. 决定:全量重绘 or 差分重绘什么时候"全量重绘"(fullRender)
| 触发条件 | 原因 |
|---|---|
首次渲染(previousLines 为空) | 屏幕是干净的,直接全输出 |
| 终端宽度变化 | 宽度变了,换行位置全变,必须重排 |
| 终端高度变化(非 Termux) | 视口要对齐,需重绘 |
内容收缩到工作区以下(clearOnShrink) | 要清掉多余空行 |
全量重绘会用 \x1b[?2026h / \x1b[?2026l(同步输出) 包裹,避免终端逐行刷新产生闪烁。
什么时候"差分重绘"
ts
// 扫描上一帧与当前帧,找出第一个和最后一个变化的行
let firstChanged = -1, lastChanged = -1;
for (let i = 0; i < maxLines; i++) {
if (oldLine[i] !== newLine[i]) {
if (firstChanged === -1) firstChanged = i;
lastChanged = i;
}
}只重写 [firstChanged, lastChanged] 区间内的行,其余行完全跳过。这就是流式输出高效的原因:模型吐一个字,只有那一行变化,就只重写那一行。
特殊处理:Kitty 图片
TUI 支持终端图片(Kitty 协议)。expandChangedRangeForKittyImages 会把变化区间扩张到覆盖整张图片占的多行,并 deleteKittyImages 清理旧图,避免图片撕裂。
渲染循环:requestRender
组件数据变化时调用 requestRender()(tui.ts:770),TUI 会安排下一帧渲染(合并多次请求为一帧)。这就是"有状态 UI + 无状态内核"的接缝:Agent 发事件 → 组件改状态 → requestRender → 下一帧差分绘制。
从 Agent 事件到屏幕
回顾 2.3 生命周期事件,TUI 是这些事件的消费者:
Agent 事件(message_update / tool_execution_start / …)
│
▼
状态更新(组件持有的数据变化)
│
▼
requestRender()
│
▼
下一帧:布局 → 渲染变化的组件 → 差分写差异行例如 message_update 更新"正在打字的助手消息"组件 → render() 返回新行 → 差分检测到变化行 → 只重写那一行。
为什么自研而不是用 React/Ink
- 体积与启动:Ink 要打包整个 React 运行时,对 CLI 太重;自研只做"组件渲染 + 差分"。
- 终端特殊性:行宽、CJK 宽度、ANSI 定位、滚动、Kitty 图片等,通用 UI 框架不直接支持。
- 性能:差分渲染精确到"只重写变化行",对模型逐字流式输出特别重要。
- 无虚拟 DOM:终端里不需要浏览器那套虚拟 DOM,用"行数组 + 差分"更直接。
关键组件
@pi/coding-agent 的交互界面由大量 @pi/tui 组件拼装而成(packages/coding-agent/src/modes/interactive/components/):
- 消息渲染(用户/助手/工具结果)
- 输入框(编辑器 + 自动补全 + 撤销栈)
- 工具执行指示器(bash 运行中…)
- 底部状态栏(git 分支、模型、扩展状态)
- 选择器/对话框(模型选择、主题切换)
与 Agent 的分工
- Agent 内核:无状态,只发事件,不渲染。
- TUI:有状态,消费事件,只管渲染。
这条"无状态内核 + 有状态 UI"的边界是 pi 分层架构的又一体现 —— 换一个 UI(比如远程客户端、JSON 输出),内核一行都不用改。
小结
- TUI 是三层流水线:组件(纯渲染)→ 布局(矩形/裁剪)→ 差分绘制。
Component.render(width)是纯函数,可按宽度缓存。- 布局支持
scroll/vstack/hstack,靠clip裁剪。 - 差分渲染只重写"变化区间",流式输出高效;全量重绘只在首帧/尺寸变化时触发。
- 完全自研,不用 React/Ink。
真实源码位置
Component/CURSOR_MARKER:packages/tui/src/tui.ts:23,79- 布局:
packages/tui/src/layout.ts:100 - 渲染缓存:
packages/tui/src/layout.ts:62 - 差分渲染:
packages/tui/src/tui-main-screen.ts:180
下一步看 Demo:最小 TUI。