Skip to content

6.9 pi-tui:终端界面库

本页分析版本earendil-works/pi@c13ffe12026-07-30

本章解决什么问题:Pi 的交互界面不是 React、不是 ink,也没有虚拟 DOM。它是一套只有一个抽象的自研终端界面(TUI,Terminal User Interface)库。本章讲清这个抽象是什么、为什么它必须做差分渲染、两个渲染器有什么区别,以及 AgentSession 的流式事件最终是怎么变成屏幕上那几个字节的。 前置知识2.7 事件、回调与取消5.4 流式事件如何传播到界面6.3 pi-agent-core:Agent 与循环学习目标:① 用一句话说出 pi-tui 的组件模型,并解释它为什么不需要虚拟 DOM;② 说清「全量重绘为什么闪烁」以及 TuiMainScreen 的差分算法与五个退化条件;③ 区分 TuiMainScreenTuiAltScreen 的职责边界;④ 完整复述「Agent 事件 → 组件 → 终端字节」这条链;⑤ 亲手跑通一个只读脚本,看见差分渲染真的只写了变化的那一行。

建立直觉:组件就是一个函数

在浏览器里写界面,你操作的是 DOM 节点树,框架负责把「你想要的状态」翻译成「对树的最小改动」。终端没有 DOM——终端只接受一串字节,其中有些是要显示的字符,有些是控制光标和颜色的转义序列(escape sequence)。所以终端界面库要回答的第一个问题是:界面在内存里长什么样?

pi-tui 的答案极简:界面就是一个字符串数组,每个元素是屏幕上的一行。

📘 概念pi-tui 的组件(Component)
一个组件是实现了 render(width: number): string[] 的对象:给它一个可用宽度,它返回若干行文本(可以带 ANSI 转义序列)。除此之外只有三个可选/必需成员:handleInput?(data) 在获得焦点时收键盘输入、wantsKeyRelease? 决定是否接收按键释放事件、invalidate() 清空自己的渲染缓存。整个框架只有这一个抽象——没有虚拟 DOM、没有布局引擎、没有组件生命周期。
earendil-works/pi@c13ffe1第 23–47 行在 GitHub 查看 ↗
pi-tui 的全部核心抽象。`render` 返回「每行一个字符串」的数组,这个返回值就是 diff 的单位。

组合靠 Container:它持有 children: Component[]render 时把每个子组件的行数组按顺序纵向拼接。这就是「组件树 → 行数组」的全部逻辑,10 行(源码事实):

ts
// packages/tui/src/tui.ts:235-244
render(width: number): string[] {
	const lines: string[] = [];
	for (const child of this.children) {
		const childLines = child.render(width);
		for (const line of childLines) {
			lines.push(line);
		}
	}
	return lines;
}
packages/tui/src/tui.ts · render(width: number): string[]
earendil-works/pi@c13ffe1第 235–244 行在 GitHub 查看 ↗
纵向拼接子组件的行。没有 flex、没有 grid——「布局」在 pi-tui 里等于「数组拼接顺序」。
⚠️ 常见误解以为 pi-tui 里有虚拟 DOM 或 diff 树
React 的 diff 发生在组件树上:框架比较两棵虚拟节点树,算出要改哪些 DOM 节点。pi-tui 完全不做这件事——组件树每帧都被完整地重新 render 一遍,diff 只发生在最终的行字符串数组上(`TuiMainScreen.ts:260-287`)。理解这一点,后面「为什么组件层敢写得那么暴力」才讲得通。

Component 四个成员里最不直观的是 invalidate()。它存在的原因是:框架不缓存,但组件可以缓存。以最基础的 Text 为例,它按 (text, width) 缓存渲染结果,命中就直接返回旧数组(packages/tui/src/components/text.ts:45-49),而 invalidate() 就是把 cachedText / cachedWidth / cachedLines 三个字段清空(text.ts:39-43)。主题切换时 TuiBase.invalidate() 递归传播给整棵树和所有 overlay(tui.ts:656-659),缓存统一失效——这是 invalidate() 唯一的用途。

为什么必须做差分渲染

