Skip to content

6.9 pi-tui:终端界面库 ​

本页分析版本earendil-works/pi@16787ad2026-09-21

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

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

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

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

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

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

ts
// packages/tui/src/tui.ts:366-378
render(width: number): string[] {
	const lines: string[] = [];
	const mouseChildren: Array<{ component: Component; height: number }> = [];
	for (const child of this.children) {
		const childLines = child.render(width);
		mouseChildren.push({ component: child, height: childLines.length });
		for (const line of childLines) {
			lines.push(line);
		}
	}
	this.mouseLayout = { width, children: mouseChildren };
	return lines;
}
packages/tui/src/tui.ts · render(width: number): string[]
earendil-works/pi@16787ad第 366–378 行在 GitHub 查看 ↗
纵向拼接子组件的行,顺手记下每个子组件占了几行,供鼠标事件按行号找到对应的子组件。没有 flex、没有 grid——「布局」在这里等于「数组拼接顺序」。
⚠️ 常见误解以为 pi-tui 里有虚拟 DOM 或 diff 树
React 的 diff 发生在组件树上:框架比较两棵虚拟节点树,算出要改哪些 DOM 节点。pi-tui 完全不做这件事——组件树每帧都被完整地重新 render 一遍,diff 只发生在最终的行字符串数组上(`packages/tui/src/tui-main-screen.ts:363-389`)。理解这一点,后面「为什么组件层敢写得那么暴力」才讲得通。

Component 四个成员里最不直观的是 invalidate()。它存在的原因是:框架不缓存,但组件可以缓存。以最基础的 Text 为例,它按 (text, width) 缓存渲染结果,命中就直接返回旧数组(packages/tui/src/components/text.ts:47-49),而 invalidate() 就是把 cachedText / cachedWidth / cachedLines 三个字段清空(text.ts:39-43)。主题切换时 TuiBase.invalidate() 递归传播给当前挂载的整棵树和所有 overlay(tui.ts:873-876),缓存统一失效——这是 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() 的前半段是准备阶段,顺序不能乱(源码事实,packages/tui/src/tui-main-screen.ts:247-274):

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

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

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

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

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

ts
// packages/tui/src/tui-main-screen.ts:363-376(节选)
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@16787ad第 363–389 行在 GitHub 查看 ↗
差分核心:逐行字符串比较得出变更区间 `firstChanged..lastChanged`;随后为「追加行」和「Kitty 内联图片块」各做一次区间修正。

比较结果有三种走向:

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

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

ts
// packages/tui/src/tui-main-screen.ts:487-490
// 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"(:460)与 "\x1b[?2026l"(:567)之间,即 CSI 2026 同步输出:终端在这对标记之间不刷新屏幕,一帧的所有改动原子生效。移动光标用的是相对序列 ESC[nB / ESC[nA(:479-483),每行先 \x1b[2K 清行再写(:516)。输出不是拼成一个大字符串再写,而是交给一个 BoundedTerminalWriter(tui-main-screen.ts:18 起,注释说明按 1 MiB 分块写,免得一次全量重绘拼出超过 V8 上限的字符串),最后 output.flush() 写出剩余部分(:598)。一帧通常远小于 1 MiB,所以实际上仍是一次写入。

什么时候退化为全量重绘 ​

差分不是万能的。TuiMainScreen 在五种常见情况下调用 fullRender()(源码事实):首帧(tui-main-screen.ts:331-335)、终端宽度变化(换行结果会变,:338-342)、终端高度变化(Termux 例外,因为软键盘开合会改高度,:347-351)、内容收缩且开启了 clearOnShrink(:356-360)、变更行位于上一帧视口之上(差分够不着,:451-455);另外,要重画的 Kitty 内联图片放不进当前屏幕时也会退回全量重绘(:497-503)。设置环境变量 PI_TUI_DEBUG_REDRAW=1 后,每次全量重绘都会把原因追加进日志目录下的 pi-tui-debug.log(:321-328)。

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

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

谁来触发重绘 ​

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

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

