Skip to content

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)

每个 LayoutBoxclip 是它和父级裁剪区的交集(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

  1. 体积与启动:Ink 要打包整个 React 运行时,对 CLI 太重;自研只做"组件渲染 + 差分"。
  2. 终端特殊性:行宽、CJK 宽度、ANSI 定位、滚动、Kitty 图片等,通用 UI 框架不直接支持。
  3. 性能:差分渲染精确到"只重写变化行",对模型逐字流式输出特别重要。
  4. 无虚拟 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_MARKERpackages/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