如果每帧都「清屏 + 重画全部内容」,会有三个后果:

  1. 闪烁。清屏与重画之间存在时间差,终端可能在这个空档刷新一次,用户看到一次白/黑闪。
  2. 滚动历史被冲掉\x1b[3J 会连同 scrollback 一起清掉,你上翻就找不到之前的对话了。
  3. 带宽浪费。一个 80×24 的屏幕全量重绘要写几 KB;spinner 每 80ms 转一帧,按每帧几 KB 估算就是每秒几十 KB 的无用输出——通过 SSH 时尤其明显。

pi-tui 的做法是:每帧照旧渲染整棵组件树得到新的行数组,但只把「与上一帧不同的那些行」写给终端。官方文档说明这一点:packages/tui/README.md:8 把「Differential Rendering: Updates only changed lines or viewport rows」列为第一特性,README.md:10 说明用 CSI 2026 做原子更新以避免闪烁。

一帧的生命周期

TuiMainScreen.doRender() 的前半段是准备阶段,顺序不能乱(源码事实,TuiMainScreen.ts:146-173):

  1. newLines = this.render(width)——渲染整棵组件树(:163);
  2. 有 overlay 时 compositeOverlays() 把浮层的行合成进去(:166-168,实现见 tui.ts:1044);
  3. extractCursorPosition() 找出并剥离光标标记(:171,实现见 tui.ts:1134);
  4. applyLineResets() 给每行末尾补上 SGR 重置与 OSC 8 超链接重置(:173,实现见 tui.ts:1105)。

第 3 步的光标标记是 CURSOR_MARKER = "\x1b_pi:c\x07"tui.ts:79),一个零宽的 APC 序列。有焦点的组件把它嵌在自己 render 输出的光标处,TUI 找到后剥离并据此定位硬件光标——目的是让输入法(IME)的候选窗跟着光标走。官方文档在 packages/coding-agent/docs/tui.md:31-57 对扩展作者解释了这套机制。

第 4 步的必要性很实际:终端的颜色是「状态」,不是「属性」。如果某行以「红色未关闭」结尾,下一行会继承红色。SEGMENT_RESETtui.ts:250)在每行尾部补一个 \x1b[0m\x1b]8;;\x07,把样式和超链接都关掉,保证行与行之间互不污染。官方文档也写明了这条约定(packages/coding-agent/docs/tui.md:29)。

差分的核心:逐行字符串比较

准备完成后,差分本身朴素得让人意外——两个数组逐位置比较字符串,记下第一个和最后一个不同的下标:

ts
// packages/tui/src/TuiMainScreen.ts:260-274(节选)
let firstChanged = -1;
let lastChanged = -1;
const maxLines = Math.max(newLines.length, this.previousLines.length);
for (let i = 0; i < maxLines; i++) {
	const oldLine = i < this.previousLines.length ? this.previousLines[i] : "";
	const newLine = i < newLines.length ? newLines[i] : "";
	if (oldLine !== newLine) {
		if (firstChanged === -1) firstChanged = i;
		lastChanged = i;
	}
}
// …(省略:新内容更长时把 lastChanged 顶到末行;Kitty 图片块需整块重画时扩展区间)
earendil-works/pi@c13ffe1第 260–287 行在 GitHub 查看 ↗
差分核心:逐行字符串比较得出变更区间 `firstChanged..lastChanged`;随后为「追加行」和「Kitty 内联图片块」各做一次区间修正。

比较结果有三种走向:

  • 完全没变firstChanged === -1):一个字节都不写,只更新硬件光标位置后返回(TuiMainScreen.ts:290-295)。
  • 变更全在被删掉的行里:只发清行序列,不重写内容(:298-344)。
  • 常规情况:把光标相对移动firstChanged 所在行,然后只重写 firstChanged..lastChanged 这段区间。

第三种情况的关键三行,源码注释直接写出了动机:

ts
// packages/tui/src/TuiMainScreen.ts:383-386
// Only render changed lines (firstChanged to lastChanged), not all lines to end
// This reduces flicker when only a single line changes (e.g., spinner animation)
const renderEnd = Math.min(lastChanged, newLines.length - 1);
for (let i = firstChanged; i <= renderEnd; i++) {

注意它写的是「只重写变更区间」,而不是「从第一处变化一直重写到末尾」——注释点名了受益场景:spinner 动画。

整个输出被包在 "\x1b[?2026h":356)与 "\x1b[?2026l":463)之间,即 CSI 2026 同步输出:终端在这对标记之间不刷新屏幕,一帧的所有改动原子生效。移动光标用的是相对序列 ESC[nB / ESC[nA:374-379),每行先 \x1b[2K 清行再写(:412),最后 this.terminal.write(buffer) 一次性把整个缓冲交给终端(:495)。

什么时候退化为全量重绘

差分不是万能的。TuiMainScreen 在五种情况下调用 fullRender()(源码事实):首帧(:229-233)、终端宽度变化(换行结果会变,:236-240)、终端高度变化(Termux 例外,因为软键盘开合会改高度,:245-249)、内容收缩且开启了 clearOnShrink:254-258)、变更行位于上一帧视口之上(差分够不着,:348-352)。设置环境变量 PI_DEBUG_REDRAW=1 后,每次全量重绘都会把原因写进 pi-debug.log:219-226)。

