跳到主要内容

pi-tui:差分渲染的终端 UI 库

30 秒导读: Pi 是一个跑在终端里的编码 agent。它的界面(输入框、Markdown 输出、选择菜单、加载动画……)不是用别人的框架画的,而是自造了一个 TUI 库 @earendil-works/pi-tui。这一章讲这个库本身:它把整个界面表达成"一组字符串行",每次刷新时只重画和上一帧不同的那几行(差分渲染),从而在流式输出、动画光标下不闪屏。这是全书最外围、最独立的一章——不依赖前四章,反过来第 04 章的交互模式用的就是这里的组件。


1. 这是什么(零基础也能懂)

一句话定义: pi-tui 是一个用差分渲染画终端界面的独立库——你给它一棵组件树,它负责把树渲染成文本,并聪明地只更新屏幕上变化的部分。

它解决什么问题。 终端本质上是一块字符网格,程序通过写 ANSI 转义序列(以 \x1b[ 开头的控制码,如"光标上移 3 行""清除本行""反色")来控制它。最朴素的刷新办法是每帧清屏 + 重画全部——但这会闪烁,而且当内容有几百行、还夹着流式 token 时,重画量巨大。pi-tui 的价值就是把"重画"变廉价

为什么一个 agent CLI 要自造 TUI。 有现成的 inkblessed 等库,但 agent 的界面有几个刁钻需求:

  • 输出里混着中文/emoji(宽字符)彩色/超链接(ANSI 码),朴素的"按字符数截断"会把界面撑爆或错位。
  • LLM 是流式吐字的,界面每秒要刷新很多次,必须避免整屏重绘。
  • 要支持终端内嵌图片(截图、图表)、IME 输入(中日韩候选框定位)、Kitty 键盘协议(区分 Shift+EnterEnter)——这些库大多支持不全。

于是 Pi 把渲染引擎做成一个零业务逻辑、可单独发布的包,自己吃透这些底层细节。

用起来什么样。 使用者组装一棵组件树,交给 TUI 驱动:

// 示意,非源码
const tui = new TUI(new ProcessTerminal()); // 绑定真实终端
const editor = new Editor(tui, theme, options); // 一个多行编辑器组件
tui.addChild(new Markdown("# Hello", theme)); // 一段 Markdown
tui.addChild(editor);
tui.setFocus(editor); // 键盘输入交给 editor
tui.start(); // 进入 raw 模式,开始渲染循环
editor.onSubmit = (text) => { /* 用户按了回车 */ };

一句话直觉/类比。 把它想成终端界面的"React 但只有一个 diff 维度":组件负责 render(width) → string[](把自己画成若干行),TUI 负责比较新旧两组行、只把差异写回屏幕。React diff 的是虚拟 DOM 树,pi-tui diff 的是一维的行数组——终端就是一叠行,这个抽象刚好合身。


2. 顶层全景(它大概怎么转)

这一节讲这个库由哪些部件组成、一帧是怎么流动的。

2.1 部件地图

部件职责文件
TUI渲染循环 + 差分算法 + overlay + 焦点/输入分发packages/tui/src/tui.ts:295
Component 接口所有组件的契约:render(width)/invalidate()packages/tui/src/tui.ts:64
Terminal 抽象底层终端读写:raw 模式、stdin、尺寸、光标packages/tui/src/terminal.ts:52
utils.ts宽字符与 ANSI 的度量/切片/换行(渲染的数学)packages/tui/src/utils.ts
组件库Box/Text/Markdown/Input/Editor/SelectList/Loader/Imagepackages/tui/src/components/
输入解析按键 → 语义键名:keys.ts/keybindings.ts/native-modifiers.tspackages/tui/src/keys.ts
terminal-image.tsKitty/iTerm2 图形协议编码、能力探测packages/tui/src/terminal-image.ts
terminal-colors.tsOSC 11 背景色 / 颜色主题探测的解析packages/tui/src/terminal-colors.ts

2.2 一帧的数据流

一帧从"有东西变了"开始,到"屏幕上只改了那几行"结束:

组件状态变化 ┌──────────── TUI.doRender() ───────────┐
(setText / 按键 / 动画帧) │ │
│ │ 1. render(width) 收集所有组件的行 │
▼ │ ↓ │
requestRender() ──合并到下一 tick──▶ │ 2. 合成 overlay(菜单/弹层叠上去) │
(16ms 节流) │ ↓ │
│ 3. 抽出光标标记(给 IME 定位) │
│ ↓ │
│ 4. 与 previousLines 逐行比较, │
│ 求出 firstChanged..lastChanged │
│ ↓ │
│ 5. 只把这段行写回终端(移动光标+清行) │
└────────────────┬───────────────────────┘

terminal.write(buffer)

关键点:第 4、5 步是这个库的心脏。它 diff 组件树,而是把树 render 成一维行数组,再和上一帧的行数组做逐行字符串比较。因为终端天然是一叠行,这个粗糙但极快的 diff 就够用了。

主线走一遍(高层): 用户在编辑器里敲一个字 → Editor.handleInput 改内部文本、调 tui.requestRender() → 下一个事件循环 tick 触发 doRender() → 整棵树重新 render() 出比如 200 行 → 和上一帧比较发现只有编辑器那 1 行变了 → TUI 只把光标移到那行、清行、重写那 1 行。屏幕其余 199 行纹丝不动,没有闪烁。


3. 核心机制之一:差分渲染

它要解决的小问题: 界面每秒刷新很多次,但每次通常只有一两行真的变了(光标闪、spinner 转、流式追加一行)。怎么只更新那部分?

思路/直觉: 保存上一帧渲染出的行数组 previousLines;新一帧渲染出 newLines;从上往下、从下往上找出"第一处不同"和"最后一处不同",只重写这个闭区间。其余的行终端里已经是对的,一个字节都不用发。

3.1 找出变化区间

doRender() 里这段逐行扫描就是 diff 的全部核心(packages/tui/src/tui.ts:1370-1388):

// 真实源码节选,tui.ts:1371
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;
}
}