scheduleRender() 按 MIN_RENDER_INTERVAL_MS - 距上一帧耗时 设 setTimeout(tui.ts:991-992),其中 MIN_RENDER_INTERVAL_MS = 16(tui.ts:477),即约 60 帧/秒的上限;渲染完成后若期间又有新请求,再排下一帧(tui.ts:1000-1002)。

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

那么谁在请求重绘?三类来源:终端输入、终端 resize(tui.ts:883)、以及应用逻辑。终端输入是个例外:焦点组件处理完按键后,TuiBase 调的是跳过 16ms 节流的 requestImmediateRender()(tui.ts:1080,实现在 963-978),注释解释说键盘输入对延迟敏感,而在 Windows 上连 setTimeout(0) 都可能要等满一个 16ms 周期。动画属于第三类且很典型:Loader 组件用 setInterval 每 80ms 换一帧 braille 字符(packages/tui/src/components/loader.ts:82-85),换完在 updateDisplay() 里自己调 this.ui.requestRender()(loader.ts:97-99,默认帧集与间隔见 loader.ts:11-12)。没有全局 tick,动画组件自己推。

两个渲染器:常规模式与全屏模式 ​

pi-tui 的渲染器是「一个 TUI 接口 + 两个实现」(官方文档 packages/tui/README.md:7 把 “Interchangeable Renderers” 列为第一特性,61-62 行说明两者分工):

  • TuiMainScreen(mode = "regular",packages/tui/src/tui-main-screen.ts:124-125)——渲染进终端的主屏幕缓冲区,保留 scrollback。这是默认行为,也是「退出 pi 之后聊天记录还留在终端里、可以上翻」的原因。
  • TuiAltScreen(mode = "fullscreen",packages/tui/src/tui-alt-screen.ts:197-199)——启动时写入 \x1b[?1049h(tui-alt-screen.ts:61,在 beforeTerminalStart 的 363-365 行写出)进入备用屏幕缓冲区,像 vim / less 那样占满整屏,滚动由应用自己管理,并接管鼠标滚轮、拖选复制(OSC 52)、OSC 8 链接点击和 Ctrl+Shift+F 搜索(功能清单见 packages/tui/README.md:709)。

两者共享 TuiBase(tui.ts:465)提供的焦点、overlay、输入分发、渲染节流,各自实现 doRender() 和几个生命周期钩子(tui.ts:508-518)。差分策略因此不同:主屏版比较的是文档行(可能远多于屏幕高度);全屏版先把内容排进一个与终端等高的画面,再逐行比较屏幕行,对变化的行发 \x1b[row;1H\x1b[2K 定位重写(tui-alt-screen.ts:1726-1729)。对超宽的行,全屏版直接按列截断(1679-1682),不像主屏版那样报错退出。

全屏模式还多了一样主屏版刻意不提供的东西:布局。TuiAltScreen.setLayoutRoot()(tui-alt-screen.ts:307-312)接受一棵由 VStack、HStack、ScrollView 组成的布局树,doRender 用 renderLayoutFrame(packages/tui/src/layout.ts:379,在 tui-alt-screen.ts:1666 调用)按终端高度给各区域分配行数,每个 ScrollView 独立滚动;没有布局根时,整份文档被放进一个隐式的 ScrollView(tui-alt-screen.ts:264)。官方文档说明这套语义「intentionally unavailable on TuiMainScreen, where the terminal owns scrollback」(packages/tui/README.md:83)。

全屏版退出时,默认不是简单地切回主屏了事,而是可以把完整文档回放到主屏上:

ts
// packages/tui/src/tui-alt-screen.ts:383-406(节选)
protected override afterTerminalStop(options: TuiStopOptions): void {
	if (!this.altScreenActive) return;
	this.altScreenActive = false;
	if (options.preserveScreen) {
		this.terminal.write(`${BEGIN_SYNCHRONIZED_OUTPUT}${EXIT_ALT_SCREEN}\x1b[?25h${END_SYNCHRONIZED_OUTPUT}`);
	} else {
		// …(省略:按当前宽度重新 render 出完整文档,去掉标记、截断超宽行,存进 lastDocument)
		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] ?? ""}`;
		}
		// …(省略:恢复自动换行、显示光标、结束同步输出)
		this.terminal.write(buffer);
	}
	// …(省略:还原 iTerm2 图片能力)
}
earendil-works/pi@16787ad第 383–406 行在 GitHub 查看 ↗
退出备用屏后,除非调用方要求保留屏幕(preserveScreen),否则把整份文档重新打印到主屏。官方文档 `packages/tui/README.md:62` 的说法与此一致:停止时「restores the main buffer and prints the complete final document」。

Pi 自己退出全屏模式时走的是另一条等价的路:设置项 fullscreenExitOutput 默认为 "transcript",这时 stopInteractiveTui 先切回常规渲染器、让主屏版把整份对话画出来,再停止(packages/coding-agent/src/modes/interactive/interactive-mode.ts:835-842);设为 "resume-hint" 则保留原屏幕、只打印恢复会话的提示(packages/coding-agent/docs/settings.md:93)。

选择哪一个渲染器由一个工厂函数决定,它的注释自称是「coding-agent 各种呈现方式共享的组合根」:

ts
// packages/coding-agent/src/modes/interactive/tui-renderer.ts:21-47(节选)
export function createInteractiveTui(options: InteractiveTuiOptions): TuiMainScreen | TuiAltScreen {
	const terminal = options.terminal ?? new ProcessTerminal();
	if (options.tuiMode === "fullscreen") {
		// …(省略:搜索高亮样式)
		return new TuiAltScreen(terminal, options.showHardwareCursor, options.logDirectory, {
			// …(省略:搜索高亮、「跳到最新消息」提示、openUrl、右键粘贴与复制选区等回调)
		});
	}
	return new TuiMainScreen(terminal, options.showHardwareCursor, options.logDirectory);
}
earendil-works/pi@16787ad第 21–48 行在 GitHub 查看 ↗
唯一决定使用哪个渲染器的地方。`options.tuiMode` 来自命令行 `--tui-mode` 或设置项 `tuiMode`(构造点在 `interactive-mode.ts:567-585`)。注意 `options.terminal` 可注入——测试正是靠它替换成虚拟终端。

模式有两个来源(源码事实):启动参数 --tui-mode regular|fullscreen(packages/coding-agent/src/cli/args.ts:203-210;真实采集的帮助文本 research/cli-captures/pi-help.txt:54),以及设置项 tuiMode(默认 "regular",packages/coding-agent/src/core/settings-manager.ts:159;官方文档把 "fullscreen" 标为实验性,docs/settings.md:92),前者优先(interactive-mode.ts:567)。在 /settings 里改这一项会立即生效:switchTuiMode(interactive-mode.ts:844-891)停掉旧渲染器、把同一棵组件树摘下来挂到新渲染器上,从常规切走时还会保存主屏的渲染状态以便切回时接着用。组件手里拿的 this.ui 其实是一个 Proxy(createInteractiveTuiReference,tui-renderer.ts:51-79),每次访问都转发给当前的渲染器,所以换渲染器时组件毫无感知。

对应的测试只断言几个事实就区分了两种渲染器:tuiMode: "regular" 时 mode 为 regular、写入序列中不含 \x1b[?1049h;tuiMode: "fullscreen" 时 mode 为 fullscreen、含这个序列(packages/coding-agent/test/interactive-tui.test.ts:51-79)。这是「怎么测终端 UI」的一个好范例——不去截图比对,而是断言协议字节。

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

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

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

Terminal 是接口(packages/tui/src/terminal.ts:71-113),生产实现 ProcessTerminal 在 start() 里把 stdin 切到 raw 模式(terminal.ts:178-180)、开启 bracketed paste(:185)、协商 Kitty 键盘协议(:202)。原始字节先经 StdinBuffer(packages/tui/src/stdin-buffer.ts:281)切分成一次一个按键序列,再转发给 handler(terminal.ts:214-228)——不切分的话,一次 read 里可能挤着好几个按键,matchesKey() 就没法工作了。

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

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

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

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

  1. 起点:ESC 字节到达 CustomEditor.handleInput,命中 app.interrupt,调用 this.onEscape——custom-editor.ts:103-111;
  2. onEscape 由 setupKeyHandlers() 安装,四级分派中的第一级是「正在流式输出」——interactive-mode.ts:2965-2967;
  3. → restoreQueuedMessagesToEditor({ abort: true }):先把排队消息倒回编辑器,再中止——interactive-mode.ts:4577-4596;
  4. → this.session.abort()——interactive-mode.ts:4583(队列为空时)或 :4593(非空时);AgentSession.abort() 先标记中止、取消重试与压缩,再调 this.agent.abort()——packages/coding-agent/src/core/agent-session.ts:2075-2085;
  5. 终点:Agent.abort() 触发本次 run 的 AbortController——packages/agent/src/agent.ts:338-340。

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

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

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

ts
// packages/coding-agent/src/modes/interactive/interactive-mode.ts:935-943
this.mountInteractiveTui(this.renderer, [
	this.documentContainer,
	this.pendingMessagesContainer,
	this.statusContainer,
	this.widgetContainerAbove,
	this.editorContainer,
	this.widgetContainerBelow,
	this.footerContainer,
]);
earendil-works/pi@16787ad第 827–833 行在 GitHub 查看 ↗
挂载函数:把分区依次 addChild 到渲染器上;如果是全屏渲染器,再额外设置布局根。组件在构造函数里 new 好(`:578-615`),装配放在 `init()`;随后 `this.ui.setFocus(this.editor)` 与 `this.ui.start()`(`:948`、`:951`)。

其中 documentContainer 自己又依次装着启动横幅、已加载资源列表和聊天区(interactive-mode.ts:591-594),footerContainer 里是内置 footer 或扩展提供的自定义 footer(2437-2445)。常规模式下,这七个分区就是纵向拼接;全屏模式下,createChatViewport(packages/coding-agent/src/modes/interactive/chat-viewport.ts:22-46)把 documentContainer 放进一个跟随末尾滚动的 ScrollView,其余六个分区用 VStack 固定在屏幕底部,形成「上面对话可滚、下面输入区不动」的布局。同一棵组件树,两种排法——这正是切换模式时可以原样搬运组件树的原因。

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

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

AgentSession.subscribe 把 listener 推进 _eventListeners(packages/coding-agent/src/core/agent-session.ts:1146-1156),事件源头是构造时对 Agent 的订阅(agent-session.ts:434),经 _handleAgentEvent(:894-974,先在 918 行派给扩展、再在 919 行广播)到达 _emit(:831-835)。事件的联合类型 AgentSessionEvent 定义在 agent-session.ts:164-205——它是 UI 的全部数据源。

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

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

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

ts
// packages/coding-agent/src/modes/interactive/components/assistant-message.ts:91-106(节选)
updateContent(message: AssistantMessage, isStreaming = this.isStreaming): void {
	this.lastMessage = message;
	this.isStreaming = isStreaming;
	// 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@16787ad第 91–106 行在 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-49),前面说过它自己 setInterval 推重绘。默认编辑器创建时带着 embedWorkingStatus: true(interactive-mode.ts:603-607),于是「工作中」「压缩中」「重试中」这些状态不另占一行,而是交给编辑器、画进输入框的上边框里(showStatusIndicator,interactive-mode.ts:2215-2226;绘制在 custom-editor.ts:36-79,状态文本由 status-indicator.ts:24-27 的 renderInBorder 压成一行)。状态出现、消失都不改变总行数,也就不会触发 clearOnShrink 的全屏重绘。只有扩展换上了没有声明这个能力的自定义编辑器时,状态才退回到编辑器上方的独立一行;此时在常规模式下清除状态会留下一个 IdleStatus 占位——它的 render 返回两行全是空格的行(status-indicator.ts:114-123),目的同样是让总行数不变(interactive-mode.ts:2229-2247,对应测试 packages/coding-agent/test/interactive-tui.test.ts:379-471)。

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

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

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

第一行是工作目录 + git 分支(采集时 Pi 仓库检出的是版本 tag 而不是分支,所以显示 detached,见 packages/coding-agent/src/core/footer-data-provider.ts:239-251;有会话名时还会追加 • 会话名),第二行左侧是 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@16787ad第 84–106 行在 GitHub 查看 ↗
每帧遍历 `sessionManager.getEntries()` 现算累计 usage。注意 `invalidate()`(`:72-74`)是**空实现**——footer 不缓存任何东西。

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

  • 来自 session 的部分(token 累计、费用、上下文占用百分比、模型 id、thinking 级别)每帧现算:遍历全部会话条目累加 usage,包括 assistant 消息、单独记账的 usage 条目以及压缩与分支摘要自己的 usage(footer.ts:91-106),上下文占用超 70% 变黄、超 90% 变红(footer.ts:156-162),空间不够时逐级降级截断(footer.ts:176-179 起)。
  • 来自 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:1046-1048)。暴露给 footer 与扩展的是只读视图 ReadonlyFooterDataProvider(footer-data-provider.ts:385-388)。

为什么要自研 ​

这是本章唯一一个没有官方答案的问题。我们检索过仓库根 README.md、packages/tui/README.md、packages/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 里都要自己写——全屏模式的 VStack / HStack / ScrollView、拖选复制与搜索正是这样一行行写出来的(packages/tui/src/layout.ts、tui-alt-screen.ts);而在常规模式下,每个组件都要自己保证不超宽。

实践任务 ​

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

目标:用 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;;\u0007 是 applyLineResets 补的行尾重置。

进阶:把 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。
  • 在仓库外直接 tsx 但 node_modules/.bin/tsx 路径写错:tsx 只存在于 pi 仓库的 node_modules 里。
  • 忘了 await term.waitForRender():渲染是节流的,立刻读 writes 会是空的。

对应源码位置:packages/tui/src/tui.ts:111(Component)、:366(Container.render)、:952(requestRender);packages/tui/src/tui-main-screen.ts:363(行 diff)、:489(只重写变更区间)、:598(写出);packages/tui/test/virtual-terminal.ts:11(VirtualTerminal)。

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

本章小结 ​

  • pi-tui 的组件模型只有一个抽象:render(width) → string[]。没有虚拟 DOM,Container 的「布局」就是把子组件的行数组纵向拼接。
  • 差分发生在最终的行字符串数组上,不在组件树上。TuiMainScreen 逐行比较得出变更区间,只重写这段区间,整体包在 CSI 2026 同步输出里;完全无变化时一个字节都不写。首帧、宽/高变化、内容收缩、变更越过视口这五种常见情况会退化为全量重绘。
  • 常规模式用 TuiMainScreen(主屏、保留 scrollback),全屏模式用 TuiAltScreen(备用屏、应用自管滚动,另有 VStack / HStack / ScrollView 布局);两者共享 TUI 接口,只在 doRender() 与生命周期钩子上不同。createInteractiveTui 是唯一的选择点,--tui-mode 或设置项 tuiMode 决定用哪个,运行中还能切换。
  • 交互模式的界面是七个顶层分区(对话文档、排队消息、状态、上下两个 widget 区、编辑器、footer);常规模式纵向拼接,全屏模式让对话文档可滚、其余固定在底部。AgentSession 的事件经唯一订阅点进入一个大 switch,对这些分区增删改子组件后 requestRender()。
  • 工作中、压缩中等状态默认画在编辑器的上边框里,不另占一行。
  • AssistantMessageComponent 每次更新都全量重建——因为行级 diff 与 16ms 节流兜底,组件层才敢写得这么简单。
  • footer 有两个数据源:session 相关数据每帧现算(invalidate() 是空实现),git 分支等走 watcher + 缓存,且监视的是目录而非 HEAD 文件(inode 会变)。
  • 关键术语:差分渲染(Differential Rendering)、同步输出(CSI 2026)、主屏 / 备用屏缓冲区、raw 模式、bracketed paste、overlay(浮层)、焦点组件。
  • 关键源码索引:packages/tui/src/tui.ts:111(Component)、:319(Container)、:465(TuiBase)、:952(requestRender);packages/tui/src/tui-main-screen.ts:247(doRender)、:363(行 diff);packages/tui/src/tui-alt-screen.ts:197(TuiAltScreen)、:1661(doRender);packages/tui/src/layout.ts:379(renderLayoutFrame);packages/tui/src/terminal.ts:71(Terminal 接口);packages/coding-agent/src/modes/interactive/tui-renderer.ts:21(createInteractiveTui)、chat-viewport.ts:22(全屏布局);packages/coding-agent/src/modes/interactive/interactive-mode.ts:935(组件树)、:844(switchTuiMode)、:3284(handleEvent);packages/coding-agent/src/modes/interactive/components/assistant-message.ts:91、footer.ts:84、custom-editor.ts:36;测试 packages/tui/test/tui-render.test.ts、packages/coding-agent/test/interactive-tui.test.ts。
  • 自测问题:① 一条 assistant 回复流式输出时,屏幕上前面几十行内容一个字节都没重写,这是靠组件层还是渲染层实现的?为什么?② spinner 每 80ms 转一帧,是谁在调用 requestRender()?框架里有全局定时器吗?③ 如果你写的自定义组件某一行比终端宽了 1 个字符,会发生什么?④ 为什么 Component 需要 invalidate(),而 FooterComponent 的 invalidate() 却是空实现?
  • 下一章:6.10 交互模式与 RPC 模式——把本章只做了「概览」的输入路径补全:while (true) { await getUserInput(); await session.prompt(...) } 这个主循环如何挂在一个 Promise 上,斜杠命令与 ! bash 前缀如何分派,以及同一个 AgentSession 换成 RPC 模式后界面这一层被替换成了什么。
  • 本章尚未展开的内容:overlay 的合成与焦点归还状态机(tui.ts:1279、:558)、自动补全(packages/tui/src/autocomplete.ts)、Markdown 组件的渲染与缓存策略(packages/tui/src/components/markdown.ts)、全屏模式的布局分配、鼠标拖选、OSC 52 剪贴板与搜索(packages/tui/src/layout.ts、tui-alt-screen.ts:1445 起、alt-screen-search.ts)、内联图片协议(terminal-image.ts)。想深入终端协议本身,可以从 packages/tui/src/keys.ts 与 stdin-buffer.ts 读起。
✅ 自测问题参考答案先自己回答,再点开对照
  1. 靠渲染层。组件层其实每次都在做「全量重建」——AssistantMessageComponent 收到 message_update 就按完整消息重建整棵子组件树,前面几十行照样被重新 render 了一遍。真正让屏幕不动的是 TuiMainScreen.doRender():它把整棵树渲染出来的行字符串数组与上一帧逐行比较,只重写变化的那段区间,整体包在 CSI 2026 同步输出里;完全没变就一个字节都不写。正因为有这层兜底,组件层才敢写得这么简单。
  2. 是组件自己——谁在动谁就调 requestRender(),spinner 组件自己起一个 80ms 的定时器,每帧改完状态再请求重绘。框架里没有全局定时器、没有渲染循环:requestRender() 只是把这一帧排进队列(最快 16ms 一帧地合并),没人请求就一直不画。这也是 pi-tui 待机时几乎不占 CPU 的原因。
  3. 程序会崩掉,但会先把终端恢复原状:TuiMainScreen 检测到某行可见宽度超过终端宽度时,写一份 pi-tui-crash.log、调 this.stop() 还原终端状态,然后抛错,错误文案还直接建议你用 visibleWidth() 测量、truncateToWidth() 截断(tui-main-screen.ts:517-543)。(全屏模式下 TuiAltScreen 会直接把超宽行截断,不会崩;但组件不能指望运行在哪个模式里。)框架不做自动截断——带 ANSI 序列的字符串截起来代价不小,而且截在哪里只有组件自己知道。所以「不超宽是组件自己的责任」。
  4. 因为框架不缓存,但组件可以缓存:Text 按 (text, width) 缓存渲染结果,主题切换这类「输入没变但结果该变」的时刻就必须有人来清缓存,TuiBase.invalidate() 于是递归传播给整棵树和所有 overlay。而 FooterComponent 每帧都现算(session 数据直接读、git 分支走 watcher 缓存),压根没有按输入缓存的渲染结果可清,所以它的 invalidate() 是空实现——不是漏写。

本书分析的 Pi 版本:earendil-works/pi@16787ad(2026-09-21)