还有一条对写组件的人很重要的规矩:不超宽是组件自己的责任。若某行的可见宽度超过终端宽度,TuiMainScreen 会写 pi-crash.log、调 this.stop() 恢复终端状态,然后抛错,错误文案直接建议用 visibleWidth() 测量、truncateToWidth() 截断(:413-440)。官方文档同样写明「Each line must not exceed width」(packages/coding-agent/docs/tui.md:26 的表格行)。框架不做自动截断——因为带 ANSI 序列的字符串截断代价不小,而且截在哪里只有组件自己知道。

图加载中…

图 6.9-1 一帧的生命周期:从组件树到终端字节
从上到下是一次 `doRender()` 的完整流水线。请重点看中间那个菱形判断:它有三个出口,而「不写任何字节」这条出口是 spinner 停转、内容未变时省下所有 I/O 的原因。左侧节点对应 `tui.ts` 的 `Container.render`(:235)、`compositeOverlays`(:1044)、`extractCursorPosition`(:1134)、`applyLineResets`(:1105);右下三个节点全部在 `TuiMainScreen.ts` 的 `doRender` 里(:260、:374、:383、:495)。

谁来触发重绘

pi-tui 没有全局的每秒 N 帧定时器。重绘由 requestRender() 驱动,它做两件事:合并同一 tick 内的多次请求,以及限制帧率。

ts
// packages/tui/src/tui.ts:748-751(节选)
if (this.renderRequested) return;
this.renderRequested = true;
process.nextTick(() => this.scheduleRender());

scheduleRender()MIN_RENDER_INTERVAL_MS - 距上一帧耗时setTimeouttui.ts:757-758),其中 MIN_RENDER_INTERVAL_MS = 16tui.ts:321),即约 60 帧/秒的上限;渲染完成后若期间又有新请求,再排下一帧(tui.ts:767-769)。

packages/tui/src/tui.ts · requestRender
earendil-works/pi@c13ffe1第 730–771 行在 GitHub 查看 ↗
渲染调度:`force` 走「重置状态 + nextTick 立即画」,普通请求走「同 tick 合并 + 16ms 节流」。流式 token 可能毫秒级到达,节流是必需的。

那么在调 requestRender()?三类来源:终端输入(焦点组件处理完按键后由 TuiBase 统一调用,tui.ts:845)、终端 resize(tui.ts:666)、以及应用逻辑。动画属于第三类且很典型:Loader 组件用 setInterval 每 80ms 换一帧 braille 字符,换完自己调 this.ui.requestRender()packages/tui/src/components/loader.ts:83-91,默认帧集与间隔见 loader.ts:11-12)。没有全局 tick,动画组件自己推

两个渲染器:主屏与备用屏

本书锁定的 commit 提交主题正是 feat(tui): add alternate-screen renderer(见 4.2 仓库、版本与历史)。这次改动把原来只有一个渲染器的结构,拆成了「一个 TUI 接口 + 两个实现」:

  • TuiMainScreen——渲染进终端的主屏幕缓冲区,保留 scrollback。这是默认行为,也是「退出 pi 之后聊天记录还留在终端里、可以上翻」的原因。
  • TuiAltScreen——写入 \x1b[?1049h 进入备用屏幕缓冲区TuiAltScreen.ts:22),像 vim / less 那样占满整屏,自己维护滚动视口(scrollTop / stickToBottomTuiAltScreen.ts:59-61),并接管鼠标滚轮、拖选与 OSC 8 链接点击。

