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 的答案极简:界面就是一个字符串数组,每个元素是屏幕上的一行。
render(width: number): string[] 的对象:给它一个可用宽度,它返回若干行文本(可以带 ANSI 转义序列)。除此之外只有四个可选/必需成员:handleInput?(data) 在获得焦点时收键盘输入、handleMouse?(event) 在全屏模式下收鼠标事件、wantsKeyRelease? 决定是否接收按键释放事件、invalidate() 清空自己的渲染缓存。整个框架的核心只有这一个抽象——没有虚拟 DOM、没有组件生命周期;在常规模式下也没有布局引擎(全屏模式另有一套可选的纵横分区布局,见后文)。 Component组合靠 Container:它持有 children: Component[],render 时把每个子组件的行数组按顺序纵向拼接。这就是「组件树 → 行数组」的全部逻辑(源码事实):
// 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;
}render(width: number): string[]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() 唯一的用途。
为什么必须做差分渲染
如果每帧都「清屏 + 重画全部内容」,会有三个后果:
- 闪烁。清屏与重画之间存在时间差,终端可能在这个空档刷新一次,用户看到一次白/黑闪。
- 滚动历史被冲掉。
\x1b[3J会连同 scrollback 一起清掉,你上翻就找不到之前的对话了。 - 带宽浪费。一个 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):
newLines = this.render(width)——渲染整棵组件树(:264);- 有 overlay 时
compositeOverlays()把浮层的行合成进去(:267-269,实现见tui.ts:1279); extractCursorPosition()找出并剥离光标标记(:272,实现见tui.ts:1382);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)。
差分的核心:逐行字符串比较
准备完成后,差分本身朴素得让人意外——两个数组逐位置比较字符串,记下第一个和最后一个不同的下标:
// 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 图片块需整块重画时扩展区间)firstChanged比较结果有三种走向:
- 完全没变(
firstChanged === -1):一个字节都不写,只更新硬件光标位置后返回(tui-main-screen.ts:392-397)。 - 变更全在被删掉的行里:只发清行序列,不重写内容(
:400-447)。 - 常规情况:把光标相对移动到
firstChanged所在行,然后只重写firstChanged..lastChanged这段区间。
第三种情况的关键三行,源码注释直接写出了动机:
// 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 内的多次请求,以及限制帧率。
// 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)。
requestRender那么谁在请求重绘?三类来源:终端输入、终端 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)。
全屏版退出时,默认不是简单地切回主屏了事,而是可以把完整文档回放到主屏上:
// 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 图片能力)
}afterTerminalStopPi 自己退出全屏模式时走的是另一条等价的路:设置项 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 各种呈现方式共享的组合根」:
// 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);
}createInteractiveTui模式有两个来源(源码事实):启动参数 --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):
// 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):
- 起点:ESC 字节到达
CustomEditor.handleInput,命中app.interrupt,调用this.onEscape——custom-editor.ts:103-111; onEscape由setupKeyHandlers()安装,四级分派中的第一级是「正在流式输出」——interactive-mode.ts:2965-2967;- →
restoreQueuedMessagesToEditor({ abort: true }):先把排队消息倒回编辑器,再中止——interactive-mode.ts:4577-4596; - →
this.session.abort()——interactive-mode.ts:4583(队列为空时)或:4593(非空时);AgentSession.abort()先标记中止、取消重试与压缩,再调this.agent.abort()——packages/coding-agent/src/core/agent-session.ts:2075-2085; - 终点:
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 模式:事件如何变成界面
到这里两半拼上了。交互模式的界面由七个顶层分区组成,挂载顺序就是屏幕自上而下的顺序(源码事实,下面这段是整个界面布局的唯一来源):
// 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,
]);mountInteractiveTui其中 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()。 订阅只有一处:
// 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)。
message_update现在看那个最反直觉的实现细节。AssistantMessageComponent 看起来应该「随文本增量往后追加」,但它的 updateContent 每次都先清空再全量重建:
// 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 提示)
}组件层完全不做增量,因为行级 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`。
footer:状态栏的两个数据源
底部状态栏是 FooterComponent。下面是真实采集的画面片段(来自 research/cli-captures/pi-tui-main.txt:29-30,真实采集;因当时未登录任何 provider,模型显示为 unknown):
/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)。
render(width: number): string[]两个数据源的性格完全不同:
- 来自 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 等现成库」。
据此推断(尚未在源码中直接证实),从代码特征可以反推出三条动机:
- 主屏渲染 + 保留 scrollback。这是
TuiMainScreen的核心卖点,也直接决定了「退出 pi 后聊天记录还留在终端里」的产品体验;以整屏或备用屏为主要模型的框架很难提供同样的行为。全屏模式是后来作为可选项补上的,而且连它退出时都要把完整对话回放到主屏。 - 对新终端特性的第一方控制。Kitty 键盘协议、CSI 2026 同步输出、Kitty / iTerm2 内联图片、OSC 8 超链接、OSC 52 剪贴板、OSC 133 命令块标记、IME 光标定位——这些都在 pi-tui 里有直接实现,依赖第三方库意味着要等上游支持。
- 极简抽象带来的可控性。
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 会报错):
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 下都能用):
env PI_REPO="$PWD" ./node_modules/.bin/tsx /tmp/pi-tui-two-lines.mts预期现象(以下为本书作者实际运行的输出):
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读起。
✅ 自测问题参考答案先自己回答,再点开对照
- 靠渲染层。组件层其实每次都在做「全量重建」——
AssistantMessageComponent收到message_update就按完整消息重建整棵子组件树,前面几十行照样被重新render了一遍。真正让屏幕不动的是TuiMainScreen.doRender():它把整棵树渲染出来的行字符串数组与上一帧逐行比较,只重写变化的那段区间,整体包在 CSI 2026 同步输出里;完全没变就一个字节都不写。正因为有这层兜底,组件层才敢写得这么简单。 - 是组件自己——谁在动谁就调
requestRender(),spinner 组件自己起一个 80ms 的定时器,每帧改完状态再请求重绘。框架里没有全局定时器、没有渲染循环:requestRender()只是把这一帧排进队列(最快 16ms 一帧地合并),没人请求就一直不画。这也是 pi-tui 待机时几乎不占 CPU 的原因。 - 程序会崩掉,但会先把终端恢复原状:
TuiMainScreen检测到某行可见宽度超过终端宽度时,写一份pi-tui-crash.log、调this.stop()还原终端状态,然后抛错,错误文案还直接建议你用visibleWidth()测量、truncateToWidth()截断(tui-main-screen.ts:517-543)。(全屏模式下TuiAltScreen会直接把超宽行截断,不会崩;但组件不能指望运行在哪个模式里。)框架不做自动截断——带 ANSI 序列的字符串截起来代价不小,而且截在哪里只有组件自己知道。所以「不超宽是组件自己的责任」。 - 因为框架不缓存,但组件可以缓存:
Text按(text, width)缓存渲染结果,主题切换这类「输入没变但结果该变」的时刻就必须有人来清缓存,TuiBase.invalidate()于是递归传播给整棵树和所有 overlay。而FooterComponent每帧都现算(session 数据直接读、git 分支走 watcher 缓存),压根没有按输入缓存的渲染结果可清,所以它的invalidate()是空实现——不是漏写。