它把"哪几行需要动"缩小成一个区间。随后只对 firstChanged..lastChanged 生成写入序列(tui.ts:1493 的渲染循环),而不是从头写到尾。注释里点明了动机:"减少闪烁——当只有一行变化时(如 spinner 动画)"。

3.2 光标怎么走到那一行

终端的光标是相对定位的("上移 N 行""下移 N 行"),没有"跳到第 137 行"这种绝对命令(在滚动区里)。所以 TUI 必须自己记住光标现在在第几行(hardwareCursorRow),算出到目标行的差值,发 \x1b[<n>B(下移)或 \x1b[<n>A(上移),再 \r 回到行首(tui.ts:1481-1488)。这套"记账"是差分渲染能工作的前提:diff 告诉你改哪行,光标记账告诉你怎么走过去

3.3 什么时候退化成整屏重绘

差分只在"版面结构没变"时安全。有几种情况必须放弃 diff、走 fullRender(true)(清屏+重画):

触发条件为什么必须整屏重绘位置
首帧没有上一帧可比tui.ts:1336
终端变宽/变窄换行位置全变了,旧行作废tui.ts:1343
终端变高(非 Termux)可视窗口偏移,行号对不上tui.ts:1352
变化点在可视窗口上方差分只能改可视区内的行tui.ts:1455
requestRender(force)主动要求从头来(如主题切换)tui.ts:712

fullRender\x1b[2J\x1b[H\x1b[3J(清屏、回原点、清 scrollback)彻底重来,并用 \x1b[?2026h/l 把整批写入包在同步输出里,让终端一次性刷新、避免中间态被看到(tui.ts:1286)。差分路径同样用这对同步标记(tui.ts:1463)。

3.4 节流:一帧最多多久一次

无节制地重画会浪费 CPU。requestRender() 把请求合并到 process.nextTick,再由 scheduleRender() 保证两帧间隔至少 MIN_RENDER_INTERVAL_MS = 16 毫秒(约 60fps 上限,tui.ts:309tui.ts:741)。也就是说:一个 tick 内调 100 次 requestRender() 也只会渲染一次。

原理演示(把差分想清楚):

// 示意,非源码:差分渲染的最小内核
function paint(newLines: string[]) {
let first = -1, last = -1;
for (let i = 0; i < Math.max(newLines.length, prev.length); i++) {
if ((newLines[i] ?? "") !== (prev[i] ?? "")) {
if (first === -1) first = i; // 第一处不同
last = i; // 最后一处不同
}
}
if (first === -1) return; // 全等,啥都不发
moveCursorTo(first); // 相对移动光标到 first 行
for (let i = first; i <= last; i++) {
write("\x1b[2K"); // 清本行
write(newLines[i]); // 重写本行
if (i < last) write("\r\n");
}
prev = newLines; // 记住这一帧
}

真实实现比这多了 overlay 合成、宽字符校验、Kitty 图片保留行、追加优化和光标记账,但骨架就是这样。


4. 核心机制之二:宽字符与 ANSI 的精确度量

它要解决的小问题: 终端里"一个字符占几列"不等于"字符串有几个 code unit"。中文/日文/全角占 2 列,组合 emoji(带肤色、ZWJ)占 2 列但由多个 code point 组成,ANSI 颜色码占 0 列(它是控制码,不显示)。如果按 .length 截断或换行,界面必然错位甚至崩。

这是差分渲染能成立的地基:diff 比较的是行字符串,但"这行有没有超出终端宽度""从第几列切"全靠一套可视宽度函数。

4.1 visibleWidth:一行到底占几列

visibleWidth(str)(utils.ts:216)是全库最基础的函数。它先走快路径(纯 ASCII 直接返回 length),否则:

  1. \t 当 3 列;
  2. 一遍扫描剥掉所有 ANSI/OSC/APC 转义序列(用 extractAnsiCode,utils.ts:290,能识别 ESC[…m 样式码、ESC]… 超链接、ESC_… 光标标记三类);
  3. Intl.Segmenter字形簇(grapheme) 切分,对每簇算宽度 graphemeWidth(utils.ts:167)——零宽簇 0 列、RGI emoji 2 列、东亚全角 2 列。
  4. 结果进一个 512 条上限的 LRU 缓存,因为同样的行会被反复度量。

graphemeWidth 里有个性能巧思:emoji 判定用的 \p{RGI_Emoji} 正则很贵,所以先用便宜的 couldBeEmoji 预筛(检查码点区间和是否含 VS16),预筛不过就不跑贵正则(utils.ts:27utils.ts:178)。

4.2 sliceByColumn:按"列"而不是"字符"切

sliceByColumn(line, startCol, length, strict)(utils.ts:1057)按可视列区间切一行,同时保留跨越边界的 ANSI 样式——否则切一半会把颜色码丢掉、后面全串色。strict=true 时,若某个宽字符会跨过右边界,就整个丢弃(不允许半个字),这在 overlay 合成时用来严防超宽。

4.3 wrapTextWithAnsi:带颜色的自动换行

wrapTextWithAnsi(text, width)(utils.ts:694)把长文本按 width 折行,同样在折行时保持 ANSI 状态跨行延续。Text 组件、Markdown 组件都靠它把段落塞进列宽。

4.4 最后一道保险:超宽即崩

差分写行前,TUI 会断言每行 visibleWidth(line) <= width;一旦有自定义组件没截断、渲染出超宽行,TUI 会把所有行 dump 到 ~/.pi/agent/pi-crash.log、清理终端状态、然后抛错(tui.ts:1520-1546)。设计者宁可显式崩溃并留下证据,也不让界面悄悄错位——因为超宽会真的把 diff 的光标记账搞乱、雪崩成满屏乱码。

4.5 一个易忽略的正确性细节:normalizeTerminalOutput

写每行前,非图片行都要过一遍 normalizeTerminalOutput(tui.ts:1095applyLineResets 调用 utils.ts:282)。它专门处理泰语/老挝语的预组合 AM 元音(/):这些字符在差分重绘时某些终端会渲染出残留脏格,于是替换成等宽的兼容分解形式。这是那种"没踩过坑绝对想不到"的细节,直接印证了差分渲染在真实终端上的脆弱性。每行还会补一个 \x1b[0m\x1b]8;;\x07 复位(SEGMENT_RESET,tui.ts:1093),防止样式/超链接泄漏到下一行。


5. 核心机制之三:组件模型

它要解决的小问题: 界面要能拼装(一个框里放文本、文本上叠背景色、列表里放条目),但底层只认"一叠行"。怎么在"行数组"之上搭出可复用组件?

思路/直觉: 定一个极简契约——组件就是一个能把自己画成若干行的东西Component 接口只要求两个方法(tui.ts:64):

// 真实源码,tui.ts:64
export interface Component {
render(width: number): string[]; // 给我宽度,还你若干行
handleInput?(data: string): void; // 可选:有焦点时收键盘输入
wantsKeyRelease?: boolean; // 可选:是否要收 Kitty 的按键松开事件
invalidate(): void; // 清缓存,强制重画
}

就这么简单。render纯函数式的:同样的宽度 + 同样的内部状态 → 同样的行。这让上层缓存(很多组件缓存 {width, text} → lines,比如 TextBoxImage)和差分成为可能。

5.1 组合靠 Container

Container(tui.ts:256)是"有孩子的组件":它的 render 就是把每个孩子的行竖着拼起来(tui.ts:280)。TUI 本身就 extends Container——所以整个界面是一棵容器树,根就是 TUI,render 一路递归下去把树摊平成行数组。

TUI (Container)
├── Markdown → ["# Hello", "world", ...]
├── Box (padding) → [" ", " foo ", " "]
└── Editor → ["┌──────┐", "│ cursor│", "└──────┘"]
摊平拼接 ▼
["# Hello","world",...," "," foo "," ","┌──────┐",...]

5.2 库里现成的组件

组件干什么文件
Text多行文本 + 自动换行 + 可选背景components/text.ts:7
Box给孩子加内边距和背景色components/box.ts:14
Markdown渲染 Markdown(标题/列表/代码块/表格)components/markdown.ts:110
Input单行输入框(带水平滚动、假光标)components/input.ts:19
Editor多行编辑器(换行、滚动、自动补全、撤销)components/editor.ts:252
SelectList可过滤/滚动的选择菜单components/select-list.ts:40
SettingsList带搜索和子菜单的设置项列表components/settings-list.ts:34
Loader带 spinner 动画的加载提示components/loader.ts:17
CancellableLoader可 Esc 取消的 Loader(带 AbortSignal)components/cancellable-loader.ts:13
Image终端内嵌图片(见第 7 节)components/image.ts:24

Loader 值得看一眼它怎么和渲染循环咬合(components/loader.ts:77):它自己起一个 setInterval,每 80ms 换一个 spinner 帧、setText、再调 ui.requestRender()。因为差分渲染,每次只有 spinner 那一行被重写——所以一个转圈动画几乎不耗什么。

5.3 焦点与硬件光标(IME 定位)

同一时刻只有一个组件"有焦点",由 tui.setFocus() 指定;键盘输入只发给它(tui.ts:827)。但还有个微妙需求:中日韩输入法的候选框要浮现在真实光标处。于是需要焦点的组件在 render 输出里,在光标位置埋一个零宽标记 CURSOR_MARKER(\x1b_pi:c\x07,一个终端会忽略的 APC 序列,tui.ts:120)。TUI 在渲染后扫描这个标记、算出它的行列、把它剥掉,再把硬件光标移过去(tui.ts:1234extractCursorPosition)。Input 组件的 render 就演示了这个:焦点时在光标前插入 marker(components/input.ts:434)。

5.4 Overlay:把弹层叠上去

菜单、确认框、自动补全下拉这类"浮在内容之上"的东西用 overlay 栈实现(tui.showOverlay,tui.ts:493)。渲染时,基础内容先摊平成行,再把每个可见 overlay 按 focusOrder 排序、算好位置,用 compositeLineAt(tui.ts:1176)逐行把 overlay 的字符切进基础行的对应列区间——这又全靠 §4 的 sliceByColumn/extractSegments 来正确处理宽字符和样式边界。合成发生在差分比较之前,所以叠了弹层的界面照样只重画变化的行。


6. 核心机制之四:输入与编辑

它要解决的小问题: 终端把按键当字节流塞进来,而且同一个键在不同终端、不同协议下编码完全不同。Enter\r,但 Shift+Enter 在没有协议时根本传不出来;方向键是 \x1b[A;Kitty 协议下则是 \x1b[<码>;<修饰>u 还带按下/重复/松开事件。怎么把这团字节变成"用户按了 ctrl+w"这种语义?

6.1 三层键解析

  • keys.ts 是解析核心。matchesKey(data, "ctrl+w")(keys.ts:820)判断一段输入是否等于某个语义键;parseKittySequence(keys.ts:587)解析 Kitty 的 CSI-u 格式(含 shifted key、base layout key、事件类型);isKeyRelease(keys.ts:527)判断是不是"松开"事件(默认组件不收松开事件,除非 wantsKeyRelease)。它同时兼容三套编码:Kitty 协议、xterm modifyOtherKeys、以及传统序列。
  • keybindings.ts 把语义键映射到动作TUI_KEYBINDINGS(keybindings.ts:54)定义了一整套默认绑定(如 tui.editor.deleteWordBackwardctrl+walt+backspace),KeybindingsManager(keybindings.ts:155)支持用户覆盖并检测冲突。组件里于是能写 kb.matches(data, "tui.select.up") 而不用关心具体键码(components/select-list.ts:115)。
  • native-modifiers.ts 处理一个边角:某些终端(如 Apple Terminal)传不出 Shift+Enter。在 macOS 上,Pi 通过一个原生 .node 插件直接问系统"此刻 Shift 按着吗"(isNativeModifierPressed,native-modifiers.ts:51),从而补出这个键。协议协商本身在 Terminal.start 时发生:queryAndEnableKittyProtocol(terminal.ts:220)先请求 Kitty 标志、再用 Device-Attributes 查询做哨兵,收到 DA 而没收到 Kitty 响应就回退到 modifyOtherKeys,无需超时等待。

6.2 编辑器里的编辑原语

Editor(components/editor.ts:252)是最重的组件,它把几个独立小模块拼成一个像样的多行编辑器:

能力模块文件
按词跳/删(alt+←ctrl+w)词边界扫描word-navigation.ts:22(findWordBackward)
Emacs 式 kill/yank(ctrl+k/ctrl+y)环形缓冲kill-ring.ts:8(KillRing)
撤销(ctrl+-)快照栈undo-stack.ts:7(UndoStack,用 structuredClone 深拷贝)
自动补全(斜杠命令、@文件)补全提供器autocomplete.ts:241(AutocompleteProvider)
补全排序模糊匹配fuzzy.ts:12(fuzzyMatch)/fuzzy.ts:99(fuzzyFilter)
带 ANSI 的软换行折行editor.ts:114(wordWrapLine)

findWordBackwardIntl.Segmenter词粒度切分(不是简单按空格),能正确处理标点和 CJK;KillRing 支持连续删除累积成一条、以及 yank-pop 循环取旧条目;fuzzyMatch 给连续匹配、词边界命中加分,给间隔扣分,返回"分越低越好"的排序分。这些都是纯函数/小类,可单独测试,也印证了这个库"小模块拼装"的风格。

Editor 还实现了 EditorComponent 接口(editor-component.ts:11),这是给扩展换成 vim/emacs 模式用的——核心应用只依赖接口,不绑死具体实现。


7. 核心机制之五:终端内嵌图片

它要解决的小问题: 让 agent 在终端里直接显示图片(截图、生成的图表),而不是只印一行 [图片]

难点: 不同终端用不同协议。Kitty/Ghostty/WezTerm/Warp 用 Kitty 图形协议(APC 序列 \x1b_G…),iTerm2 用 OSC 1337,大多数终端(VSCode、Alacritty、tmux 下)根本不支持。而且图片是"像素"的,得换算成占几个字符格。

7.1 先探测能力

detectCapabilities()(terminal-image.ts:65)靠环境变量(KITTY_WINDOW_IDTERM_PROGRAMITERM_SESSION_ID…)判断当前终端支持哪种图片协议、是否真彩色、是否 OSC 8 超链接。tmux/screen 下图片协议不可靠,直接标 images: null。探测不出来时保守假设不支持——图片降级成 [Image: foo.png 800x600] 文本(imageFallback,terminal-image.ts:482)。

7.2 编码与尺寸换算

encodeKitty(terminal-image.ts:165)把 base64 图片数据编码成 Kitty APC 序列,超过 4096 字节自动分块(m=1 续传标志)。calculateImageCellSize(terminal-image.ts:257)用每格像素尺寸(CellDimensions)把图片像素换算成占几列几行——而每格多少像素是 TUI 启动时用 \x1b[16t 问终端、在 consumeCellSizeResponse(tui.ts:873)里收回来的。Image 组件(components/image.ts:24)据此返回 rows 行(第一行是图片序列,其余是占位空行),让差分渲染知道这块图占几行。

7.3 图片与差分渲染的咬合

图片是差分渲染最棘手的部分:它不是普通文本行,不能简单地"清行重写"。TUI 为此专门:

  • isImageLine(terminal-image.ts:146)识别图片行、parseKittyImageHeader(tui.ts:28)从序列里读出图片 id 和占用行数;
  • 记住上一帧的图片 id 集合,差分时若图片所在区间变了,先发 deleteKittyImage(terminal-image.ts:215)删掉旧图再画新的(tui.ts:1161deleteChangedKittyImages);
  • 把变化区间扩展到覆盖整个图片块(expandChangedRangeForKittyImages,tui.ts:1138),避免只重画半张图。

这段是"差分渲染 + 特殊单元"如何共存的典型:普通行走廉价 diff,图片行走"先删后画 + 区间扩展"的特殊通道。


8. 巧妙之处(可借鉴的技术)

  • 一维行数组当 diff 目标。 不做树 diff,而是把整棵组件树 renderstring[],再逐行字符串比较。终端本就是一叠行,这个"降维"让 diff 极简却够用(tui.ts:1370)。
  • 同步输出包裹整批写入。 每帧的所有终端写入都夹在 \x1b[?2026h … \x1b[?2026l 里,终端一次性刷新,杜绝了"改了一半被看到"的撕裂(tui.ts:1286)。
  • 可视宽度缓存 + emoji 预筛。 visibleWidth 对非 ASCII 结果做 LRU 缓存,graphemeWidth 用便宜的码点区间检查挡在昂贵的 \p{RGI_Emoji} 正则前面(utils.ts:27utils.ts:262)。热路径上这两处优化很实在。
  • 超宽即崩 + 崩溃日志。 与其让界面悄悄错位,不如 dump 全部行到日志再抛错——把"渲染契约被破坏"变成可诊断的显式失败(tui.ts:1520)。
  • 零宽 APC 光标标记。 用一个终端会忽略的转义序列在文本流里标记逻辑光标位置,渲染后再定位硬件光标——一个不占版面、又能跨组件传递光标信息的巧妙信道(tui.ts:120)。
  • 协议协商用 DA 做哨兵。 查询 Kitty 键盘协议时尾随一个 Device-Attributes 查询;老终端不懂 Kitty 但一定会回 DA,于是"收到 DA 而无 Kitty 响应"就能立刻判定不支持、回退,免去启动超时(terminal.ts:220)。

9. 边界与局限

  • 只是渲染引擎,没有布局系统。 组件靠竖直堆叠 + 内边距 + overlay,没有 flexbox/grid 那种约束求解。复杂布局要手工算宽高。
  • diff 粒度是整行。 一行里改一个字,也重写整行(而非只改那个字符)。对终端来说整行重写足够快,但它确实不做行内 diff。
  • 图片依赖终端支持,且在 tmux/screen 下禁用。 不支持的终端一律降级成文本占位(terminal-image.ts:74)。
  • 宽度/高度变化必然整屏重绘。 resize 时无法差分,会重画全屏(tui.ts:1343)——这是换行位置改变的必然代价。
  • 强依赖 Intl.Segmenter 与 Node 运行时。 字形切分、structuredClone、原生 .node 修饰键插件都绑定较新的 Node/平台;这不是一个跨运行时的通用库。
  • 平台特例散落各处。 Termux(键盘弹出改高度)、Apple Terminal(Shift+Enter)、Windows(VT 输入)、tmux(超链接转发)各有专门分支——真实终端生态的碎片化,库只能逐个特判。

10. 代码地图(导航索引)

主题文件关键符号
渲染循环 + 差分主体packages/tui/src/tui.tsTUIdoRenderfullRender
组件契约packages/tui/src/tui.tsComponentContainer
overlay 合成packages/tui/src/tui.tsshowOverlaycompositeOverlayscompositeLineAt
光标标记 / IMEpackages/tui/src/tui.tsCURSOR_MARKERextractCursorPosition
背景色/主题探测packages/tui/src/tui.tsqueryTerminalBackgroundColorqueryTerminalColorScheme
底层终端packages/tui/src/terminal.tsTerminalProcessTerminalqueryAndEnableKittyProtocol
颜色解析packages/tui/src/terminal-colors.tsparseOsc11BackgroundColorparseTerminalColorSchemeReport
宽度/切片/换行packages/tui/src/utils.tsvisibleWidthsliceByColumnwrapTextWithAnsinormalizeTerminalOutputextractAnsiCode
图形协议packages/tui/src/terminal-image.tsdetectCapabilitiesencodeKittyrenderImagedeleteKittyImageisImageLine
按键解析packages/tui/src/keys.tsmatchesKeyparseKittySequenceisKeyRelease
键位绑定packages/tui/src/keybindings.tsTUI_KEYBINDINGSKeybindingsManager
原生修饰键packages/tui/src/native-modifiers.tsisNativeModifierPressed
编辑器packages/tui/src/components/editor.tsEditorwordWrapLine
编辑原语word-navigation.ts / kill-ring.ts / undo-stack.tsfindWordBackwardKillRingUndoStack
补全 / 模糊autocomplete.ts / fuzzy.tsAutocompleteProviderfuzzyMatchfuzzyFilter
输入框 / 选择 / 加载components/input.tsselect-list.tsloader.tsInputSelectListLoader

和本组其它章的关系: 本章是纯呈现层,不涉及 agent 逻辑。TUI 承载的那些界面(交互式会话、审批提示、命令补全)如何驱动业务,见 04 章 pi-coding-agent 的 CLI 模式;整个 Pi 的全景与阅读顺序见 index