两者共享 TuiBasetui.ts:311)提供的焦点、overlay、输入分发、渲染节流,各自只实现 doRender()。差分策略因此不同:主屏版比较的是文档行(可能远多于屏幕高度),alt 屏版先按 scrollTop 截出视口再比较屏幕行,对变化的行发 \x1b[row;1H\x1b[2K 定位重写(TuiAltScreen.ts:413-416)。

alt 屏最贴心的一处在退出时:它不是简单地切回主屏了事,而是把完整文档回放到主屏上:

ts
// packages/tui/src/TuiAltScreen.ts:121-135(节选)
protected override afterTerminalStop(): void {
	if (!this.altScreenActive) return;
	this.altScreenActive = false;
	let buffer = `${BEGIN_SYNCHRONIZED_OUTPUT}${EXIT_ALT_SCREEN}${DISABLE_AUTOWRAP}`;
	for (let row = 0; row < this.lastDocument.length; row++) {
		if (row > 0) buffer += "\r\n";
		buffer += `\r\x1b[2K${this.lastDocument[row] ?? ""}`;
	}
	// …(省略:恢复自动换行、显示光标、结束同步输出、还原 iTerm2 图片能力)
	this.terminal.write(buffer);
}
earendil-works/pi@c13ffe1第 121–135 行在 GitHub 查看 ↗
退出备用屏后,把整份文档重新打印到主屏。官方文档 `packages/tui/README.md:62` 的说法与此一致:停止时「restores the main buffer and prints the complete final document」。

选择哪一个由一个七行的工厂函数决定,它的注释自称是「选择交互式终端渲染器的组合根」:

ts
// packages/coding-agent/src/modes/interactive/interactive-mode.ts:337-343
export function createInteractiveTui(options: InteractiveTuiOptions): TUI {
	const terminal = options.terminal ?? new ProcessTerminal();
	if (options.alt) {
		return new TuiAltScreen(terminal, options.showHardwareCursor, options.logDirectory, { openUrl: openBrowser });
	}
	return new TuiMainScreen(terminal, options.showHardwareCursor, options.logDirectory);
}
earendil-works/pi@c13ffe1第 337–343 行在 GitHub 查看 ↗
唯一决定使用哪个渲染器的地方,由 `--alt` 命令行开关经 `options.alt` 传入(构造点在 `interactive-mode.ts:481-485`)。注意 `options.terminal` 可注入——测试正是靠它替换成虚拟终端。

对应的测试只断言一个转义序列就区分了两种渲染器:alt: false 时写入序列中不含 \x1b[?1049halt: truepackages/coding-agent/test/interactive-tui.test.ts:16-41)。这是「怎么测终端 UI」的一个好范例——不去截图比对,而是断言协议字节。

从终端字节到组件:输入路径概览

界面另一半是输入。TuiBase.start() 是「终端字节流」与「组件树」之间唯一的接线点(源码事实,tui.ts:661-675):

ts
// packages/tui/src/tui.ts:664-667
this.terminal.start(
	(data) => this.handleTerminalInput(data),
	() => this.requestRender(),
);

Terminal 是接口(packages/tui/src/terminal.ts:52-94),生产实现 ProcessTerminalstart() 里把 stdin 切到 raw 模式(terminal.ts:140-142)、开启 bracketed paste(:147)、协商 Kitty 键盘协议(:166)。原始字节先经 StdinBufferpackages/tui/src/stdin-buffer.ts:274)切分成一次一个按键序列,再转发给 handler(terminal.ts:309-318)——不切分的话,一次 read 里可能挤着好几个按键,matchesKey() 就没法工作了。

进入 handleTerminalInput()tui.ts:773-847)后依次是:终端查询应答、全局输入监听器、调试键、overlay 焦点校正,最后交给焦点组件并统一 requestRender()。交互模式把焦点设在编辑器上(interactive-mode.ts:746)。

编辑器这一层分两级。Editorpackages/tui/src/components/editor.ts:270)是 pi-tui 通用编辑器:多行文本、撤销栈、自动补全、粘贴处理。它对超长粘贴有个巧思——超过 10 行或 1000 字符的粘贴不进正文,而是存进 this.pastes 并插入 [paste #1 +123 lines] 这样的标记(editor.ts:1197-1212),提交时再由 expandPasteMarkers() 还原(editor.ts:985-992);这样一次粘贴几千行也不会把编辑器的渲染撑爆。匹配到 tui.input.submit(默认 Enter,packages/tui/src/keybindings.ts:124)时调 submitValue():先清空自己的全部状态,最后才调 this.onSubmit(result)editor.ts:1260-1274)——顺序很重要,否则回调里如果又往编辑器写东西就会被随后的清空吞掉。

CustomEditorpackages/coding-agent/src/modes/interactive/components/custom-editor.ts:7-80)是 coding-agent 的子类,覆写 handleInput 并在交给父类之前按序拦截:扩展注册的快捷键 → 粘贴图片 → app.interrupt(默认 Escape,packages/coding-agent/src/core/keybindings.ts:66)→ app.exit(默认 Ctrl+D,且只在编辑器为空时才退出)→ 其余 app 级动作 → super.handleInput(data)。它的 onEscape / onCtrlD / onPasteImage / onExtensionShortcut 都是可动态替换的公开字段custom-editor.ts:12-16),这是 ESC 语义随上下文切换的基础。

