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的差分算法与五个退化条件;③ 区分TuiMainScreen与TuiAltScreen的职责边界;④ 完整复述「Agent 事件 → 组件 → 终端字节」这条链;⑤ 亲手跑通一个只读脚本,看见差分渲染真的只写了变化的那一行。
建立直觉:组件就是一个函数
在浏览器里写界面,你操作的是 DOM 节点树,框架负责把「你想要的状态」翻译成「对树的最小改动」。终端没有 DOM——终端只接受一串字节,其中有些是要显示的字符,有些是控制光标和颜色的转义序列(escape sequence)。所以终端界面库要回答的第一个问题是:界面在内存里长什么样?
pi-tui 的答案极简:界面就是一个字符串数组,每个元素是屏幕上的一行。
render(width: number): string[] 的对象:给它一个可用宽度,它返回若干行文本(可以带 ANSI 转义序列)。除此之外只有三个可选/必需成员:handleInput?(data) 在获得焦点时收键盘输入、wantsKeyRelease? 决定是否接收按键释放事件、invalidate() 清空自己的渲染缓存。整个框架只有这一个抽象——没有虚拟 DOM、没有布局引擎、没有组件生命周期。 Component组合靠 Container:它持有 children: Component[],render 时把每个子组件的行数组按顺序纵向拼接。这就是「组件树 → 行数组」的全部逻辑,10 行(源码事实):
// 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;
}render(width: number): string[]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() 唯一的用途。
为什么必须做差分渲染
如果每帧都「清屏 + 重画全部内容」,会有三个后果:
- 闪烁。清屏与重画之间存在时间差,终端可能在这个空档刷新一次,用户看到一次白/黑闪。
- 滚动历史被冲掉。
\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() 的前半段是准备阶段,顺序不能乱(源码事实,TuiMainScreen.ts:146-173):
newLines = this.render(width)——渲染整棵组件树(:163);- 有 overlay 时
compositeOverlays()把浮层的行合成进去(:166-168,实现见tui.ts:1044); extractCursorPosition()找出并剥离光标标记(:171,实现见tui.ts:1134);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_RESET(tui.ts:250)在每行尾部补一个 \x1b[0m\x1b]8;;\x07,把样式和超链接都关掉,保证行与行之间互不污染。官方文档也写明了这条约定(packages/coding-agent/docs/tui.md:29)。
差分的核心:逐行字符串比较
准备完成后,差分本身朴素得让人意外——两个数组逐位置比较字符串,记下第一个和最后一个不同的下标:
// 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 图片块需整块重画时扩展区间)firstChanged比较结果有三种走向:
- 完全没变(
firstChanged === -1):一个字节都不写,只更新硬件光标位置后返回(TuiMainScreen.ts:290-295)。 - 变更全在被删掉的行里:只发清行序列,不重写内容(
:298-344)。 - 常规情况:把光标相对移动到
firstChanged所在行,然后只重写firstChanged..lastChanged这段区间。
第三种情况的关键三行,源码注释直接写出了动机:
// 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 内的多次请求,以及限制帧率。
// packages/tui/src/tui.ts:748-751(节选)
if (this.renderRequested) return;
this.renderRequested = true;
process.nextTick(() => this.scheduleRender());scheduleRender() 按 MIN_RENDER_INTERVAL_MS - 距上一帧耗时 设 setTimeout(tui.ts:757-758),其中 MIN_RENDER_INTERVAL_MS = 16(tui.ts:321),即约 60 帧/秒的上限;渲染完成后若期间又有新请求,再排下一帧(tui.ts:767-769)。
requestRender那么谁在调 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/stickToBottom,TuiAltScreen.ts:59-61),并接管鼠标滚轮、拖选与 OSC 8 链接点击。
两者共享 TuiBase(tui.ts:311)提供的焦点、overlay、输入分发、渲染节流,各自只实现 doRender()。差分策略因此不同:主屏版比较的是文档行(可能远多于屏幕高度),alt 屏版先按 scrollTop 截出视口再比较屏幕行,对变化的行发 \x1b[row;1H\x1b[2K 定位重写(TuiAltScreen.ts:413-416)。
alt 屏最贴心的一处在退出时:它不是简单地切回主屏了事,而是把完整文档回放到主屏上:
// 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);
}afterTerminalStop选择哪一个由一个七行的工厂函数决定,它的注释自称是「选择交互式终端渲染器的组合根」:
// 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);
}createInteractiveTui对应的测试只断言一个转义序列就区分了两种渲染器:alt: false 时写入序列中不含 \x1b[?1049h,alt: true 时含(packages/coding-agent/test/interactive-tui.test.ts:16-41)。这是「怎么测终端 UI」的一个好范例——不去截图比对,而是断言协议字节。
从终端字节到组件:输入路径概览
界面另一半是输入。TuiBase.start() 是「终端字节流」与「组件树」之间唯一的接线点(源码事实,tui.ts:661-675):
// packages/tui/src/tui.ts:664-667
this.terminal.start(
(data) => this.handleTerminalInput(data),
() => this.requestRender(),
);Terminal 是接口(packages/tui/src/terminal.ts:52-94),生产实现 ProcessTerminal 在 start() 里把 stdin 切到 raw 模式(terminal.ts:140-142)、开启 bracketed paste(:147)、协商 Kitty 键盘协议(:166)。原始字节先经 StdinBuffer(packages/tui/src/stdin-buffer.ts:274)切分成一次一个按键序列,再转发给 handler(terminal.ts:309-318)——不切分的话,一次 read 里可能挤着好几个按键,matchesKey() 就没法工作了。
进入 handleTerminalInput()(tui.ts:773-847)后依次是:终端查询应答、全局输入监听器、调试键、overlay 焦点校正,最后交给焦点组件并统一 requestRender()。交互模式把焦点设在编辑器上(interactive-mode.ts:746)。
编辑器这一层分两级。Editor(packages/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)——顺序很重要,否则回调里如果又往编辑器写东西就会被随后的清空吞掉。
CustomEditor(packages/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):
- 起点:ESC 字节到达
CustomEditor.handleInput,命中app.interrupt,调用this.onEscape——custom-editor.ts:45-53; onEscape由setupKeyHandlers()安装,四级分派中的第一级是「正在流式输出」——interactive-mode.ts:2596-2598;- →
restoreQueuedMessagesToEditor({ abort: true }):先把排队消息倒回编辑器,再中止——interactive-mode.ts:4021-4040; - →
this.agent.abort()——interactive-mode.ts:4027(队列为空时)或:4037(非空时); - 终点:
Agent.abort()触发本次 run 的AbortController——packages/agent/src/agent.ts:311-314。
顺带解决一个常见困惑:raw 模式下 Ctrl+C 不产生 SIGINT,它只是一个普通字节 \x03。tui.ts:837-838 的注释明说输入(含 Ctrl+C)一律交给焦点组件处理;官方文档 packages/tui/README.md:43 也在快速上手示例里提醒要自己拦截它才能退出。Pi 把它绑成 app.clear:单击清空编辑器,500ms 内连按两次才退出(interactive-mode.ts:3565-3572)。
interactive 模式:事件如何变成界面
到这里两半拼上了。交互模式的界面由九个顶层分区组成——八个 Container 加一个 FooterComponent,装配顺序就是屏幕自上而下的顺序(源码事实,下面这段是整个界面布局的唯一来源):
// 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);事件处理的本质,就是对这九个分区增删改子组件,然后 requestRender()。 订阅只有一处:
// 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 推进 _eventListeners(packages/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 的全部数据源。
handleEvent(interactive-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:按toolCallId从pendingTools取出组件,分别标记开始、更新部分结果、写入最终结果后删除(:3039-3080)。
message_update现在看那个最反直觉的实现细节。AssistantMessageComponent 看起来应该「随文本增量往后追加」,但它的 updateContent 每次都先清空再全量重建:
// 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 提示)
}组件层完全不做增量,因为行级 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`。
footer:状态栏的两个数据源
底部状态栏是 FooterComponent。下面是真实采集的画面片段(来自 research/cli-captures/pi-tui-main.txt,真实采集;因当时未登录任何 provider,模型显示为 unknown):
/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)。
render(width: number): string[]两个数据源的性格完全不同:
- 来自 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 与扩展的是只读视图 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 里都要自己写,而且每个组件都要自己保证不超宽。
实践任务
目标:用 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:23(Component)、:235(Container.render)、:730(requestRender);packages/tui/src/TuiMainScreen.ts:260(行 diff)、:383(只重写变更区间)、:495(写出);packages/tui/test/virtual-terminal.ts:11(VirtualTerminal)。
想看更完整的验证:仓库自带的测试就是这么写的。运行 node --test --test-reporter=spec packages/tui/test/tui-render.test.ts(本书作者实测 24 个用例全部通过),其中 tui-render.test.ts:541 的 renders 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 paste、overlay(浮层)、焦点组件。
- 关键源码索引:
packages/tui/src/tui.ts:23(Component)、:211(Container)、:311(TuiBase)、:730(requestRender);packages/tui/src/TuiMainScreen.ts:146(doRender)、:260(行 diff);packages/tui/src/TuiAltScreen.ts:54、:350;packages/tui/src/terminal.ts:52(Terminal接口);packages/coding-agent/src/modes/interactive/interactive-mode.ts:337(createInteractiveTui)、:735(组件树)、:2881(handleEvent);packages/coding-agent/src/modes/interactive/components/assistant-message.ts:83、footer.ts:84;测试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: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.ts与stdin-buffer.ts读起。