架构(三):pi-tui 的渲染与编辑器
差分渲染怎么只写变化的那几行,以及 2473 行的编辑器
pi-tui 是整个 CLI 树里最不起眼的一个包:它不依赖 pi-ai,不知道大模型是什么,只负责把字符串画到终端上然后收回按键。
也正因为如此,它是三个架构篇里最容易读完的一个。但读完你会发现,真正难的部分不是画界面,是让终端不闪、不卡、不在某些特定终端上把画面搞乱。
本文的基线是 v1.3.0-16-g8efd314fa。
19.1 组件模型
一个接口,四个方法
整个 pi-tui 的核心是 Component(packages/tui/src/tui.ts:117):
interface Component {
render(width: number): string[];
handleInput?(data: string): void;
handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
invalidate(): void;
}四个方法,语义都很窄,但每一个都有硬约束。
render(width) 返回行数组,每一行必须不超过 width 列。 这不是建议——超宽会直接抛异常。tui-main-screen.ts:518 那段代码在发现某行 visibleWidth(line) > width 时的动作值得完整看一遍:
output.append("\x1b[2K"); // Clear current line
if (!isImage && visibleWidth(line) > width) {
// Log all lines to crash file for debugging
const crashLogPath = path.join(this.logDirectory ?? os.tmpdir(), "pi-tui-crash.log");
const crashData = [ /* 崩溃现场、终端宽度、超宽的行号、全部渲染行 */ ].join("\n");
fs.writeFileSync(crashLogPath, crashData);
// Clean up terminal state before throwing
this.stop();
throw new Error(errorMsg);
}它把全部渲染行写进一个 crash log、先把终端状态收拾干净、再抛错。错误信息里直说了原因:
This is likely caused by a custom TUI component not truncating its output.
这里的「宽度」是可见列宽,不是字符串长度。ANSI 转义序列不占列、宽字符占两列、emoji 和组合字符又是另一套规则。所以 visibleWidth()、truncateToWidth()、sliceByColumn()、wrapTextWithAnsi() 这四个工具函数是必须用的,不要自己数 String.length(packages/tui/src/utils.ts:250、:1121、:900、:1263)。
handleInput(data) 收的是原始终端输入,可能包含 ANSI 转义序列。多字节转义序列被拆包送达是常态,所以不能假设「一次 handleInput 对应一次按键」。StdinBuffer(packages/tui/src/stdin-buffer.ts,480 行)就是专门做批量拆包的。
invalidate() 清缓存。 没有缓存状态的组件可以写空实现;但如果你缓存了任何跟宽度或主题相关的渲染结果,主题切换和窗口 resize 就不会生效。
样式不跨行
tui.ts:423 定义了一个常量:
const SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";每渲染完一行,TUI 都会在行尾追加它——SGR 全部复位,顺带把 OSC 8 超链接也关掉。
这意味着样式不跨行。 你写一个带背景色和前景色的多行字符串,期望第二行继承第一行的颜色,实际结果是第二行是默认色。官方 README 给了两条解法(README.md:265):逐行重新套样式,或者用 wrapTextWithAnsi() 让它在换行处正确保留。
这条约束会一路影响到你写扩展的方式:任何带样式的字符串都必须是「按行」的,不能是一个整块的多行文本。
Container 在 tui.ts 里
这是写这份文档时最值得强调的一条纠错。
Container 存在,定义在 packages/tui/src/tui.ts:358,由 packages/tui/src/index.ts:159 导出:
export { Box } from "./components/box.ts";
export { CancellableLoader } from "./components/cancellable-loader.ts";
// ...
export {
type Component,
Container,
CURSOR_MARKER,
// ...
} from "./tui.ts";它不在 components/ 目录里。 只扫 packages/tui/src/components/ 会得出「没有 Container」的结论——目录里确实没有 container.ts。但 TuiBase 自己就 extends Container,整个渲染树的根都是它。
它的实现简单到可以完整贴出来(tui.ts:358):
export class Container implements Component {
children: Component[] = [];
private mouseLayout?: { width: number; children: Array<{ component: Component; height: number }> };
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;
}
// addChild / removeChild / clear / invalidate / handleMouse
}有一处细节体现了对性能的真实考量:render() 的同时顺手把每个子组件的高度记进 mouseLayout,这样 handleMouse 做命中测试时不用为了知道「y 坐标落在哪个孩子身上」而重新渲染一遍所有子组件。README 把它写成了设计原则(README.md:304):
ContainerandBoxroute events to nested children using geometry recorded by the last rendered frame, so pointer motion does not rerender children merely to hit-test them.
鼠标移动不该触发重渲染。 这一条在整个包里反复出现。
内置组件清单
packages/tui/README.md:14 给了完整名单:
VStack / HStack 和 Container 不是一个层次的东西,这个区别在 19.4 展开。
这个清单里没有编辑器模式的概念。 pi-tui 没有内置的模式化编辑器——Editor 是多行软换行编辑器,靠 keybindings.ts 里的动作 ID 派发按键。想要模态编辑(normal/insert 那种)得自己实现 EditorComponent 接口,用 ctx.ui.setEditorComponent() 挂上去,官方示例是 examples/extensions/modal-editor.ts。
// packages/tui/src/editor-component.ts:11
export interface EditorComponent extends Component {
getText(): string;
setText(text: string): void;
handleInput(data: string): void;
onSubmit?: (text: string) => void;
onChange?: (text: string) => void;
// 可选:addToHistory / insertTextAtCursor / getExpandedText
// setAutocompleteProvider / borderColor / setPaddingX / setAutocompleteMaxVisible
}接口注释里举的例子就是模式化编辑器——但那只是接口允许,不是内置实现。要在 Pi 里得到一个内置的模式编辑器,得自己写扩展。
19.2 两种渲染器
默认是全屏
先纠正一个流传很广的说法。
Pi 的 TUI 默认是全屏模式。 packages/coding-agent/src/core/settings-defaults.ts:62:
tuiMode: "fullscreen",读取逻辑在 src/core/settings-manager.ts:1257,注意它是怎么处理「没设」的情况的:
return this.settings.tuiMode === "regular" ? "regular" : SETTINGS_DEFAULTS.tuiMode;只有显式写了 "regular" 才走常规模式,其他一切情况(包括没写、写了非法值)都落到 fullscreen。 这个默认值偏向安全侧的写法值得学——一个拼错的配置项不会静默把你切到另一种行为。
这个默认值在 v1.0.0 时就已经是这样了,可以直接验证:
git show v1.0.0:packages/coding-agent/src/core/settings-manager.ts | grep -n tuiMode
# 184: tuiMode?: TuiMode; // default: "fullscreen"
# 1349: return this.settings.tuiMode === "regular" ? "regular" : "fullscreen";分发逻辑在 src/modes/interactive/tui-renderer.ts:22,用 TypeScript 的重载让返回类型跟着 tuiMode 走:
export function createInteractiveTui(options: InteractiveTuiOptions & { readonly tuiMode: "fullscreen" }): TuiAltScreen;
export function createInteractiveTui(options: InteractiveTuiOptions & { readonly tuiMode: "regular" }): TuiMainScreen;
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, { /* ... */ });
}
return new TuiMainScreen(terminal, options.showHardwareCursor, options.logDirectory);
}所以:pi-tui 自己没有「默认模式」这个概念,TuiMode = "regular" | "fullscreen"(packages/tui/src/tui.ts:457)只是个类型;是 pi-coding-agent 决定默认值。TuiMainScreen.mode 硬编码 "regular",TuiAltScreen.mode 硬编码 "fullscreen"(tui-main-screen.ts:125、tui-alt-screen.ts:203)。
界面上的实际差别在 03 交互式会话 的 3.7 节讲。这里只需要记住:你看到的历史能不能用终端自己的滚轮往上翻、能被 tmux 的 copy-mode 抓到、退出后是否还在——这些取决于你现在是哪个模式。
TuiMainScreen:写进主缓冲区
TuiMainScreen 把内容写进终端的主缓冲区。它把渲染状态显式建模成一个结构(tui-main-screen.ts:113):
export interface TuiMainScreenRenderState {
previousLines: string[];
previousWidth: number;
previousHeight: number;
cursorRow: number;
hardwareCursorRow: number;
maxLinesRendered: number;
previousViewportTop: number;
}previousLines 就是差分渲染的全部依据——上一帧的每一行原文。19.3 整节都在讲怎么用它。
三个跟位置相关的字段分工明确,别混:
cursorRow——内容末尾在哪一行,用来算视口位置;hardwareCursorRow——硬件光标实际在哪一行,用来算相对移动量;previousViewportTop——工作区顶边在滚动缓冲里的绝对行号。
previousWidth / previousHeight 记的是上一次的终端尺寸,maxLinesRendered 记的是工作区历史最大高度(只在清屏时才会缩回去),这两个都是用来判断「要不要放弃差分」的。
README 把它的三种渲染策略列得很清楚(README.md:745):
- 首次渲染:直接输出全部行,不清滚动缓冲;
- 宽度变化 / 变化发生在视口上方:清屏并全量重绘;
- 常规更新:把光标移到第一个变化的行,清到行尾,重写变化的行。
TuiAltScreen:接管整屏
TuiAltScreen 一开始就切进备用缓冲区:
// tui-alt-screen.ts:63
const ENTER_ALT_SCREEN = "\x1b[?1049h";
const EXIT_ALT_SCREEN = "\x1b[?1049l";
const DISABLE_AUTOWRAP = "\x1b[?7l";
const ENABLE_AUTOWRAP = "\x1b[?7h";启动时写的是(tui-alt-screen.ts:383):
this.terminal.write(
`${ENTER_ALT_SCREEN}${DISABLE_AUTOWRAP}${this.mouseEnabled ? mouseSequence : ""}\x1b[2J\x1b[H\x1b[?25l`,
);四个决定:进备用缓冲区、关自动换行、开鼠标、隐藏光标。
关自动换行是差分渲染的前提。 如果终端自己会折行,那么「第 N 行」这个概念在你的控制之外,你算出来的 lastChanged 和终端实际画出来的位置就对不上了。
鼠标序列分两种(tui-alt-screen.ts:66):
const ENABLE_BUTTON_MOTION_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1004h\x1b[?1006h";
const ENABLE_ALL_MOTION_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h";多一个 ?1003h(all-motion)。选择逻辑写在 tui-alt-screen.ts:372:
// Multiplexers can lag when every pointer movement is forwarded. Button-motion
// tracking preserves clicks, wheel events, selections, and scrollbar dragging.
const mouseSequence =
process.env.TMUX !== undefined ||
process.env.ZELLIJ !== undefined ||
process.env.STY !== undefined ||
term.startsWith("tmux") ||
term.startsWith("screen")
? ENABLE_BUTTON_MOTION_MOUSE
: ENABLE_ALL_MOTION_MOUSE;在 tmux/zellij 下关掉 all-motion。 注释说得很直接:多路复用器转发每一个指针移动事件时会卡,用 button-motion 依然能拿到点击、滚轮、选区和滚动条拖拽。这是一个只在真实使用中才会撞上、撞上之后才会去读的判断。
TuiAltScreen 停止时的行为值得单说(tui-alt-screen.ts:403):它会把完整文档打印到主缓冲区去。
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] ?? ""}`;
}也就是说,全屏模式下退出 Pi,你不会「什么都没留下」——对话记录会被打印回主屏幕。传 preserveScreen: true 则不打印,留给下一个 TUI 接管同一个终端。
Container 的两种变体
Container 是通用容器。但 VStack / HStack / ScrollView 走的是另一套机制——它们实现了 LAYOUT_NODE 符号方法(packages/tui/src/layout-node.ts:3):
export const LAYOUT_NODE = Symbol.for("@earendil-works/pi-tui/layout-node");
export interface StackLayoutEntry {
component: Component;
basis?: number | "auto";
grow?: number;
shrink?: number;
minSize?: number;
maxSize?: number;
visible?: (viewport: LayoutViewport) => boolean;
}用了 Symbol.for 而不是 Symbol(),是为了让跨越多个包副本时仍然认得出来。
这套语义只在 TuiAltScreen 上可用,因为它需要「终端高度」这个约束。主屏幕上终端自己管滚动,VStack 的高度分配没有意义。README 把这写成了刻意的限制(README.md:121):
These semantics are intentionally unavailable on
TuiMainScreen, where the terminal owns scrollback.
19.3 差分渲染
基本算法
TuiMainScreen.doRender()(tui-main-screen.ts:247,一个 370 行的大方法)里,差分的核心是这段(:363):
// Find first and last changed lines
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;
}
}就这么多。逐行比较,取第一个和最后一个不同的行号,然后只重写这个区间。
这个选择的代价和收益都很明确:
- 代价——如果第 3 行和第 500 行都变了,中间 497 行会被重写。差分是「区间」而不是「集合」;
- 收益——循环只有一次遍历,且完全不需要 diff 算法(没有 LCS、没有 Myers)。对于终端 UI 这种「大部分时候只有末尾在变」的场景,区间差分的实际命中率非常高。
比较的是整行字符串相等,不是字符级 diff。ANSI 序列不同也算变化,所以如果你每帧都重新生成时间戳或者 spinner 字符,即使只有 1 行变,区间也会被拉大。
写入的部分(: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++) {
if (i > firstChanged) output.append("\r\n");
// ...
output.append("\x1b[2K"); // Clear current line
output.append(line);
}注释里那个例子正是差分渲染的主战场:spinner 每帧只改一行。如果每次都从第一行重画到最后一行,spinner 会让整个界面闪烁;只写那一行则完全看不出来。
什么时候放弃差分
全量重绘的触发条件有五处,每一处都对应一个「算出来的位置不可信」的场景:
第三条里那个 isTermuxSession() 例外很典型:
// Height changes normally need a full re-render to keep the visible viewport aligned,
// but Termux changes height when the software keyboard shows or hides.
// In that environment, a full redraw causes the entire history to replay on every toggle.
if (heightChanged && !isTermuxSession()) {Android 上弹出软键盘会让终端高度变一下。常规模式下这会触发全量重绘,导致每次切键盘都把整个历史重放一遍。所以专门为 Termux 开了一个后门。
第四条(clearOnShrink)的注释还解释了为什么有 overlays 时不能做:
// Content shrunk below the working area and no overlays - re-render to clear empty rows
// (overlays need the padding, so only do this when no overlays are active)
// Configurable via setClearOnShrink()内容变短时,屏幕底部会留下旧内容的残影。但如果有 overlay,它需要那几行空行来撑开空间,所以这时反而不能清。
只追加的快捷路径
新内容比旧内容长时(流式输出最常见的情况),代码里有一条专门优化(:378):把 lastChanged 直接拉到新内容的末行。后面的移动视口逻辑(:465)在目标行超出当前可见底部时,往下滚相应行数并同步更新 prevViewportTop。流式输出时,这一整段都退化成「往下滚 N 行 + 写新行」,不需要重写旧内容。
同步输出:让整帧原子呈现
无论走差分还是全量,写出去的内容都包在两个转义序列之间:\x1b[?2026h 和 \x1b[?2026l(tui-main-screen.ts:281、:303)。
这就是 DEC 私有模式 2026,同步输出(synchronized output)。终端收到 ?2026h 后会缓冲所有输出,直到 ?2026l 才一次性提交。
差分解决了「少写」,同步输出解决了「不闪」。 两者是正交的:即使你只写了一行,中间状态(先清行再写内容)也可能被终端显示出来;同步输出把整个序列变成一次提交,中间状态不可见。
README 的措辞是 "atomic screen updates (no flicker)"(README.md:10)。它还提到支持情况:这是较新的协议,不支持的终端会忽略这两个序列——这就是为什么必须包起来而不是替代差分。
BoundedTerminalWriter:1 MiB 分块
这是全篇最值得学的工程细节。
terminal.write() 接受一个字符串,理论上你可以把整帧拼成一个大字符串一次写出去。但源码注释把动机说得很直接:"Streams terminal output in 1 MiB chunks so a full render never forms one string large enough to exceed V8's limit."
一个超长 transcript 渲染出来的帧完全可能撞上 V8 的字符串长度上限。而且即使不撞上限,构造一个几 MB 的中间字符串也会造成明显的 GC 压力和卡顿。
解决方案是一个只有 74 行的小类(tui-main-screen.ts:18):
const MAX_RENDER_WRITE_CHARS = 1024 * 1024; // 1 MiB
class BoundedTerminalWriter {
private buffer = "";
private writtenChars = 0;
private readonly write: (data: string) => void;
append(value: string): void {
let offset = 0;
while (offset < value.length) {
const capacity = MAX_RENDER_WRITE_CHARS - this.buffer.length;
if (capacity === 0) {
this.flush();
continue;
}
let end = Math.min(value.length, offset + capacity);
if (
end < value.length &&
value.charCodeAt(end - 1) >= 0xd800 && // 高代理对开始
value.charCodeAt(end - 1) <= 0xdbff &&
value.charCodeAt(end) >= 0xdc00 && // 低代理对
value.charCodeAt(end) <= 0xdfff
) {
end--; // 别把代理对劈开
}
if (end === offset) {
this.flush();
continue;
}
this.buffer += value.slice(offset, end);
offset = end;
if (this.buffer.length === MAX_RENDER_WRITE_CHARS) {
this.flush();
}
}
}
flush(): void {
if (!this.buffer) return;
this.write(this.buffer);
this.writtenChars += this.buffer.length;
this.buffer = "";
}
}三个设计点:
第一,append() 接受任意长度的字符串。 调用方不需要关心分块。fullRender() 里就是一串 output.append(...),中间没有一处关心块边界。
第二,切分点不会劈开代理对。 那段 charCodeAt 判断检查的是:切分点前一个码元在高代理区间(0xD800–0xDBFF)、切分点后一个码元在低代理区间(0xDC00–0xDFFF)。命中就把切分点往前挪一格。
这不是洁癖。一个被劈开的代理对会变成 U+FFFD 替换字符,直接显示成乱码。 而中文和 emoji 场景下代理对非常常见——一个中文字符就是一对。如果分块边界刚好落在中间,用户会看到随机的方块。顺带一提,这段代码也说明了为什么 pi-tui 在中文支持上是认真的:wordWrapLine() 里对 CJK 换行点有专门的判断(editor.ts:198)——中日韩字符之间任何位置都可以断行,不需要空白。
第三,调用方自己写 begin/end 序列。 源码注释说得很清楚:"Callers append synchronized-output begin/end sequences themselves; the final flush() writes any remainder, including the end sequence." 这个分工是对的——同步输出必须横跨整个帧,不能每块一次。
注意 TuiAltScreen 没有用这个类。它的输出天然有界——一个视口就 height 行,拼一个几百 KB 的字符串完全没问题。为不需要的场景加抽象是负资产。
Kitty 图片:两个额外的坑
内联图片是差分渲染最凶险的场景。图片在终端里不是字符,是外挂的位图,它的生命周期和你的行数组完全脱钩。
坑一:一张图片占多行,重绘区间必须扩出去。
Kitty 图形协议允许一张图片占多行。你的行数组里只有「图片的锚点行」有内容,其余被占的行在 render() 返回的数组里是空的。
所以差分算出来的 [firstChanged, lastChanged] 可能只落在锚点行上,而实际上整块图片区域都要重画。expandChangedRangeForKittyImages()(tui-main-screen.ts:210)负责把区间撑大,核心是这三行:
const blockEnd = i + this.getKittyImageReservedRows(lines, i) - 1;
if (i >= firstChanged || (i <= lastChanged && blockEnd >= firstChanged)) {
expandedFirstChanged = Math.min(expandedFirstChanged, i);
expandedLastChanged = Math.max(expandedLastChanged, blockEnd);
}它同时扫 previousLines 和 newLines。 因为重绘区间的两端都可能因为图片而扩展——旧帧里的图片块和新帧里的图片块都要算进去。只扫新帧会漏掉「旧图片被删除」这种情况。
注意它同时扫 previousLines 和 newLines。 因为重绘区间的两端都可能因为图片而扩展——旧帧里的图片块和新帧里的图片块都要算进去。只扫新帧会漏掉「旧图片被删除」这种情况。
坑二:协议不自动回收,重画前必须显式删除。
这是 Kitty 和 iTerm2 的分水岭。删除序列在 terminal-image.ts:276:
export function deleteKittyImage(imageId: number): string {
return `\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`;
}
export function deleteAllKittyPlacements(): string {
return `\x1b_Ga=d,d=a,q=2\x1b\\`;
}三个动词:d=I(按 image id 删)、d=A(删所有数据)、d=a(删所有 placement)。末尾的 q=2 是「静默抑制响应」——否则每删一张图终端就会回一堆确认序列。
TuiMainScreen 维护了一个 previousKittyImageIds: Set<number>,每次重绘前把区间内旧行的 id 收集起来发删除序列(tui-main-screen.ts:233):
const maxLine = Math.min(lastChanged, this.previousLines.length - 1);
for (let i = firstChanged; i <= maxLine; i++) {
for (const id of extractKittyImageIds(this.previousLines[i] ?? "")) ids.add(id);
}
return this.deleteKittyImages(ids);如果忘了这一步,后果不是「图片位置错了」,是「图片永远留在屏幕上」——旧图片和新内容叠在一起,再也擦不掉。
iTerm2 则相反,直接放弃。 README 写得很直接(README.md:688):
iTerm2's inline-image protocol does not provide operations to delete an existing placement or crop its source while scrolling. To prevent stale images from remaining over repainted content,
TuiAltScreenrenders image components as text placeholders in iTerm2.TuiMainScreencontinues to render iTerm2 inline images normally.
TuiAltScreen 的处理更彻底——启动时直接把 iTerm2 的图片能力关掉(tui-alt-screen.ts:356):
if (capabilities.images === "iterm2") {
this.savedCapabilities = capabilities;
setCapabilities({ ...capabilities, images: null });
this.invalidate();
}「协议做不到就别用」是一个合法的工程决策,比留一半功能在不支持的终端上炸掉要好。
TuiAltScreen 的差分
备用屏幕的差分逻辑更简单,因为它是固定高度的视口(tui-alt-screen.ts:1703):
const fullRedraw =
this.previousScreen.length === 0 || this.previousScreenWidth !== width || this.previousScreenHeight !== height;
const changedRows = screen.map((line, row) => line !== this.previousScreen[row]);全量重绘的条件只剩三个:首帧、宽度变了、高度变了。没有「变化在视口上方」的问题——视口就那么大,所有行都在视野里。
写入阶段同样跳过未变的行:
for (let row = 0; row < height; row++) {
if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue;
buffer += `\x1b[${row + 1};1H\x1b[2K${preparedKittyScreen.lines[row] ?? ""}`;
}用绝对光标定位(CSI row ; col H)而不是相对移动,因为视口里没有「当前在哪一行」的概念要维护——这比主屏幕那边处理 prevViewportTop 简单得多。
代价是主屏幕能省下的它省不下:主屏幕的流式追加只写末尾几行,备用屏幕的行号会随内容增长整体移动(因为它要维持 follow: "end"),很多行都会落到变更区间里。这也是它用 BoundedTerminalWriter 意义不大的原因——总量本来就和终端高度成正比。
19.4 全屏渲染与布局
布局帧
TuiAltScreen 支持两种模式。没有 setLayoutRoot() 时,它用一个隐式的 ScrollView 包住全部内容,保持「单文档滚动」的旧行为;有 setLayoutRoot() 时,走 renderLayoutFrame()(layout.ts):
const root = this.layoutRoot ?? this.implicitScrollView;
let nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender());渲染管线是严格有序的(tui-alt-screen.ts:1684 往后):
let nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender());
if (this.refreshSearch(nextLayout)) { // 搜索命中变了,重排一次
nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender());
}
// Resolve fake cursors before highlighting and compositing, which only track SGR codes
let screen = this.resolveFakeCursors(nextLayout.lines.map((line) => line.replace(OSC133_ZONE_PREFIX, "")));
screen = this.applySearchHighlights(screen, nextLayout);
screen = this.compositeScrollToEndIndicator(screen, nextLayout, width);
screen = this.compositeOverlays(screen, width, height);
if (screen.length > height) screen = screen.slice(screen.length - height);
screen = this.applySelection(screen, nextLayout);
screen = this.compositeFlashes(screen, width, height);这张管线本身就是一份设计文档:光标先解析(因为后面的高亮和合成只认 SGR 码),然后搜索高亮、滚动提示、overlay 合成、裁剪到视口高度、选区、闪烁。每一步的输入都是上一步的输出。
其中两个条件渲染值得注意:
if (screen.length > height) screen = screen.slice(screen.length - height)——保留最后height行。这保证了follow: "end"的语义;if (this.refreshSearch(nextLayout)) { ...再排一次... }——搜索面板可能因为匹配变化而改变高度,那布局就得重算。这是唯一的「重入」点。
最后一步之后才做差分比较和写入(:1698):
screen = this.applyLineResets(screen).map((line) => {
if (isImageLine(line) || visibleWidth(line) <= width) return line;
return sliceByColumn(line, 0, width, true); // 这里裁,不抛错
});注意和 TuiMainScreen 的差别:备用屏幕对超宽行是裁剪,主屏幕是抛错。 同一个包里两种策略,因为备用屏幕的视口内容在视口内,超宽的行裁掉即可;主屏幕的行直接写进用户的滚动缓冲,默默裁掉会让用户丢内容,静默失败比大声崩溃更糟。
VStack / HStack / ScrollView
StackLayoutEntry 的六个字段(layout-node.ts:10):
用法(README.md:132):
const transcript = new Container();
const editorAndFooter = new VStack([editor, new Text("status")]);
if (isViewportTUI(tui)) {
tui.setLayoutRoot(new VStack([
{
component: new ScrollView(transcript, {
follow: "end",
primary: true,
overscroll: "chain",
}),
basis: 0, // 基础 0,全部高度靠 grow
grow: 1,
minSize: 1,
},
{
component: editorAndFooter,
basis: "auto", // 按内容高度
shrink: 1,
minSize: 1,
},
]));
}这就是 Pi 全屏模式的实际布局:一个 follow: "end" 的主 ScrollView 吃掉所有剩余高度,下面一个按内容定高、允许收缩的编辑区。
isViewportTUI(tui) 是个类型守卫——它让这段代码在主屏幕模式下也能安全跑(那时 setLayoutRoot 不存在,VStack 的语义也不适用)。
ScrollViewOptions 的默认值在构造器里(components/scroll-view.ts:41):
this.followEnd = (options.follow ?? "none") === "end";
this.primary = options.primary ?? false;
this.overscroll = options.overscroll ?? "chain";
this.currentScrollbar = options.scrollbar ?? "hidden";
this.scrollbarHideDelayMs = Math.max(0, Math.floor(options.scrollbarHideDelayMs ?? 1000));四个默认值里,follow 默认 "none" 是保守的——不主动跟随。主 ScrollView 必须显式写 follow: "end",否则新内容会把用户正在看的位置顶走。
overscroll 默认 "chain" 也是个好默认值:滚到顶再往上滚,把剩余 delta 交给外层滚动视图。README 说未处理的滚轮事件「滚最近的 ScrollView 并把没用完的 delta 链式传下去」(README.md:292)。
Overlay
showOverlay(component, options) 的 OverlayOptions 有二十来个字段,但真正需要理解的是定位解析顺序(README.md:239):
minWidth作为宽度的下限,在宽度算完之后应用;- 位置:绝对
row/col> 百分比row/col>anchor; margin把最终位置夹回终端边界内;visible回调决定是否渲染,每帧都调。
九个锚点值(center、四个角、四个边中点)定义在 tui.ts:214。
Overlay 是合成而不是替换。compositeTuiLine()(tui.ts:426)把 overlay 行拼进基础行的指定列区间,用 SEGMENT_RESET 隔开三段,并且在结果超宽时按列裁切——图片行例外,直接原样返回(tui.ts:433)。在图片上叠 overlay 会产生不可预测的结果,所以直接放弃。
Overlay 的焦点语义有一处很容易踩(README.md:220):
handle.unfocus()— Focus loses; TUI falls back to another visible capturing overlay or the previous focus target.handle.unfocus({ target: null })— Focus loses; no component receives input until focus is set again.
unfocus() 和 unfocus({ target: null }) 完全不同。 前者把焦点交还给别的组件,后者让焦点彻底空掉——直到有东西重新 setFocus,所有按键输入都进黑洞。写「模态对话框」时通常要后者,写「临时弹出面板」时通常要前者。
19.5 键盘与 Kitty 协议
按键标识
keys.ts 有 1455 行,因为它同时处理三代输入协议。它的公共 API 很小:
matchesKey(data: string, keyId: KeyId): boolean;
parseKey(data: string): string | undefined;
const Key = { enter: "enter", escape: "escape", ctrl: (c: string) => `ctrl+${c}`, ... };
isKeyRelease(data: string): boolean;
isKeyRepeat(data: string): boolean;Key 是一个普通对象,键名和值一一对应(keys.ts:163),覆盖特殊键、符号键、字母数字和带修饰键的构造器。KeyId 是类型层面的联合 BaseKey | ModifiedKeyId<BaseKey>(keys.ts:152),所以 Key.ctrl("c") 有自动补全,返回值也确实符合 KeyId。
三种取模态键的方式:
if (matchesKey(data, Key.ctrl("c"))) { /* ... */ }
if (matchesKey(data, "ctrl+shift+p")) { /* 字符串字面量也行 */ }
if (matchesKey(data, Key.shift("tab"))) { /* ... */ }Kitty 键盘协议
终端按键的历史包袱在于同一个物理键在不同终端上编码不同。「Shift+Tab」在没有 Kitty 协议的终端上是一个转义序列,识别它要靠超时和模式启发式。
Kitty 键盘协议把这件事解决了:它给你一个统一的、带修饰键编码的格式。keys.ts 通过一个模块级开关支持它(keys.ts:27)——setKittyProtocolActive() / isKittyProtocolActive()。注释说明了谁来设这个开关:"Called by ProcessTerminal after detecting protocol support."
keys.ts 顶部列了它要处理的坑(keys.ts:12):
Symbol keys are also supported, however some ctrl+symbol combos overlap with ASCII codes, e.g. ctrl+[ = ESC.
Ctrl+[ 和 ESC 是同一个字节。 终端无法区分「用户按了 ESC」和「用户按了 Ctrl+[」。keys.ts 的处理方式是:这些冲突组合仍然可以用 * 语法表示 ctrl+shift 组合。这是一个协议层面的固有限制,不是实现缺陷。
parseKey() / matchesKey() 因此要同时走两条路:先试 Kitty 格式,失败再退回传统序列表。这也是为什么这个文件有 1455 行。
键位是数据不是代码
keybindings.ts 的做法值得单独说一句:键位绑定是一张数据表,不是一堆 if。
"tui.editor.deleteToLineStart": { defaultKeys: "ctrl+u", description: "Delete to line start" },
"tui.editor.deleteToLineEnd": { defaultKeys: "ctrl+k", description: "Delete to line end" },
"tui.editor.yank": { defaultKeys: "ctrl+y", description: "Yank" },
"tui.editor.yankPop": { defaultKeys: "alt+y", description: "Yank pop" },
"tui.editor.undo": { defaultKeys: "ctrl+-", description: "Undo" },编辑器里判断按键变成了一句查询(components/editor.ts:829):
if (kb.matches(data, "tui.editor.deleteToLineEnd")) {
this.deleteToEndOfLine();
return;
}好处有三个:
- 用户可以改。
KeybindingsManager提供运行时重绑定,扩展和用户配置走同一张表; - 冲突可以检测。
KeybindingConflict类型专门为此存在; - 帮助信息自动生成。每个绑定自带
description,不用另外维护一份文档。
键位 ID 分四个命名空间(keybindings.ts:9 起):
altScreen 那组的默认值值得看几个(keybindings.ts:192):
"tui.altScreen.search": { defaultKeys: "ctrl+shift+f", description: "Search the primary scroll view" },
"tui.altScreen.searchNext": { defaultKeys: ["enter", "ctrl+g"], description: "Select the next search match" },
"tui.altScreen.searchPrevious": { defaultKeys: ["shift+enter", "ctrl+shift+g"], description: "Select the previous search match" },
"tui.altScreen.top": { defaultKeys: "ctrl+home", description: "Scroll viewport to top" },
"tui.altScreen.bottom": { defaultKeys: "ctrl+end", description: "Scroll viewport to bottom" },语义提示的跳转绑在 ctrl+shift+up/down 上(README 说它能匹配常见的终端提示符导航快捷键),实现方式是识别 OSC 133 的语义区域标记。完整的默认键位表属于 03 交互式会话 的范围,本文不复述。
19.6 编辑器
2473 行里有什么
components/editor.ts 是这个包里最大的文件,2473 行。它的类结构大概是这样:
export class Editor implements Component, Focusable {
private state: EditorState = { lines: [""], cursorLine: 0, cursorCol: 0 };
focused: boolean = false;
private scrollOffset = 0;
private autocompleteProvider?: AutocompleteProvider;
private pastes = new Map<number, string>(); // 大段粘贴的内容
private pasteCounter = 0;
private history: string[] = [];
private historyIndex = -1;
private killRing = new KillRing();
private undoStack = new UndoStack<EditorSnapshot>();
private jumpMode: "forward" | "backward" | null = null;
private preferredVisualCol: number | null = null; // 上下移动的粘性列
private snappedFromCursorCol: number | null = null;
// ...
}几个字段能直接说明它解决过什么真实问题。
preferredVisualCol——「粘性列」。上下移动光标时,你希望光标回到视觉上同一列,而不是逻辑上的同一列。视觉列和逻辑列在软换行之后是两回事:逻辑第 30 列可能在视觉第 3 行的末尾。这个字段记着「用户上次所在的视觉列」,让上下移动看起来是自然的。
snappedFromCursorCol 的注释是这个文件里最好的一段工程说明(editor.ts:360):
// When the cursor is snapped to the start of an atomic segment, e.g. a
// paste marker, cursorCol no longer reflects where the cursor would have
// landed. This field stores the pre-snap cursorCol so that the next
// vertical move can resolve it to a visual column on whatever VL it belongs to.当光标被吸附到「原子段」的开头时,cursorCol 已经不再表示它本来该在的位置了。 一个原子段是什么?是一个粘贴标记 [paste #3 +500 lines]——用户按左右键时它整体跳过,不会停在中间。于是 cursorCol 变成一个失真的值。这个字段存下吸附前的真实值,让下一次纵向移动能把它还原成视觉列。
一个粘贴标记能被 Intl.Segmenter 切成 17 个字素,各有各的 index。「原子」意味着这 17 个必须被当成 1 个处理。代码里到处都是 isPasteMarker(segment) 的判断,每个地方都在为这个抽象付出一点代价。
软换行
wordWrapLine(line, maxWidth, preSegmented?)(editor.ts:124)是一个 90 行的换行算法。它做三件事:
- 优先在空白后断行,记录「最后一个可断点」;
- 没有可断点时强制在当前位置断开——处理超长的单词或宽字符;
- CJK 字符之间任意位置都可断。
第三点的判断在这段:
// Record wrap opportunity: whitespace followed by non-whitespace
// (multiple spaces join; the break point is after the last space),
// or at a boundary where either side is CJK (CJK allows breaking
// between any adjacent characters).
const next = segments[i + 1];
if (isWs && next && (isPasteMarker(next.segment) || !isWhitespaceChar(next.segment))) {
wrapOppIndex = next.index;
wrapOppWidth = currentWidth;
} else if (!isWs && next && !isWhitespaceChar(next.segment)) {
const isCjk = !isPasteMarker(grapheme) && cjkBreakRegex.test(grapheme);
const nextIsCjk = !isPasteMarker(next.segment) && cjkBreakRegex.test(next.segment);
if (isCjk || nextIsCjk) {
wrapOppIndex = next.index;
wrapOppWidth = currentWidth;
}
}还有一条更细的:比一个原子段还宽的东西要按字素重新折(editor.ts:173):
if (gWidth > maxWidth) {
// Single atomic segment wider than maxWidth (e.g. paste marker
// in a narrow terminal). Re-wrap it at grapheme granularity.
// The segment remains logically atomic for cursor movement / editing —
// the split is purely visual for word-wrap layout.注释里那句区分是关键:折行是纯视觉的,逻辑上它仍然是一个原子。 「逻辑」和「视觉」在这个包里是贯穿始终的一对概念——软换行让一行逻辑文本变成多行视觉行,光标位置、点击命中、选择范围全都得在这两套坐标之间来回换算。
可见高度
编辑器不会长到无限高。render() 的做法是(editor.ts:536):
// Calculate max visible lines: 30% of terminal height, minimum 5 lines
const terminalRows = this.tui.terminal.rows;
const maxVisibleLines = Math.max(5, Math.floor(terminalRows * 0.3));30%,下限 5 行。 比例而不是绝对值,是为了在不同终端高度下都保持合理——24 行的终端给 7 行,48 行给 14 行。
注意这里用到了构造时传入的 tui。Editor 的构造函数签名是 constructor(tui: TUI, theme: EditorTheme, options)——tui 是必需的,因为没有它就不知道终端有多少行。这也是 README 特别标注的原因(README.md:452):
const editor = new Editor(tui, theme, options?); // tui is required for height-aware scrolling
滚动边框也做得比较细(editor.ts:279 的 createScrollBorder):宽度够时居中显示 ↑ 3 more,不够时退化成 ─── ↑ 3 more,再不够就按列裁切。三级降级保证任何窄终端下都有一个可读的提示。
大段粘贴
粘贴超过阈值会被折叠成标记(editor.ts:1301):
// Check if this is a large paste (> 10 lines or > 1000 characters)
const totalChars = filteredText.length;
if (pastedLines.length > 10 || totalChars > 1000) {
this.pasteCounter++;
const pasteId = this.pasteCounter;
this.pastes.set(pasteId, filteredText);
const marker = pastedLines.length > 10
? `[paste #${pasteId} +${pastedLines.length} lines]`
: `[paste #${pasteId} ${totalChars} chars]`;两个阈值:超过 10 行或超过 1000 字符。超过 10 行显示行数,否则显示字符数。
内容存在 pastes Map 里,不在文本里。 提交时 getExpandedText() 把标记还原成真实内容。
这个设计解决的是一个具体的灾难场景:你往编辑器里粘一个 2000 行的文件,编辑器要把它渲染出来,光标要能在里面移动,自动补全要扫它,然后 Pi 把它整个塞进上下文里。折叠成标记之后,屏幕上只有一行 [paste #1 +2000 lines],光标跳过它,展开发生在最后一刻。
标记的正则是(editor.ts:31):
const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;isPasteMarker() 要求整个字素长度 ≥ 10 且完全匹配(editor.ts:39)——避免把用户真的打出来的 [paste #1] 误判成标记。删除一个标记时还会把后续标记重新编号(editor.ts:1414)。
handlePaste() 开头还有一段针对具体终端 bug 的修复(editor.ts:1267):
// Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
// control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
// (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so
// the per-char filter below preserves newlines instead of stripping ESC and
// leaking the printable tail (e.g. "[106;5u") into the editor.tmux 的弹窗功能会把粘贴内容里的控制字节重新编码。如果不还原,ASCII 32 以下的全被过滤掉,[106;5u 这种可打印尾巴会漏进编辑器里。这是一个只在 tmux 弹窗里粘贴才会复现的 bug。
其它几块
历史记录用 historyIndex: -1 表示「没在浏览」,0 是最近一条。浏览历史时先存一份草稿(historyDraft),退出浏览时恢复用户正在编辑的内容。
Kill ring(kill-ring.ts)是个 46 行的环形缓冲区,源码注释自己就标了它做的是经典编辑器里的 kill/yank 语义。连续删除会累积成一条(前删就前置、后删就后接),支持 yank(贴最近)和 yank-pop(在旧条目间循环),绑在 ctrl+y 和 alt+y(keybindings.ts:140)。
Undo(undo-stack.ts)是 28 行的泛型栈,push 时做 structuredClone 深拷贝,弹出来的直接返回不再克隆。编辑器往里塞的是 EditorSnapshot——文本状态加上粘贴注册表(editor.ts:225)。只存文本会导致撤销一个大粘贴之后内容丢了,下次展开标记变成空。
自动补全的默认触发字符是 ["@", "#"](editor.ts:254),防抖 20ms(editor.ts:253),autocompleteMaxVisible 默认 5 并夹在 [3, 20](editor.ts:380)。夹上下限是因为小于 3 行没法用、大于 20 行会吃掉整个视口。Tab 触发补全,斜杠命令走另一条路(editor.ts:2266 的 handleTabCompletion() 和 :2279 的 handleSlashCommandCompletion())。
光标与 IME
Focusable 接口(README README.md:306 详述)解决了中文输入法的一个具体问题:候选词窗口要贴着光标弹,而 TUI 的硬件光标默认是隐藏的。
协议是这样:组件在渲染输出里放一个零宽的 CURSOR_MARKER(一个 APC 转义序列,tui.ts:198),紧挨着它放一个用反显画出来的「假光标」。TUI 拿到渲染结果后扫一遍 CURSOR_MARKER,把硬件光标移动到那个位置(虽然看不见),于是 IME 候选窗出现在正确的地方。
class MyInput implements Component, Focusable {
focused: boolean = false;
render(width: number): string[] {
const marker = this.focused ? CURSOR_MARKER : "";
return [`> ${beforeCursor}${marker}${renderFakeCursor(atCursor)}${afterCursor}`];
}
invalidate(): void {}
}硬件光标默认仍然隐藏,README 解释了原因(README.md:334):这样假光标照常渲染,同时把硬件光标定位好,让跟踪隐藏光标的终端也能显示候选窗。有些终端必须光标可见才能定位 IME,那就用构造参数 showHardwareCursor 或 setShowHardwareCursor(true) 打开。
还有一个容易漏的规则:容器组件包着 Input 或 Editor 时必须自己实现 Focusable 并把 focused 透传给子组件(README.md:336)。
class SearchDialog extends Container implements Focusable {
private searchInput: Input;
private _focused = false;
get focused(): boolean { return this._focused; }
set focused(value: boolean) {
this._focused = value;
this.searchInput.focused = value; // 必须透传
}
}不这么做的后果是明确的:中文、日文、韩文的 IME 候选窗会弹在错误的位置。 而这是每一个写搜索对话框扩展的人都会撞上的问题。
19.7 性能取舍
渲染节流
TuiBase.requestRender()(tui.ts:1001)是所有渲染请求的唯一入口:
requestRender(force = false): void {
if (force) {
this.resetRenderState();
this.requestImmediateRender();
return;
}
if (this.renderRequested) return; // 已经在队列里了
this.renderRequested = true;
process.nextTick(() => this.scheduleRender());
}三道机制叠在一起:
第一道,renderRequested 标志位。 同一 tick 内多次 requestRender() 只会排一次。
第二道,16 ms 最小间隔(tui.ts:518):
private static readonly MIN_RENDER_INTERVAL_MS = 16;scheduleRender() 算出还差多久才够 16 ms,用 setTimeout 补上。16 ms 就是 60 Hz——比你的终端刷新率更快,意味着渲染永远追不上输入,不会丢帧。
第三道,force 通道。 requestRender(true) 跳过节流立即渲染,并且重置渲染状态(resetRenderState() 把 previousWidth 设成 -1 之类,强制下一次走全量重绘)。
注释里解释了抢占逻辑(tui.ts:1020):
// A previously queued scheduleRender() can create a timer before this
// callback runs. User input must preempt that throttled frame.
this.cancelRenderTimer();用户输入必须抢占节流帧。 敲键之后等 16 ms 才看到回显,手感会明显发木——一个正在流式输出的 spinner 会让每一帧都被推迟。
缓存的层次
包里反复出现的模式是「按宽度缓存」:命中条件永远是 cachedWidth === width,invalidate() 把缓存打回 undefined。Markdown 组件有渲染缓存,Container 缓存鼠标布局,布局帧有 renderCache: Map<Component, Map<number, string[]>>(layout.ts 的 LayoutContext)——按组件、按宽度两级缓存。
但缓存的粒度选择是有代价的。colors.ts 的 README 警告最直接(README.md:111):
Conversions are not cached. OKLCH colors, especially ones outside the sRGB gamut, are more expensive to convert than sRGB or indexed colors. For colors used on every render, convert once and reuse the result.
颜色转换没有缓存。 OKLCH 到 sRGB 要解三次方程,每帧转一遍一个 OKLCH 主题的界面会有可感知的开销。解法是在 render 路径外面算好,存成 sRGB 复用。
Markdown 组件有渲染缓存,Container 缓存鼠标布局,布局帧有 renderCache: Map<Component, Map<number, string[]>>(layout.ts 的 LayoutContext)——按组件、按宽度两级缓存。
但缓存的粒度选择是有代价的。README 给的建议是(packages/coding-agent/docs/tui.md:96):
Theme tokens are converted once per theme; compute concrete colors outside the render path when possible.
以及 colors.ts 里那句更直接的警告(README.md:111):
Conversions are not cached. OKLCH colors, especially ones outside the sRGB gamut, are more expensive to convert than sRGB or indexed colors. For colors used on every render, convert once and reuse the result.
颜色转换没有缓存。 OKLCH 到 sRGB 要解三次方程,每帧转一遍一个 OKLCH 主题的界面会有可感知的开销。解法是在 render 路径外面算好,存成 sRGB 复用。
图片缓存的上限
TuiAltScreen 对离屏 Kitty 图片设了三个上限(tui-alt-screen.ts:77):
const MAX_CACHED_OFFSCREEN_KITTY_IMAGES = 16;
const MAX_CACHED_OFFSCREEN_KITTY_TRANSMISSION_BYTES = 32 * 1024 * 1024;
const MAX_CACHED_OFFSCREEN_KITTY_DECODED_BYTES = 64 * 1024 * 1024;数量上限 16 张,同时限制传输字节 32 MiB、解码字节 64 MiB。滚动时图片要缓存起来(滚回去时不用重新上传),但缓存必须有界。 一个只限数量不限字节的上限挡不住「16 张 4K 全屏图」。
一个必须说的取舍
有一件事这个包没有做:它不保证在所有终端上正确工作。
具体说:\x1b[?2026h 同步输出在老终端上被忽略;Kitty 图形协议在别的终端上不存在;Kitty 键盘协议不支持时退回启发式识别;tmux 下关掉 all-motion;iTerm2 下干脆不画图片。
这个包选择的是「探测能力,能用就用,不能用就降级,绝不假装」。 TerminalCapabilities + setCapabilityOverrides() 这套机制就是为这个存在的——可以在测试里伪造能力,也可以强制关掉某个特性。
对比一下另一个常见做法:在不支持的终端上照样发序列,终端把不认识的字节当文本打印出来,用户屏幕上出现 \x1b_Ga=T,f=100;AAAA。pi-tui 的选择显然更好,代价是代码里到处都有 if (protocol === "kitty") 这样的分支。
调试开关
三个环境变量,都在源码里直接可见:
第三个最有价值。它的日志内容是:
[ISO timestamp] fullRender: terminal width changed (120 -> 100) (prev=412, new=430, height=40)为什么全量重绘、当时有多少行——一清二楚。「为什么我的 TUI 一直闪」这类问题的答案通常就在这里。
19.8 事实来源
本文所有数字都可以在仓库里复算:
完整事实表(含每条断言的推导命令)见专栏仓库的 research/pi-agent/FACTS.md。
三篇架构读完,可以回到 01 架构地图 对照着看三个包各承担什么。想知道这些终端细节在用户侧长什么样,回 03 交互式会话;想知道终端输出被 agent 消化成什么,回 18 pi-agent-core。