ESC 是一条很好的跨层贯穿案例。完整调用链(每环都给出 path:line):

  1. 起点:ESC 字节到达 CustomEditor.handleInput,命中 app.interrupt,调用 this.onEscape——custom-editor.ts:45-53
  2. onEscapesetupKeyHandlers() 安装,四级分派中的第一级是「正在流式输出」——interactive-mode.ts:2596-2598
  3. restoreQueuedMessagesToEditor({ abort: true }):先把排队消息倒回编辑器,再中止——interactive-mode.ts:4021-4040
  4. this.agent.abort()——interactive-mode.ts:4027(队列为空时)或 :4037(非空时);
  5. 终点Agent.abort() 触发本次 run 的 AbortController——packages/agent/src/agent.ts:311-314

顺带解决一个常见困惑:raw 模式下 Ctrl+C 不产生 SIGINT,它只是一个普通字节 \x03tui.ts:837-838 的注释明说输入(含 Ctrl+C)一律交给焦点组件处理;官方文档 packages/tui/README.md:43 也在快速上手示例里提醒要自己拦截它才能退出。Pi 把它绑成 app.clear:单击清空编辑器,500ms 内连按两次才退出(interactive-mode.ts:3565-3572)。

interactive 模式:事件如何变成界面

到这里两半拼上了。交互模式的界面由九个顶层分区组成——八个 Container 加一个 FooterComponent,装配顺序就是屏幕自上而下的顺序(源码事实,下面这段是整个界面布局的唯一来源):

ts
// packages/coding-agent/src/modes/interactive/interactive-mode.ts:735-746
this.ui.addChild(this.headerContainer);
this.ui.addChild(this.loadedResourcesContainer);
this.ui.addChild(this.chatContainer);
this.ui.addChild(this.pendingMessagesContainer);
this.ui.addChild(this.statusContainer);
this.renderWidgets(); // Initialize with default spacer
this.ui.addChild(this.widgetContainerAbove);
this.ui.addChild(this.editorContainer);
this.ui.addChild(this.widgetContainerBelow);
this.ui.addChild(this.footer);
this.ui.setFocus(this.editor);
earendil-works/pi@c13ffe1第 735–746 行在 GitHub 查看 ↗
组件树装配。构造函数只 new 对象(`:487-507`),装配统一放在 `init()`;随后 `setupKeyHandlers()`、`setupEditorSubmitHandler()`、`this.ui.start()`(`:748-752`)。

事件处理的本质,就是对这九个分区增删改子组件,然后 requestRender() 订阅只有一处:

ts
// packages/coding-agent/src/modes/interactive/interactive-mode.ts:2875-2879
private subscribeToAgent(): void {
	this.unsubscribe = this.session.subscribe(async (event) => {
		await this.handleEvent(event);
	});
}

AgentSession.subscribe 把 listener 推进 _eventListenerspackages/coding-agent/src/core/agent-session.ts:800-810),事件源头是构造时对 Agent 的订阅(agent-session.ts:393),经 _handleAgentEvent:595-622,先派给扩展再广播)到达 _emit:548-552)。事件的联合类型 AgentSessionEvent 定义在 agent-session.ts:139-181——它是 UI 的全部数据源

handleEventinteractive-mode.ts:2881)是一个大 switch,每个事件先无条件 this.footer.invalidate():2886),各分支几乎都以 this.ui.requestRender() 收尾。三个关键分支:

  • message_start 且角色是 assistant:new 一个 AssistantMessageComponent 存进 this.streamingComponent,挂到 chatContainer,立刻 updateContent:2945-2957)。
  • message_update:把新消息交给同一个组件,并扫描 content 里的 toolCall,没见过的 id 就 new 一个 ToolExecutionComponent 挂进聊天区并登记到 pendingTools,见过的调 updateArgs:2960-2993)。
  • tool_execution_start / update / end:按 toolCallIdpendingTools 取出组件,分别标记开始、更新部分结果、写入最终结果后删除(:3039-3080)。
earendil-works/pi@c13ffe1第 2960–2993 行在 GitHub 查看 ↗
`message_update` 分支:文本增量交给 `streamingComponent`,工具调用按 id 去重后挂成独立组件。这是「流式输出如何长在屏幕上」的实际入口。

现在看那个最反直觉的实现细节。AssistantMessageComponent 看起来应该「随文本增量往后追加」,但它的 updateContent 每次都先清空再全量重建

ts
// packages/coding-agent/src/modes/interactive/components/assistant-message.ts:83-95(节选)
updateContent(message: AssistantMessage): void {
	this.lastMessage = message;
	// Clear content container
	this.contentContainer.clear();
	const hasVisibleContent = message.content.some(
		(c) => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()),
	);
	if (hasVisibleContent) {
		this.contentContainer.addChild(new Spacer(1));
	}
	// …(省略:按 content 顺序重建 Markdown / 折叠 thinking / 追加 stopReason 提示)
}
earendil-works/pi@c13ffe1第 83–95 行在 GitHub 查看 ↗
「增量增长」的真实实现是全量重建:每个 token 事件都把内容容器清空,再按 `message.content` 顺序重造 `Markdown` / `Text` 子组件。

组件层完全不做增量,因为行级 diff 已经兜住了。 全量重建产生的行数组里,绝大多数行与上一帧逐字符相同,TuiMainScreen 的比较会发现只有末尾一两行变了,于是只写这一两行。再加上 16ms 节流把毫秒级到达的 token 事件合并成最多 60 帧/秒,这套「暴力组件 + 精细渲染」的分工才成立。这是本章最值得记住的因果关系:正因为差分下沉到了框架的最底层、以行字符串为单位,上层组件才敢写得这么简单。

代价也要说清楚。一种看法是:这套分工让组件代码极其好写、几乎不可能写出「界面与状态不一致」的 bug;代价是单帧渲染成本随消息长度线性增长——一条很长的回复,每来一个 token 就要重新解析一次完整 Markdown。仓库内未见针对这条路径的性能测试或基准,所以「实际会不会成为瓶颈」属于推断(尚未在源码中直接证实)。

最后两个小细节。spinner 属于 WorkingStatusIndicator → StatusIndicator → Loader → Text 这条继承链(packages/coding-agent/src/modes/interactive/components/status-indicator.ts:9-40),前面说过它自己 setInterval 推重绘。而 spinner 消失时会留下 IdleStatus 占位——它的 render 返回两行全是空格的行(status-indicator.ts:105-114),目的是让总行数不变,从而不触发 clearOnShrink 的全屏重绘(挂载逻辑见 interactive-mode.ts:1890-1901,对应测试 packages/coding-agent/test/interactive-tui.test.ts:58-79)。

图加载中…

图 6.9-2 一个 message_update 事件走到屏幕上
从上到下是时间顺序。请重点看两处「收窄」:`AssistantMessageComponent` 把整条消息重建成组件(信息量最大的一步),而 `TuiMainScreen` 在最后一步把它收窄成「只有变化行」的几十个字节。对应源码:`agent-session.ts:595`、`:548`、`interactive-mode.ts:2876`、`:2886`、`:2963`、`assistant-message.ts:87`、`tui.ts:730`、`TuiMainScreen.ts:260`、`:495`。

底部状态栏是 FooterComponent。下面是真实采集的画面片段(来自 research/cli-captures/pi-tui-main.txt,真实采集;因当时未登录任何 provider,模型显示为 unknown):

text
/Volumes/macport/pibook/pipibook/_sources/pi (main)
0.0%/0 (auto)                                                                                unknown

第一行是工作目录 + git 分支(有会话名时还会追加 • 会话名),第二行左侧是 token 统计与上下文占用、右侧右对齐模型 id。类注释把分工写得很清楚:「Computes token/context stats from session, gets git branch and extension statuses from provider」(packages/coding-agent/src/modes/interactive/components/footer.ts:46-49)。

earendil-works/pi@c13ffe1第 84–104 行在 GitHub 查看 ↗
每帧遍历 `sessionManager.getEntries()` 现算累计 usage。注意 `invalidate()`(`:72-74`)是**空实现**——footer 不缓存任何东西。

两个数据源的性格完全不同:

  • 来自 session 的部分(token 累计、费用、上下文占用百分比、模型 id、thinking 级别)每帧现算:遍历全部会话条目累加 usage(footer.ts:91-104),上下文占用超 70% 变黄、超 90% 变红(footer.ts:154-160),空间不够时逐级降级截断(footer.ts:174-177 起)。
  • 来自 provider 的部分(git 分支、扩展状态、可用 provider 数)走缓存 + 文件监视:FooterDataProvider 直接读 .git/HEAD 的文本、取 ref: refs/heads/ 之后的内容作为分支名,不 spawn git 进程packages/coding-agent/src/core/footer-data-provider.ts:239-251)。

后者藏着一个很好的工程细节。watcher 监视的是 HEAD 所在的目录,不是 HEAD 文件本身,源码注释解释了原因:git 用「写临时文件 + rename 覆盖」的原子写,HEAD 的 inode 会变,fs.watch 盯着文件会失效(footer-data-provider.ts:313-315)。变更经防抖后异步刷新,刷新完通过 onBranchChange 回调触发一次 requestRender()(订阅点在 interactive-mode.ts:833-835)。暴露给 footer 与扩展的是只读视图 ReadonlyFooterDataProviderfooter-data-provider.ts:385-388)。

为什么要自研

这是本章唯一一个没有官方答案的问题。我们检索过仓库根 README.mdpackages/tui/README.mdpackages/coding-agent/docs/ 下的文档与 AGENTS.md,未发现任何文字解释「为什么不用 ink / blessed 等现成库」。

据此推断(尚未在源码中直接证实),从代码特征可以反推出三条动机:

  1. 主屏渲染 + 保留 scrollback。这是 TuiMainScreen 的核心卖点,也直接决定了「退出 pi 后聊天记录还留在终端里」的产品体验;以整屏或备用屏为主要模型的框架很难提供同样的行为。
  2. 对新终端特性的第一方控制。Kitty 键盘协议、CSI 2026 同步输出、Kitty / iTerm2 内联图片、OSC 8 超链接、OSC 52 剪贴板、OSC 133 命令块标记、IME 光标定位——这些都在 pi-tui 里有直接实现,依赖第三方库意味着要等上游支持。
  3. 极简抽象带来的可控性Component 只有一个 render,没有生命周期、没有调度器,行为完全可预测,也让虚拟终端测试变得容易。

代价同样明显:布局、滚动、选区、剪贴板这些浏览器里「白送」的能力,在 pi-tui 里都要自己写,而且每个组件都要自己保证不超宽。

实践任务

🛠 实践任务亲手看见差分渲染只写了一行

目标:用 pi-tui 渲染两行文本,然后只改第二行,观察终端实际收到的字节里有没有第一行。

前置条件:已有 pi 仓库(本书为 _sources/pi)且已 npm install不需要任何 API Key,全程离线,也不会修改仓库里的任何文件。

步骤 1:在仓库之外新建 /tmp/pi-tui-two-lines.mts(扩展名必须是 .mts——/tmp 下没有 package.json,用 .ts 会被当成 CommonJS,顶层 await 会报错):

ts
import { pathToFileURL } from "node:url";

const repo = process.env.PI_REPO;
if (!repo) {
	console.error("请先设置 PI_REPO=<pi 仓库根目录>");
	process.exit(1);
}
const load = (rel: string) => import(pathToFileURL(`${repo}/${rel}`).href);

const { Container, Text, TuiMainScreen } = await load("packages/tui/src/index.ts");
const { VirtualTerminal } = await load("packages/tui/test/virtual-terminal.ts");

// 1) 组件就是 render(width) -> string[]
const root = new Container();
const line1 = new Text("hello pi-tui", 0, 0);
const line2 = new Text("frame 0", 0, 0);
root.addChild(line1);
root.addChild(line2);
console.log("render(20) =", JSON.stringify(root.render(20)));

// 2) 记录终端真正收到的字节
class RecordingTerminal extends VirtualTerminal {
	writes: string[] = [];
	write(data: string) {
		this.writes.push(data);
		super.write(data);
	}
}

const term = new RecordingTerminal(20, 6);
const tui = new TuiMainScreen(term);
tui.addChild(root);
tui.start();
await term.waitForRender();
console.log("首帧写入 =", JSON.stringify(term.writes.join("")));

term.writes = [];
line2.setText("frame 1");
tui.requestRender();
await term.waitForRender();
console.log("第二帧写入 =", JSON.stringify(term.writes.join("")));
console.log("第二帧含 hello =", term.writes.join("").includes("hello"));
console.log("fullRedraws =", tui.fullRedraws);

tui.stop();

步骤 2:在 pi 仓库根目录运行(env VAR=... 的写法在 bash / zsh / fish 下都能用):

bash
env PI_REPO="$PWD" ./node_modules/.bin/tsx /tmp/pi-tui-two-lines.mts

预期现象(以下为本书作者实际运行的输出):

text
render(20) = ["hello pi-tui        ","frame 0             "]
首帧写入 = "\u001b[?2026hhello pi-tui        \u001b[0m\u001b]8;;\u0007\r\nframe 0             \u001b[0m\u001b]8;;\u0007\u001b[?2026l"
第二帧写入 = "\u001b[?2026h\r\u001b[2Kframe 1             \u001b[0m\u001b]8;;\u0007\u001b[?2026l"
第二帧含 hello = false
fullRedraws = 1

如何判断成功第二帧含 hello = false 且第二帧字节明显短于首帧,说明 hello pi-tui 那一行一个字节都没有重写fullRedraws = 1 说明只有首帧走了全量渲染路径。你还应能指出输出里三个序列的含义:\u001b[?2026h / \u001b[?2026l 是 CSI 2026 同步输出的起止、\u001b[2K 是清当前行、\u001b[0m\u001b]8;;\u0007applyLineResets 补的行尾重置。

进阶:把 line1.setText("HELLO") 也加上再跑一次,观察第二帧变长(两行都进了变更区间);或把 new Text("frame 0", 0, 0) 换成一个更长的字符串,看 Text 的自动换行如何改变行数、进而触发不同的差分路径。

常见错误

  • 忘了设 PI_REPO,脚本会直接打印提示并退出。
  • .ts 而不是 .mts,报 Top-level await is currently not supported with the "cjs" output format
  • 在仓库外直接 tsxnode_modules/.bin/tsx 路径写错:tsx 只存在于 pi 仓库的 node_modules 里。
  • 忘了 await term.waitForRender():渲染是节流的,立刻读 writes 会是空的。

对应源码位置packages/tui/src/tui.ts:23Component)、:235Container.render)、:730requestRender);packages/tui/src/TuiMainScreen.ts:260(行 diff)、:383(只重写变更区间)、:495(写出);packages/tui/test/virtual-terminal.ts:11VirtualTerminal)。

想看更完整的验证:仓库自带的测试就是这么写的。运行 node --test --test-reporter=spec packages/tui/test/tui-render.test.ts(本书作者实测 24 个用例全部通过),其中 tui-render.test.ts:541renders correctly when only a middle line changes (spinner case) 正是 spinner 场景的回归测试。

本章小结

  • pi-tui 的组件模型只有一个抽象:render(width) → string[]。没有虚拟 DOM,Container 的「布局」就是把子组件的行数组纵向拼接。
  • 差分发生在最终的行字符串数组上,不在组件树上。TuiMainScreen 逐行比较得出变更区间,只重写这段区间,整体包在 CSI 2026 同步输出里;完全无变化时一个字节都不写。首帧、宽/高变化、内容收缩、变更越过视口这五种情况会退化为全量重绘。
  • 本书锁定 commit 引入的 TuiAltScreen 与既有的 TuiMainScreen 共享 TUI 接口,只在 doRender() 与生命周期钩子上不同;createInteractiveTui 是唯一的选择点。
  • 交互模式的界面是九个顶层分区(八个 Container 加一个 FooterComponent);AgentSession 的事件经唯一订阅点进入一个大 switch,对这些分区增删改子组件后 requestRender()
  • AssistantMessageComponent 每次更新都全量重建——因为行级 diff 与 16ms 节流兜底,组件层才敢写得这么简单
  • footer 有两个数据源:session 相关数据每帧现算(invalidate() 是空实现),git 分支等走 watcher + 缓存,且监视的是目录而非 HEAD 文件(inode 会变)。
  • 关键术语:差分渲染(Differential Rendering)同步输出(CSI 2026)主屏 / 备用屏缓冲区raw 模式bracketed pasteoverlay(浮层)焦点组件
  • 关键源码索引:packages/tui/src/tui.ts:23Component)、:211Container)、:311TuiBase)、:730requestRender);packages/tui/src/TuiMainScreen.ts:146doRender)、:260(行 diff);packages/tui/src/TuiAltScreen.ts:54:350packages/tui/src/terminal.ts:52Terminal 接口);packages/coding-agent/src/modes/interactive/interactive-mode.ts:337createInteractiveTui)、:735(组件树)、:2881handleEvent);packages/coding-agent/src/modes/interactive/components/assistant-message.ts:83footer.ts:84;测试 packages/tui/test/tui-render.test.tspackages/coding-agent/test/interactive-tui.test.ts
  • 自测问题:① 一条 assistant 回复流式输出时,屏幕上前面几十行内容一个字节都没重写,这是靠组件层还是渲染层实现的?为什么?② spinner 每 80ms 转一帧,是谁在调用 requestRender()?框架里有全局定时器吗?③ 如果你写的自定义组件某一行比终端宽了 1 个字符,会发生什么?④ 为什么 Component 需要 invalidate(),而 FooterComponentinvalidate() 却是空实现?
  • 下一章:6.10 交互模式与 RPC 模式——把本章只做了「概览」的输入路径补全:while (true) { await getUserInput(); await session.prompt(...) } 这个主循环如何挂在一个 Promise 上,斜杠命令与 ! bash 前缀如何分派,以及同一个 AgentSession 换成 RPC 模式后界面这一层被替换成了什么。
  • 本章尚未展开的内容:overlay 的合成与焦点归还状态机(tui.ts:1044:196-206)、自动补全(packages/tui/src/autocomplete.ts)、Markdown 组件的渲染与缓存策略(packages/tui/src/components/markdown.ts)、alt 屏的鼠标拖选与 OSC 52 剪贴板(TuiAltScreen.ts:244-328)、内联图片协议(terminal-image.ts)。想深入终端协议本身,可以从 packages/tui/src/keys.tsstdin-buffer.ts 读起。

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)