终端 UI 渲染从零到一:怎么在一个只认字节的黑框里做出 Web 级的界面
这是一份快照
本文的数字、常量、行数取自 2026-09-02 对 sid-code 源码的一次实读。 代码在动,这些数字会腐坏——引用其中任何一个之前,请按文中给出的命令在你自己的仓库里复跑一次。
这份文档写给谁:没写过终端 UI、但需要在短期内既能听懂别人在讲什么、 又能自己动手设计一套的人。用途是知识梳理与 agent 开发面试准备。
它和原始研究文档的关系:
claude-code/docs/chapter-12-terminal-ui.md(2232 行) 是执行文档——写给已经懂的人,满篇是「Blit 优化」「DECSTBM」「捕获/冒泡」 「'use no memo'」。信息密度极高,但它默认你已经知道 ANSI、TTY、alt screen、 Reconciler 是什么,所以第一次读会在第三段就卡住。本文补的正是那一层:先把终端这个东西讲通,再把那份文档里真正值钱的结论 放回它该在的位置上。
它不是摘要。 摘要会把结论抽出来变成一句正确但没用的话 (「用 Diff 减少重绘」——谁不知道呢)。本文的写法相反: 每个结论都从「为什么会有人搞错」讲起,因为面试里能拉开差距的从来不是结论本身, 是你能不能说清它的反面为什么诱人。
关于文中数字的三条使用纪律
这份文档里有大量具体数字(行数、毫秒、倍数、字节位宽)。它们不是装饰, 但也不是永恒事实。 三条纪律:
- 数字是让你看见「真实数据长什么样」,不是让你背。 「120 个 markdown 块从 658ms 降到 7ms」的价值在于让你知道 O(N²) 在终端渲染里是什么量级的体感,而不是这个具体数值。
- 面试引用要带出处和时间。 说「某个 coding agent 的终端渲染底座约 2.3 万行」 比说「终端渲染需要 2.3 万行」强一个量级——后者是错的(上游 ink 全部源码只有 3979 行)。
- 引用前一律复跑。 本文所有命令都在 2026-09-02 实跑过,附录 B 是可复跑清单。 但代码每天在变,你读到这句话时数字可能已经漂了。
事实分级标记
全文用两个标记区分证据强度,它们的可信度差一个量级:
- 🔬 = 源码实读:我在写这份文档时打开文件、跑命令核对过,带
文件:行号。可回溯复现。 - 📄 = 二手引用:来自研究文档、设计文档、产品博客。可能已过期,也可能一开始就不准。
一个活体样本,说明为什么必须分级:本文写作时复核发现,
packages/cli/src/ui/CLAUDE.md的 frontmatter 至今写着paths: ["src/ui/**", "src/ink/**"],全文 17 处提到src/ink/—— 但 🔬src/ink/这个目录已经不存在了(ls src/ink→ No such file or directory), 代码早已搬到packages/tui-renderer/,组件实际 import 的是@sid-code/tui-renderer/components/Box.tsx(MainScreenLayout.tsx:14)。这份设计规范每一条设计结论都还是对的,只有路径漂了。 如果你照着它去
src/ink/找代码,会得出「这个能力不存在」的反向结论—— 而正确结论是「能力在,换了个地方」。 这就是为什么 📄 标记的东西不能当 🔬 用。
怎么读这份文档
按顺序读。 这是一条链,不是清单——后面每一章都在用前面建立的概念。
| 章 | 讲什么 | 读完你能回答 |
|---|---|---|
| §0 | 名词地图 | 别人说 ANSI / CSI / alt screen / raw mode 时,你知道指什么 |
| §1 | 终端到底是什么 | 为什么「在终端里画界面」这件事一开始就很别扭 |
| §2 | ANSI 转义序列 | 终端的唯一 API 长什么样,为什么它是个字节协议不是图形 API |
| §3 | 为什么要 React | Reconciler 是什么,为什么不直接 console.log |
| §4 | 布局:终端没有 CSS | Flexbox 怎么被搬进终端,为什么不自己写 |
| §5 | 渲染管线五阶段 | 能画出从「状态变了」到「字节进 stdout」的完整链路 |
| §6 | 性能:级联优化 | 为什么优化要分七层,为什么效果是乘法 |
| §7 | 两种屏幕模式 | 本领域最重要的架构分岔,选错了后面全是补救 |
| §8 | 流式渲染与闪烁 | 最值钱的技术章:闪烁的真根因不是你以为的那个 |
| §9 | 输入:字节流到事件 | 为什么按一下 Esc 要等 50ms |
| §10 | 宽度与文本 | 为什么 .length 会让你的界面歪掉 |
| §11 | 视觉语言与主题 | 终端里怎么做设计系统,为什么要按对比度反解颜色 |
| §12 | 会静默坏掉的失效模式 | 这一章是本文最值钱的部分 |
| §13 | 本仓现状对照 | 一份可照着做的自查模板 |
| §15 | 动手路线图 | 五个阶段,每个阶段你会亲手撞到哪个坑 |
如果只有 20 分钟:读 §7、§8、§12。这三章是这个领域的骨架,其余都是它们的展开。
如果只有 5 分钟:读 §12 开头那张「五种失效模式都不报错」的表。
§0 名词地图:先把词认全
这一节是查询表,不用背。往后每章第一次用到某个词都会重新解释, 这里放一份集中的,是为了你读原始研究文档时能随时回来查。
终端这个领域的术语门槛异常高,原因是它的词汇来自三个不同年代: 1970 年代的物理终端硬件(DEC VT100)、1980 年代的 Unix 内核(TTY)、 以及 2010 年代的前端(Reconciler、虚拟化)。三套词汇混在一句话里是常态。
按「一个字节从键盘到屏幕」的顺序排列,不按字母序——因为这些词之间有位置关系。
0.1 硬件与内核层(最古老的一组)
| 词 | 中文 | 是什么 | 为什么你要知道 |
|---|---|---|---|
| TTY | 终端设备 | teletypewriter 的缩写,电传打字机。现在指内核里的一个字符设备 | 「终端」在 Unix 里字面就是一台打字机的抽象,它的所有怪癖都源于此 |
| PTY | 伪终端 | 一对虚拟设备(master/slave),让程序假装自己连着一台打字机 | 你的 iTerm2 / VS Code 终端都是 PTY。PTY 一关,写 stdout 就报 EIO(§12 陷阱 3) |
| stdin / stdout | 标准输入 / 输出 | 两个字节流。程序从 stdin 读、往 stdout 写 | 终端 UI 的全部 I/O 就是这两根管子,没有第三条通道 |
| raw mode | 原始模式 | 关掉内核的行缓冲与回显,让程序逐字节拿到按键 | 不进 raw mode,用户按一个键你要等他按回车才收到 |
| cooked mode | 熟模式 | 默认模式:内核帮你缓行、处理退格、回显字符 | 写 read line 的脚本吃的是这个模式的好处 |
| SIGWINCH | 窗口变化信号 | 用户拖动终端窗口改变大小时,内核发这个信号 | 唯一能知道「终端变宽了」的途径 |
| SIGCONT | 继续信号 | 从 Ctrl+Z 挂起中恢复时收到 | 恢复后终端模式可能已被 shell 重置,要自愈(§12 陷阱 5) |
0.2 字节协议层:ANSI 家族(最容易混的一组)
这一组是同一个协议的不同分支,不是并列的四种技术。它们的关系是包含关系:
ESC(0x1b)= 所有转义序列的开头
├── ESC [ → CSI (Control Sequence Introducer)光标、颜色、擦除、模式开关
│ └── SGR 是 CSI 的一个子集(以 m 结尾的那些)
├── ESC ] → OSC (Operating System Command)标题、超链接、剪贴板、进度
└── ESC 其它单字符 → 少量杂项(保存光标、切换字符集)| 词 | 中文 | 是什么 | 例子 |
|---|---|---|---|
| ANSI escape sequence | ANSI 转义序列 | 以 ESC(0x1b)开头的一串字节,终端把它当指令而不是要显示的字符 | 整个终端 UI 的唯一 API |
| CSI | 控制序列引导符 | ESC [。后面跟参数和一个结尾字母,表示一条控制命令 | \x1b[2J = 清屏 |
| SGR | 图形渲染选择 | CSI 里以 m 结尾的那一族,专管颜色和字形 | \x1b[1;31m = 粗体+红色 |
| OSC | 操作系统命令 | ESC ]。管终端窗口本身的东西 | OSC 8 = 超链接,OSC 52 = 写剪贴板 |
| DEC private mode | DEC 私有模式 | CSI ? <n> h/l 形式的开关。h=开(high),l=关(low) | \x1b[?1049h = 进备用屏 |
| DECSTBM | 设置滚动区域 | CSI <top>;<bottom> r。告诉终端「只在这几行之间滚动」 | 硬件滚动优化的关键(§6) |
| CSI u / Kitty 键盘协议 | — | 一种现代按键编码,能区分 Ctrl+I 和 Tab | 传统编码下这俩是同一个字节 0x09 |
| modifyOtherKeys | — | xterm 的另一套按键编码方案,目标同上 | 与 CSI u 是竞争关系,都要支持 |
| bracketed paste | 括号粘贴 | 终端在粘贴内容前后加 \x1b[200~ / \x1b[201~ 包裹 | 区分「用户在打字」和「用户在粘贴」的唯一可靠手段 |
| DEC 2026 / 同步输出 | — | 「接下来这一批输出是一帧,收到结束标记再刷屏」 | 防撕裂/闪烁。BSU=开始,ESU=结束 |
💡 记忆法:
ESC [管屏幕里面(光标、颜色、擦除),ESC ]管屏幕外面(窗口标题、剪贴板、任务栏进度)。 带?的是开关。这三条能覆盖你 90% 会遇到的序列。
0.3 屏幕模型层
| 词 | 中文 | 是什么 | 关键区别 |
|---|---|---|---|
| main screen | 主屏 | 默认那块。输出追加进历史,往上滚能看回去 | 写进去的内容不可修改,泼出去的水 |
| scrollback | 回滚缓冲区 | 终端自己保存的历史行。用鼠标滚轮看的就是它 | 它属于终端,不属于你的程序 |
| alternate screen / alt screen | 备用屏 | 另一块干净画布(?1049h 进入)。程序完全接管 | 没有 scrollback,滚动要自己实现;退出后内容消失 |
| viewport | 视口 | 用户当前能看见的那几十行 | 与「内容总高度」区分:内容可能有 1000 行,视口只有 40 行 |
| cell | 单元格 | 屏幕网格里的一格,存一个字符 + 它的样式 | 终端屏幕就是一个二维 cell 数组,没有像素概念 |
| damage | 脏区 | 这一帧里发生了变化的矩形范围 | Diff 阶段只比较脏区,不比较全屏 |
| blit | 位块传送 | 把上一帧某个矩形的内容整块复制到这一帧 | 借自图形学的词,这里是内存拷贝 |
0.4 React / 渲染层(最新的一组)
| 词 | 中文 | 是什么 | 为什么终端 UI 需要它 |
|---|---|---|---|
| Reconciler | 协调器 | React 的核心算法:比较新旧树,算出最小改动 | React 把它抽成了可插拔的,所以能接非浏览器的渲染目标 |
| host config | 宿主配置 | 你要提供的一组回调(怎么建节点、怎么改属性、怎么插入子节点) | 写一个自定义渲染器 = 填这张表 |
| Ink | — | 一个把 React 接到终端的库 | 本文讨论的所有实现都是它的衍生版 |
| Yoga | — | Meta 开源的 Flexbox 布局引擎 | 终端没有 CSS,布局能力全靠它 |
| 虚拟化 / virtualization | — | 只为可见的那部分内容创建组件 | 数百条消息全挂载会让布局计算变慢 |
| Static | — | 一个「打印一次就不再重绘」的容器 | 主屏模式的核心机制(§7) |
0.5 文本与宽度层(最容易被低估的一组)
| 词 | 中文 | 是什么 | 坑在哪 |
|---|---|---|---|
| code point | 码点 | 一个 Unicode 字符的编号,如 U+4E2D(中) | "中".length === 1,但它在终端占 2 列 |
| grapheme cluster | 字素簇 | 用户感知的「一个字」,可能由多个码点组成 | 👨👩👧 是 1 个字素簇 / 5 个码点 / 11 个 UTF-16 单元 |
| wcwidth | — | 一个古老的 C 函数,回答「这个字符占几列」 | 各终端实现的版本不一致,是对齐漂移的头号来源 |
| East Asian Width | 东亚宽度 | Unicode 的一份属性表,标注字符是宽/窄/歧义 | 「歧义」那一档是灾难:同一个字符不同终端宽度不同 |
| soft wrap | 软换行 | 因为行太长被终端自动折的行 | 复制时要合并回一行;源码里的换行才是硬换行 |
| ANSI 宽度 | — | 转义序列本身不占列宽 | 算宽度前必须先 strip ANSI,否则数字虚高 |
💡 一个能立刻用上的记忆法:把终端想成一张方格纸 + 一支只能按顺序移动的笔。
- cell 是一个格子,viewport 是你看得见的那一页,scrollback 是翻过去的旧页。
- ANSI 序列是给笔的指令:「跳到第 3 行第 5 列」「换红色」「擦掉这一行」。
- raw mode 是把「读者的每一次敲击立刻告诉你」这个开关打开。
- alt screen 是抽出一张新的空白纸盖在上面,用完扔掉、露出原来那页。
- 而 React + Yoga + Diff 这一整套,干的事情是: 让你能用「写 JSX」的方式,去指挥那支只会跳格子的笔。
这个类比后面会反复用到,尤其是 §7——那一章讲的是「该在旧纸上继续写, 还是抽一张新纸」,而这个选择决定了后面所有事情。
0.6 本章自检
回答不了就往回看:
\x1b[31m和\x1b[?1049h这两条,哪个是「开关」?你怎么一眼看出来的?- scrollback 属于你的程序还是属于终端?这个归属决定了什么?
"中"的.length是几?它在终端占几列?这两个数不一样会导致什么现象?- 为什么「区分用户打字和用户粘贴」需要终端配合,而不能自己判断?
§1 终端到底是什么:为什么这件事一开始就很别扭
大多数人第一次听到「在终端里做 UI」,脑子里的画面是「一个简陋的窗口」。 这个直觉会让你在前三个决策上全错。 这一节把终端的真实形态讲清楚。
1.1 先看一个场景:你想做的最小界面
假设你要做一个 coding agent 的界面,需求朴素到不能再朴素:
上面是对话历史(会越来越长)
下面固定一个输入框,用户随时能打字
AI 回复时逐字出现在浏览器里这是十分钟的活:一个 overflow: auto 的 div 装历史, 一个 position: fixed 的 textarea 钉在底部,流式内容 innerHTML += 就行。
在终端里,你会立刻撞到四面墙:
- 没有「元素」这个概念。 你不能说「把输入框放在底部」—— 终端只接受「把光标移到第 40 行第 1 列,然后写这些字符」。
- 没有 z 轴,也没有「区域」。 输入框和历史内容共享同一片格子。 历史多一行,输入框就被顶下去一行——除非你自己算清楚每一行归谁。
- 写出去的东西改不了。 主屏模式下,你写进第 10 行的内容, 在用户往下滚之后就进了 scrollback,物理上无法再修改。
- 你不知道用户看到了什么。 用户可能手动往上滚了 50 行。 终端不会告诉你这件事(主屏模式下)。
这四面墙不是「终端比较落后」,而是终端的设计目标本来就不是画界面—— 它的设计目标是「把一行文字打印到纸上」。1970 年代的 VT100 是一台真的打印机, 所以「把已经打出来的字改掉」这个操作在它的世界观里根本不存在。
1.2 于是「终端 UI 框架」这个东西必须解决什么
把上面四面墙翻译成工程问题,就是终端 UI 框架的全部职责:
| 墙 | 框架必须提供的能力 | 本文对应章节 |
|---|---|---|
| 没有元素概念 | 一层虚拟 DOM:让你写 <Box>,它翻译成光标移动 | §3 |
| 没有区域和布局 | 一个布局引擎:算出每个组件占哪几行哪几列 | §4 |
| 写出去改不了 | 一个屏幕缓冲 + Diff:先在内存里画好,再算最小改动 | §5 |
| 不知道用户看到什么 | 要么接管整个屏幕(alt screen),要么接受不知道 | §7 |
注意最后一行:前三面墙是可以「用技术绕过去」的,第四面墙不是—— 它是一个架构选择,两条路都有代价。这就是 §7 那一章存在的原因, 也是本领域最容易选错的地方。
1.3 一个反直觉的事实:终端比浏览器更难,不是更简单
很多人觉得「终端 UI 应该比 Web 简单,毕竟只有字符」。三条实测反驳:
① 你要自己实现浏览器免费给你的所有东西。
🔬 复核本仓(2026-09-02):终端渲染底座 packages/tui-renderer/src/ 是 122 个文件 / 23464 行。 它做的事情,在浏览器里是 <div> + CSS + 浏览器内核帮你做完的: 布局引擎、事件冒泡、焦点管理、文本选择、滚动、宽字符测量、剪贴板。
对照 📄 上游 ink 全部源码只有 3979 行(实测)—— 差出来的两万行不是「加功能」,绝大部分是补齐浏览器的基础能力。
② 兼容性比浏览器碎片化更严重。
浏览器的碎片化是「Safari 不支持某个 CSS 属性」。 终端的碎片化是「同一个字符在两个终端里占的列数不同」—— 这会导致你的整个界面在其中一个终端里逐行歪掉,而且不报任何错。
③ 你的输出和用户的输出在同一根管子里。
浏览器里,你的 DOM 是你的。终端里,stdout 是共享资源: 用户的 shell 会往里写,你 spawn 的子进程会往里写, console.log 会往里写(🔬 这就是为什么本仓有一个 packages/cli/src/ui/console-guard.ts——一行野生的 console.log 会把你精心算好的帧撕掉)。
1.4 心智模型:三层,从下往上
后面十四章都在这三层里打转。先把这张图记住,遇到任何问题先定位它属于哪一层。
┌─────────────────────────────────────────────────────────┐
│ ③ 应用层:你的业务组件 │
│ 消息列表 / 输入框 / 权限确认框 / Spinner │
│ 用 JSX 写,关心「显示什么」 │
│ 🔬 本仓:packages/cli/src/ui/ 176 文件 / 36365 行 │
├─────────────────────────────────────────────────────────┤
│ ② 框架层:终端渲染底座 │
│ React Reconciler + Yoga 布局 + 屏幕缓冲 + Diff │
│ 关心「怎么把组件树变成最少的字节」 │
│ 🔬 本仓:packages/tui-renderer/ 122 文件 / 23464 行 │
├─────────────────────────────────────────────────────────┤
│ ① 协议层:ANSI 转义序列 + TTY │
│ stdin 进字节,stdout 出字节 │
│ 关心「这串字节终端认不认」 │
│ 不是你写的,是 1970 年代定下来的 │
└─────────────────────────────────────────────────────────┘这张图的用法:面试里被问一个现象(「为什么会闪」「为什么对齐歪了」), 第一步永远是定位层。同一个症状在不同层的成因完全不同,修法也完全不同:
| 症状 | 可能在 ① | 可能在 ② | 可能在 ③ |
|---|---|---|---|
| 界面闪烁 | 终端不支持同步输出 | Diff 触发了全屏重绘(§8) | 组件每帧重建数组 |
| 对齐歪了 | 终端 wcwidth 表过时 | 宽度函数算错(§10) | 用了 .length(§10) |
| 按键没反应 | 没进 raw mode | 事件被上层拦了(§9) | 忘了注册处理器 |
1.5 本章自检
- 主屏模式下你写进第 10 行的内容,用户往下滚之后你还能改它吗?为什么?
- 「终端比浏览器简单」错在哪?给一条实测证据。
- 一个「界面闪烁」的报告进来,你的第一步是什么?
§2 ANSI 转义序列:终端的唯一 API
上一章说终端只接受字节。这一章讲那些字节长什么样。
这一章可以快读,但不能跳——后面每一章都在生成这些序列, 不认识它们的话,看到 \x1b[?2026h 会以为是乱码。
2.1 三条规则讲完 ANSI 的骨架
规则一:ESC(0x1b,写成 \x1b 或 \e)开头的一切,都是指令,不是内容。
你写: Hello
终端: 显示 Hello
你写: \x1b[31mHello
终端: 显示 Hello(红色)—— "[31m" 被吃掉了,它是指令规则二:绝大多数指令的形状是 ESC [ 参数 结尾字母。
这个形状叫 CSI(Control Sequence Introducer)。结尾字母决定干什么, 参数用 ; 分隔:
\x1b[2J 清屏 (J = erase in display,2 = 全屏)
\x1b[H 光标回左上角 (H = cursor position,无参数 = 1;1)
\x1b[10;5H 光标到 10 行 5 列
\x1b[3A 光标上移 3 行 (A = up)
\x1b[K 擦到行尾 (K = erase in line)
\x1b[31m 前景色红 (m = SGR)
\x1b[1;31m 粗体 + 红
\x1b[38;2;234;179;8m 前景色 RGB(234,179,8) ← 24 位真彩色
\x1b[0m 重置所有样式规则三:带 ? 的是模式开关,h 开 l 关。
这类叫 DEC private mode。记 h=high=开、l=low=关,就够用了:
\x1b[?1049h 进备用屏 \x1b[?1049l 回主屏
\x1b[?25l 隐藏光标 \x1b[?25h 显示光标
\x1b[?2004h 开括号粘贴 \x1b[?2004l 关
\x1b[?1004h 开焦点报告 \x1b[?1004l 关
\x1b[?2026h BSU 开始同步帧 \x1b[?2026l ESU 结束
\x1b[?1000h \x1b[?1002h \x1b[?1006h 开鼠标追踪(三条一起发)🔬 本仓 packages/tui-renderer/src/ink.tsx:520 就是在拼这些: (this.altScreenActive ? "" : "\x1b[?1049h") + ...
就这三条。 剩下的都是查表。你需要背的不是序列表, 而是**「看到 \x1b[ 知道是 CSI、看到 ? 知道是开关」**这个解码能力。
2.2 颜色有三档,而且必须能降级
这是一个跨终端兼容性的典型缩影,值得单独讲。
| 档 | 序列形状 | 能表达 | 谁支持 |
|---|---|---|---|
| 16 色(ANSI) | \x1b[31m | 8 前景 + 8 亮色 | 所有终端,包括 1980 年代的 |
| 256 色 | \x1b[38;5;196m | 216 色立方 + 24 灰阶 | 绝大多数 |
| 真彩色(24-bit) | \x1b[38;2;255;0;0m | 1670 万色 | 现代终端,但 tmux 会打折 |
为什么这件事重要:你的设计稿是用 RGB 画的。 如果用户的终端只支持 256 色,你直接吐 38;2;... 会怎样? 不会报错。 终端会忽略它,或者显示成一个近似色,或者显示成乱码—— 三种行为都可能,取决于终端。
所以框架必须做主动降级:检测能力 → 把 RGB 近似到能表达的档。
🔬 一个反直觉的实测细节(本仓 packages/tui-renderer/src/terminal.ts): 自动检测不可信,两个方向都要人工纠偏:
VS Code 内置终端(xterm.js):实际支持真彩色,但检测库常报 256 色
→ 要主动 boost 上去
tmux:声称支持真彩色,但透传不可靠
→ 要主动 clamp 下来(除非用户显式强制)教训:isTruecolorSupported() 这种函数的返回值是猜测,不是事实。 成熟实现里一定有一张「已知终端的已知怪癖」表,靠 TERM_PROGRAM 之类的 环境变量硬编码纠偏。这不是丑陋的 hack,这是这个领域的常态。
2.3 同步输出(DEC 2026):一条能消灭闪烁的开关
这条值得单独讲,因为它是闪烁问题的第一道防线,而且很多人不知道它存在。
问题:你要更新一帧,需要写出几百个字节(移动光标、改颜色、写字符、 再移动、再写……)。终端可能在你写到一半的时候刷新屏幕—— 用户于是看到一个半成品画面。下一次刷新才是完整的。这就是撕裂/闪烁。
解法:把整帧包在 BSU/ESU 之间:
\x1b[?2026h ← BSU: 「接下来是一帧,别刷」
...几百字节的 patch...
\x1b[?2026l ← ESU: 「好了,现在刷」🔬 本仓 terminal.ts:219 的 writeDiffToTerminal 正是这个结构: 把所有 patch 拼进一个 string(注意:不是多次 write), 前后夹 BSU/ESU,最后一次 terminal.stdout.write(buffer)。
两个容易漏的细节,都在源码注释里写着:
- 必须攒成一次 write。 多次
write之间,事件循环可能让别的东西插进来。 - tmux 是个坑:🔬
terminal.ts:76的注释直说 「tmux parses and proxies every byte but doesn't implement DEC 2026」—— 它解析你的 BSU/ESU 但不实现它。所以对 tmux 要skipSyncMarkers: 发了不但没用,还白付字节成本(高频 alt-screen 下这个成本是实的)。
这就是「能力检测」在这个领域为什么无法省掉: 同一条序列,在 A 终端消灭闪烁,在 B 终端是纯开销,在 C 终端可能显示成乱码。
2.4 OSC:管屏幕外面的事
ESC ] 开头的一族,管的是终端窗口本身:
| 序列 | 干什么 | 注意 |
|---|---|---|
OSC 0 / OSC 2 | 设置窗口标题 | 最广泛支持 |
OSC 8 | 超链接(终端里可点击的链接) | Apple Terminal 不支持 |
OSC 52 | 写系统剪贴板 | iTerm2 默认关闭、tmux 要配置——别指望它 |
OSC 9;4 | 任务栏/标签页进度 | 🔬 本仓 terminal.ts:26 isProgressReportingAvailable() |
OSC 133 | 标记 prompt 边界(shell 集成) | 让终端知道「一条命令从哪开始」 |
📄 OSC 52 值得单独提醒,因为它是一个很诱人但靠不住的方案。 本仓 ADR-040 里就因为它否决了一整条技术路线(§7 会详述): 「OSC52 在 iTerm2 默认关闭、tmux 需配置,跨终端不稳」。
一个通用判据:一个 ANSI 能力如果默认关闭,那它对你的用户群体 就等于不存在——你不能要求用户改终端配置才能用你的产品。
2.5 本章自检
\x1b[?25l干什么?你怎么从形状看出它是开关、而且是「关」?- 你的设计稿是 RGB 的,用户终端只支持 16 色。直接输出 RGB 序列会怎样?
- 为什么整帧要攒成一次
write,而不是边算边写? - tmux 号称支持 DEC 2026,为什么还要跳过 BSU/ESU?
§3 为什么要 React:Reconciler 是什么,为什么不直接 print
上一章讲完了「终端认什么字节」。现在的问题是:谁来生成那些字节?
最朴素的答案是「我自己 print」。这一节讲为什么所有成熟实现最后都走到了 React, 以及 Reconciler 到底是个什么东西——这是本文最需要「从零讲」的一个概念。
3.1 先看「自己 print」会死在哪
假设你不用任何框架,直接手写。第一版很顺利:
// v1:能跑
console.log("> " + userInput)
console.log(aiResponse)然后需求来了:AI 回复是流式的,要逐字出现,而且输入框要一直在底部。
// v2:开始变形
process.stdout.write("\x1b[2K") // 擦掉当前行
process.stdout.write("\r") // 回到行首
process.stdout.write(aiResponse) // 重写
process.stdout.write("\n> " + userInput) // 输入框再来一个需求:输入框上面要显示一个 spinner + 已用时间 + 一个「等待权限确认」的框。
// v3:你已经在手动算行数了
const linesToClear = 1 + (spinnerVisible ? 1 : 0) + (permissionVisible ? 4 : 0)
process.stdout.write(`\x1b[${linesToClear}A`) // 上移
process.stdout.write("\x1b[J") // 擦到屏幕底
// ...重新画所有东西,顺序不能错,行数不能算错v3 就是崩溃点。 具体崩在三个地方:
- 行数计算是全局耦合的。 权限框从 4 行变 5 行,
linesToClear的计算要跟着改——而它在另一个文件里。 任何一处算错,界面就错位,而且错位会累积(每帧多擦一行, 十帧之后擦掉了十行历史)。 - 换行是隐式的。 AI 回复 200 字符、终端 80 列宽 → 实际占 3 行。 你的
linesToClear必须知道这个——于是你要自己实现换行计算(§10 会讲它多难)。 - 状态和绘制混在一起。 「现在该不该显示 spinner」这个逻辑, 和「spinner 占几行」这个绘制细节,缠在同一段代码里。
这三条的共同点是:它们都是「命令式绘制」的固有问题,不是你写得不好。 浏览器时代大家用 jQuery 手动 .append() / .remove() 时, 撞的是同一堵墙,React 就是那堵墙的产物。
3.2 React 提供的那一个东西
React 只提供一个核心能力,但它正好是上面三个问题的解药:
你只描述「当前状态下界面应该长什么样」, React 负责算出「从上一个样子变成这个样子,需要做哪些最小改动」。
写法从命令式变成声明式:
// v4:不再有任何 "\x1b[" 和行数计算
<Box flexDirection="column">
{messages.map(m => <Message key={m.id} data={m} />)}
{isLoading && <Spinner elapsed={elapsed} />}
{permissionRequest && <PermissionDialog req={permissionRequest} />}
<PromptInput value={userInput} />
</Box>权限框从 4 行变 5 行?你什么都不用改——它算在布局里。
这就是全部理由。 不是「React 很流行」,也不是「组件化好看」, 而是**「行数计算这件事必须自动化,否则一定会错」**。
3.3 Reconciler:React 怎么接到终端上
这里是新手最容易卡住的地方,所以慢一点讲。
关键认知:React 本身不知道浏览器的存在。
React 被拆成了两半:
react —— 只负责「组件、hooks、状态、算差异」这套逻辑
react-dom —— 负责「把差异应用到浏览器 DOM 上」
react-native —— 负责「把差异应用到 iOS/Android 原生视图上」
你的终端渲染器 —— 负责「把差异应用到终端上」 ← 你要写的就是这一层第二半叫 host(宿主)。写一个自定义渲染器,就是提供一份 host config: 一组回调函数,告诉 React「在我这个世界里,创建节点/修改属性/插入子节点 分别该怎么做」。
这份 config 的核心就那么几个函数(🔬 本仓 reconciler.ts,488 行):
const reconciler = createReconciler({
// React 说「我要一个 <Box>」→ 你返回一个你自己的节点对象
createInstance(type, props) {
const node = createNode(type) // 建一个自己的树节点
if (type === 'ink-box') {
node.yogaNode = engine.createNode() // 顺手建一个布局节点
applyStyles(node.yogaNode, props.style)
}
return node
},
// React 说「这个节点的属性变了」→ 你更新它,并标记「脏」
commitUpdate(node, type, oldProps, newProps) { /* ... */ },
// React 说「把这个节点插到那个节点里」→ 你操作自己的树
appendChild(parent, child) { /* ... */ },
// React 说「我要一段文字」→ 文字节点只能在 <Text> 里
createTextInstance(text) { return { nodeValue: text } },
})填完这张表,你就免费得到了:hooks、useState、useEffect、Context、 Suspense、并发调度、错误边界——React 生态的全部。
这是一笔极其划算的交易,也是为什么这个领域没人从零发明一套 UI 范式。
3.4 那棵「自己的树」是什么,为什么必须存在
新手常有的疑问:既然最终要输出字节,为什么中间要建一棵树?直接算不行吗?
不行,两个理由:
理由一:Reconciler 需要一棵可查询、可修改的树来做 diff。 浏览器有真实 DOM,终端没有,所以你得自己造一个等价物。 🔬 本仓叫它 DOMElement(dom.ts,454 行), 名字直接借了 DOM——因为它在架构里的位置就是 DOM 的位置。
理由二(更实际):这棵树是「绕过 React 的性能后门」。
这一点非常关键,也是面试里的加分点。有些状态高频到不能走 React:
// 🔬 这些状态直接挂在树节点上,不进 React state
node.scrollTop // 滚动位置:鼠标滚轮每帧都变
node.pendingScrollDelta
node.dirty // 脏标记:每帧都在改为什么不用 useState? 因为 setState 会触发 「调度 → 重新渲染组件 → Reconcile → commit」这一整套。 滚动一格就走一遍这个流程,性能上不可接受。
把状态挂在树节点上,渲染管线可以在绘制阶段直接读它, 完全跳过 React。代价是这部分逻辑变成命令式的、更难调试。
💡 这是一个通用范式,值得记住: 声明式用于「结构」,命令式用于「高频局部状态」。 不是对 React 的否定,是分工。§6 会看到这个范式出现四次。
3.5 脏标记向上传播:一个小设计,大效果
markDirty() 是这层的核心机制,讲清楚它能省很多后面的困惑。
用户改了一条消息的内容
↓
那个节点 dirty = true
↓ 沿父链一路往上标
父节点 dirty = true
↓
根节点 dirty = true → 渲染器知道「这一帧要重画」为什么要往上传播,而不是只标自己? 因为绘制是从根往下递归的。递归到某个节点时要能立刻判断 「这棵子树要不要重画」——如果只标了叶子, 递归到中间节点时无法知道下面有没有变化,只能全部走一遍。
往上传播的收益:递归时看到一个 dirty === false 的节点, 整棵子树直接跳过(还能顺便 blit 上一帧的像素,见 §5)。 这是 O(变化量) 而不是 O(总量) 的前提。
3.6 一个真实的踩坑:React Compiler 会破坏这个机制
🔬 这是原始研究文档里一个很精彩的细节,讲的是优化器和手写优化打架。
React 19 的 Compiler 会自动给组件加 memoization。 但有个组件(OffscreenFreeze)的机制恰恰依赖于「不 memo」:
function OffscreenFreeze({ children }) {
'use no memo' // ← 显式关掉 React Compiler 的优化
const cached = useRef(children)
if (isVisible) cached.current = children // 可见才更新缓存
return <Box>{cached.current}</Box> // 不可见 → 返回旧的引用
}它的原理是「故意返回旧的 ReactElement 引用」—— React 看到引用没变,就跳过整棵子树的 diff。 如果 Compiler 帮它 memo 了,返回的就是「memo 后的新引用」, 这个机制当场失效,而且不报错(只是变慢了)。
📌 这个坑的通用形态: 「靠引用相等来跳过工作」的优化,会被任何自动 memo 化破坏。 而破坏之后代码照样跑、测试照样绿,只有性能悄悄退回去了。 这是 §12「静默失效」那一章的一个预告。
3.7 本章自检
- 手写
\x1b[...]做界面,第一个真正崩溃的点是什么?为什么它不是「你写得不好」? - React 被拆成哪两半?你要写的是哪一半?
- 为什么滚动位置不放在
useState里?放在哪里?代价是什么? - 脏标记为什么要向父节点传播?只标自己会怎样?
§4 布局:终端没有 CSS,怎么办
有了组件树,下一个问题:每个组件占哪几行哪几列?
4.1 为什么这件事不能「随便算算」
先看一个简单需求,感受一下复杂度:
一行里放三个东西:左边是模型名,中间弹性留白,右边是 token 数
终端宽度 80 列时怎么排?宽度 40 列时呢?模型名很长时截断谁?在 CSS 里这是 display: flex; justify-content: space-between。 自己实现的话,你需要处理: 弹性伸缩(谁能变宽)、收缩优先级(空间不够谁先被压)、 最小/最大宽度、百分比、内外边距、换行……
Flexbox 规范有数百页,数千个边界情况。 自己实现的正确性风险, 远大于引入一个依赖的成本。所以:布局这件事,业界统一的答案是「用 Yoga」。
4.2 Yoga 是什么
Yoga 是 Meta 开源的 Flexbox 布局引擎,原本为 React Native 写的, 用 C++ 实现,经过大规模生产验证。
它的接口非常朴素——不涉及任何渲染,纯数学:
输入:一棵节点树,每个节点带一组样式(flexDirection、width、padding...)
+ 根节点的可用宽高
输出:每个节点的 (left, top, width, height)框架要做的只是一个适配层:把自己的样式属性翻译成 Yoga 的 API 调用。 🔬 本仓 packages/tui-renderer/src/layout/yoga.ts(304 行)就是这一层:
function applyStyles(yogaNode, style) {
yogaNode.setFlexDirection(style.flexDirection ?? 'column')
yogaNode.setAlignItems(style.alignItems ?? 'stretch')
yogaNode.setJustifyContent(style.justifyContent ?? 'flex-start')
yogaNode.setWidth(style.width)
yogaNode.setPadding(style.padding)
yogaNode.setOverflow(style.overflow) // visible | hidden | scroll
// ...
}💡 注意默认值
flexDirection: 'column'。 Web 的 Flexbox 默认是row,终端 UI 框架普遍默认column—— 因为终端天然是「一行一行往下」的。这是个小但真实的心智差异, 从 Web 转过来的人常在这里第一次困惑。
4.3 一个真实的取舍:WASM 版 vs 纯 TS 移植
📄 原始研究文档提到一个决策:用 Yoga 的 TypeScript 移植版而不是官方 WASM 版。
🔬 本仓也是这样,而且能看到实物: packages/tui-renderer/src/_vendor/yoga-layout/index.ts 是 2594 行的纯 TS 移植,文件头注释坦白得很漂亮:
Pure-TypeScript port of yoga-layout (Meta's flexbox engine).
The upstream C++ source is ~2500 lines in CalculateLayout.cpp alone;
this port is a simplified single-pass flexbox implementation that covers
the subset of features Ink actually uses: ...理由(两条,都要能说出来):
- 启动时间和打包复杂度。 WASM 版要加载
.wasm文件—— 对一个 CLI 工具,冷启动时间是核心指标,多一个异步加载步骤不划算。 纯 TS 可以直接被 bundler 打进单文件二进制。 - 性能够用。 终端 UI 的节点数通常几百个(浏览器动辄上万)。 这个量级下,TS 和 WASM 的差距不构成瓶颈。
但代价必须诚实说出来(面试里这是加分点): 注释里的 a simplified ... implementation that covers the subset 是关键—— 它不是完整的 Flexbox。用到未覆盖的属性时,行为可能和 Web 不一致。 换句话说:你省下了启动时间,买单方式是「布局能力有边界,而且边界在注释里而不在类型里」。
4.4 布局与「测量文本」的鸡生蛋问题
这是布局层唯一一个真正的难点,也是它和 §10(宽度)耦合的地方。
问题:Yoga 要算一个文本节点占多少空间,必须知道「这段文字占几列」。 但列宽取决于容器宽度(要换行),容器宽度又取决于布局结果。循环依赖。
Flexbox 的标准解法是 measure function: Yoga 在布局过程中回调你——「如果我给这个节点 40 列宽,它需要几行高?」 你算完告诉它,它继续算布局。
Yoga: 「这个 Text 节点,可用宽度 40 列,你要多高?」
你: 「让我按 40 列换行算一下……需要 3 行」 ← 这里要调 §10 的宽度函数
Yoga: 「好,那这个节点是 40×3,继续算它的兄弟节点」为什么这个细节值得知道:它意味着宽度函数会在布局热路径上被反复调用。 一个又慢又对的宽度函数,会让整个界面卡。 🔬 所以本仓有 line-width-cache.ts、measure-text.ts、node-cache.ts 三个专门的缓存模块——不是过度设计,是这条路径真的很热。
4.5 本章自检
- 为什么不自己实现 Flexbox?
- 终端 UI 框架的
flexDirection默认值通常是什么?和 Web 一样吗? - 用纯 TS 移植版 Yoga 省了什么、代价是什么?代价写在哪里(类型里还是注释里)?
- 布局和文本测量之间的循环依赖是怎么打破的?
§5 渲染管线:从「状态变了」到「字节进 stdout」
前面四章的零件齐了:ANSI 字节(§2)、React 树(§3)、布局结果(§4)。 这一章把它们串成一条线。
这是全文的骨干章。 后面所有性能优化(§6)、闪烁问题(§8)、 失效模式(§12)都挂在这条线的某一段上——不理解这条线,后面的讨论没有落点。
5.1 先看全景:五个阶段
用户输入 / AI 吐出一个 token / 定时器 tick
↓ React 状态变化
┌────────────────────────────────────────────────────────┐
│ ① Reconcile —— React Fiber 树 → 自己的节点树 │
│ 产出:一棵带 dirty 标记的树,每个 box 挂着 Yoga 节点 │
│ 🔬 reconciler.ts (488 行) → dom.ts (454 行) │
└────────────────────────┬───────────────────────────────┘
↓
┌────────────────────────────────────────────────────────┐
│ ② Layout —— Yoga 算出每个节点的 (x, y, w, h) │
│ 输入:树 + 终端宽高;输出:每个节点的矩形 │
│ 🔬 layout/yoga.ts (304 行) │
└────────────────────────┬───────────────────────────────┘
↓
┌────────────────────────────────────────────────────────┐
│ ③ Paint —— 遍历树,把内容「画」进内存里的屏幕缓冲 │
│ 处理:边框、背景、裁剪、滚动偏移、文本换行 │
│ ★ 关键优化:dirty=false 的子树直接 blit 上一帧 │
│ 🔬 render-node-to-output.ts (1385 行) → screen.ts │
└────────────────────────┬───────────────────────────────┘
↓
┌────────────────────────────────────────────────────────┐
│ ④ Diff —— 逐 cell 比较「这一帧」和「上一帧」 │
│ 产出:一串 patch(移光标 / 换样式 / 写字符) │
│ 🔬 log-update.ts (688 行) │
└────────────────────────┬───────────────────────────────┘
↓
┌────────────────────────────────────────────────────────┐
│ ⑤ Write —— patch 序列化成 ANSI,攒成一个 string 写出去 │
│ 包在 BSU/ESU 里(§2.3) │
│ 🔬 terminal.ts:219 writeDiffToTerminal │
└────────────────────────┬───────────────────────────────┘
↓
stdout(真实字节)一句话记住这条线: React 管「该长什么样」,Layout 管「在哪」,Paint 管「画到内存」, Diff 管「和上次差哪」,Write 管「变成字节」。
5.2 为什么必须有「内存里的屏幕」这一层
阶段 ③ 画到的那个东西叫 Screen(屏幕缓冲)。 新手最常问:为什么不直接一边遍历一边往 stdout 写?
因为你需要和「上一帧」做比较,而上一帧已经在屏幕上、读不回来。
终端是只写的:你可以往 stdout 写,但不能问终端「第 10 行现在是什么」。 所以唯一的办法是:自己维护一份「屏幕现在长什么样」的副本。
prevScreen ← 上一帧的内存副本(= 我认为屏幕现在的样子)
nextScreen ← 这一帧画出来的
diff(prev, next) → patch → 写出去 → prevScreen = nextScreen这就是双缓冲,和游戏/图形界的双缓冲是同一个思想。
⚠️ 一个重要推论,后面 §8 会用到:
prevScreen是**「我认为」屏幕的样子**,不是屏幕的真实状态。 一旦这两者不一致(比如有别的程序往 stdout 写了东西), 后续所有 diff 都基于错误前提,界面会花掉且不会自愈。 🔬 这就是本仓要有console-guard.ts的原因——一行野生console.log就能让这份副本失真。
5.3 Cell 的存储:一个值得抄的内存布局
这一节偏底层,但它是**「性能优化型面试题」的标准素材**,值得看懂。
问题:一个 200 行 × 120 列的屏幕有 24000 个 cell。 每个 cell 要存:字符、前景色、背景色、粗体/斜体等、超链接、宽度。 最直白的写法是一个对象数组:
// ❌ 直白但昂贵
type Cell = { char: string; fg: string; bg: string; bold: boolean; ... }
const cells: Cell[] = new Array(24000)代价:24000 个 JS 对象 = 24000 次分配 + GC 要追踪 24000 个引用。 而且这些对象散布在堆上,diff 阶段逐个比较时缓存命中率极差。
解法:打包进一个连续的 Int32Array。 🔬 本仓 screen.ts:317 的注释把布局写得一清二楚:
每个 cell = 2 个连续的 Int32(共 8 字节)
word0 (cells[ci]) : charId (32 位,指向 CharPool 的索引)
word1 (cells[ci + 1]) : styleId[31:17] | hyperlinkId[16:2] | width[1:0]
STYLE_SHIFT = 17, HYPERLINK_SHIFT = 2
HYPERLINK_MASK = 0x7fff (15 位)
WIDTH_MASK = 3 (2 位 —— 宽度只可能是 0/1/2,两位够了)这里有三个独立的技巧,每个都能单独当面试答案:
技巧一:位域打包(bit packing)。 样式 15 位 + 超链接 15 位 + 宽度 2 位 = 32 位,塞进一个 Int32。 为什么宽度只给 2 位?因为终端字符宽度只有 0(组合字符)、1(半宽)、2(全宽) 三种可能——2 位表达 4 种,够了。这种「按值域定位宽」的思路是位域设计的核心。
技巧二:字符串池化(interning)。 cell 里存的不是字符串,是一个整数 ID。 CharPool 维护「字符 ↔ ID」的双向映射:
"H" → 1, "e" → 2, "l" → 3, "😀" → 4🔬 而且 ASCII 有快速路径:screen.ts:310 的 initCharAscii() 建了一个 128 长度的 Int32Array 表,ASCII 字符直接用 charCode 查表,不进 Map。 这是「让最常见的情况最快」的典型做法——终端里 95% 的字符是 ASCII。
同样的池化用在样式(StylePool)和超链接(HyperlinkPool)上。
技巧三:双视图批量清零。 🔬 screen.ts:453 的注释:
Allocate one buffer, two views: Int32Array for per-word access,
BigInt64Array for bulk fill in resetScreen/clearRegion.同一块内存,开两个 TypedArray 视图:
Int32Array用于逐字段读写(cells[ci]、cells[ci+1])BigInt64Array用于批量清零(cells64.fill(0n, 0, size))
因为一个 cell 正好是 64 位,用 64 位视图 fill 一次能清一个 cell, 清屏的循环次数减半。
🔬 注释里还留了一条自我约束,很值得学:
EMPTY_CELL_VALUE = 0n后面写着 「Not used for comparison — BigInt element reads cause heap allocation」。 也就是说:64 位视图只能用来fill,不能用来比较—— 因为在 JS 里读一个 BigInt 元素会在堆上分配一个 BigInt 对象, 在 diff 热路径上这是灾难。这条注释是「优化必须知道自己的边界」的范本: 同一个工具在一个操作里是优化,在另一个操作里是反优化, 而且两者都能跑通、都不报错,只有一个更慢。
5.4 阶段 ③ 的核心优化:Blit
Blit = 把上一帧某个矩形整块内存复制到这一帧。
为什么它有效:想想一个真实的对话场景。用户发一条消息,AI 开始回复。 屏幕上 90% 的内容(之前的所有消息)一个字节都没变。
if (!node.dirty && 位置没变 && prevScreen 可用) {
blitRegion(prevScreen, nextScreen, left, top, width, height)
return // ← 整棵子树跳过,不递归、不重画
}复制是 Int32Array 级别的内存拷贝——极快,而且不产生垃圾。
效果是渐进式的:
- 没有 blit:每帧 O(总内容面积)
- 有 blit:每帧 O(变化面积)
这条优化能成立的前提有两个,缺一个就静默失效:
- 脏标记必须准确。 该标脏的没标 → 内容不更新(用户看到旧内容); 不该标的标了 → 白干活(性能优化失效)。 注意这两种错误的表现完全不同:前者是可见的 bug,后者只是变慢。
- 位置必须没变。 如果一个节点内容没变但被上面的内容顶下去了一行, 就不能 blit,必须重画。
📌 面试点:被问「blit 什么时候不能用」,答**「位置变了的时候」** 就已经比只说「内容变了的时候」好一档—— 因为它说明你想过「内容不变但位置变」这个情况。
5.5 阶段 ④ 的四个优化
Diff 阶段要做的事:逐 cell 比较,生成最少的 patch。四个技巧:
① Damage 区域限制。 Paint 阶段顺手记录了「这一帧改动的矩形边界」。 Diff 只在这个矩形内比较,不扫全屏。
② 相对光标移动 vs 绝对定位。
绝对:\x1b[10;5H 「跳到第 10 行第 5 列」
相对:\x1b[3C 「右移 3 列」📄 原始文档给的理由值得注意:主屏模式下绝对定位会受 scrollback 影响—— 「第 10 行」是相对视口还是相对整个历史?答案取决于终端和滚动状态。 相对移动没有这个歧义。这是「主屏模式下你不知道自己在哪」的一个具体后果(§7)。
③ 样式差分预计算。 从「粗体+红」切到「粗体+蓝」, 朴素做法是 \x1b[0m\x1b[1m\x1b[34m(重置再重设,11 字节), 最优做法是 \x1b[34m(只改颜色,5 字节)。 🔬 StylePool 提供 transition(fromId, toId)——因为样式已经池化成 ID 了, 两个 ID 之间的最小差分序列可以预计算并缓存。 这是池化的第二重收益,比省内存更值钱。
④ 行尾空白跳过。 终端默认就是空白,不用写。
5.6 一条贯穿全线的判据:每一层都在减少下一层的工作量
这条是理解整个管线的元认知,也是 §6 那一章的引子:
虚拟化 减少 → React 要 reconcile 的节点数
脏标记 减少 → 要重新布局的节点数
blit 减少 → 要 paint 的面积
damage 减少 → 要 diff 的 cell 数
样式差分 减少 → 要写出去的字节数
BSU/ESU 减少 → 终端刷屏的次数注意这是乘法关系而不是加法关系——每层减一半,六层叠起来是 1/64。 这就是为什么 §6 叫「级联优化」。
5.7 本章自检
- 为什么必须有一份「内存里的屏幕」?为什么不能直接问终端当前显示什么?
prevScreen是「屏幕的真实状态」还是「我认为的状态」?这个区分会导致什么问题?- cell 的宽度字段为什么只给 2 位?
- 为什么 64 位视图只能用来
fill不能用来比较?错用了会怎样(报错还是变慢)? - blit 的两个前提是什么?哪一个失效是可见 bug、哪一个只是变慢?
§6 性能:为什么优化必须分七层
上一章末尾说优化是乘法关系。这一章把七层列全,并讲清每层在赌什么。
为什么这一章重要:终端 UI 的性能问题有一个特点—— 它不是「慢一点」,而是「卡死」。 因为渲染在主线程上,一帧算不完就阻塞事件循环, 表现是用户打字没反应、流式输出卡顿、风扇狂转。
6.1 七层全景
┌─ React 层 ────────────────────────────────────────┐
│ ① 虚拟化:只为可见内容创建组件(省 Fiber 节点) │
│ ② 离屏冻结:已挂载但不可见的,返回旧引用跳过 diff │
│ ③ 滚动量化:滚动位置按 bin 取整,减少 commit 次数 │
├─ 布局层 ──────────────────────────────────────────┤
│ ④ 节点缓存:布局结果按节点缓存(WeakMap) │
├─ 绘制层 ──────────────────────────────────────────┤
│ ⑤ blit + 视口裁剪 + 硬件滚动(DECSTBM) │
├─ 缓冲层 ──────────────────────────────────────────┤
│ ⑥ Int32Array 紧凑存储 + 三种池化 │
├─ 差分/输出层 ─────────────────────────────────────┤
│ ⑦ damage 限制 + 样式差分 + 空白跳过 + BSU/ESU │
└───────────────────────────────────────────────────┘⑤⑥⑦ 已在 §5 讲过。这一章补 ①②③④,以及一个反直觉的取舍。
6.2 ① 虚拟化:为什么「不渲染看不见的东西」在终端里更重要
做法:一个几百条消息的列表,只把「视口内 + 一点缓冲区」的消息 真正挂载到 React 树里,其余用一个「占位空盒子」撑出高度。
<Box height={topSpacer} /> {/* 上面没挂载的消息,撑高度 */}
{messages.slice(start, end).map(...)} {/* 只有这些真挂载 */}
<Box height={bottomSpacer} /> {/* 下面没挂载的 */}为什么终端比 Web 更需要它: Web 里没虚拟化的列表,代价是「DOM 节点多、内存高」,浏览器还能扛。 终端里,每个挂载的节点都要参与 Yoga 布局计算——而布局计算里 还嵌着文本测量(§4.4),文本测量里还有宽字符逐字符判断(§10)。 这条链是同步的、在主线程上的。
「一点缓冲区」(overscan)为什么必要: 如果精确只挂载可见的,用户滚动一格就要挂载新组件—— 挂载是有成本的(建 Yoga 节点、跑 effect),滚动会顿。 多挂载一屏左右,滚动时就有余量。📄 原始文档提到的量级是 80 行。
6.3 ③ 滚动量化:一个非常漂亮的小设计
问题:滚动事件频率极高(每帧都可能触发)。 如果每次滚动都重算「哪些消息该挂载」并 setState,React 每帧 commit 一次。
解法:把滚动位置**取整到桶(bin)**里。 📄 原始文档提到的桶大小是 40 行:
滚动位置 0~39 → bin 0 ← 这个范围内滚动,不触发重算
滚动位置 40~79 → bin 1只有跨越桶边界时才重新计算可见范围。 效果:React commit 次数从 O(滚动行数) 降到 O(滚动行数 / 40)。
代价(要能说出来):可见范围的更新会「迟一点」—— 所以 overscan 必须大于桶大小,否则会出现「滚过去了但内容还没挂载」的空白。 这两个参数是耦合的,调一个必须想另一个。
💡 同一个思想的另一个应用:📄 原始文档还提到一个细节—— 「N 条新消息」的提示药丸用
useSyncExternalStore而不是useState + onScroll。 原理相同:useSyncExternalStore的快照函数只在返回值变化时触发重渲染。 药丸只关心一个布尔值(「用户是否滚离底部」), 于是重渲染次数从 O(滚动事件数) 降到 O(可见性切换次数)。通用范式:高频事件驱动低频状态时, 不要把事件本身变成状态,把「事件的派生结论」变成状态。
6.4 ② 离屏冻结:虚拟化之上的第二层
为什么虚拟化还不够:虚拟化解决的是「不创建不需要的组件」。 但已经挂载的那些(包括 overscan 里的), 在任何状态变化时都会重新渲染——即使它们的内容没变、也不可见。
典型场景:AI 正在流式输出,屏幕底部的消息每个 token 都在变。 上方那些旧消息在 React 树里(因为 overscan), 它们的内容不会变,但每次父组件重渲染,它们也会被 reconcile 一遍。
离屏冻结的做法(§3.6 已见过代码):不可见时返回旧的 ReactElement 引用, React 一看引用没变,整棵子树跳过。
两层的分工:
全部消息(数百条)
↓ 虚拟化:只挂载可见 + overscan
约 20~50 条进 React 树
↓ 离屏冻结:只有真正可见的更新输出
约 10~20 条真正参与渲染
↓ 视口裁剪:只有视口内的写进 Screen
终端可见行数为什么要两层而不合成一层:因为它们解决的是不同的问题—— 一个是「不创建」,一个是「已创建但不更新」。 overscan 的存在使第二层必然需要:overscan 就是「已挂载但不可见」的那批。
6.5 一个反直觉的取舍:动画帧率要分档
📄 原始文档里一个很实际的设计:spinner 动画的频率按状态分两档。
「正在请求模型」状态 → 50ms / 帧(20fps)
其他等待状态 → 200ms / 帧(5fps)为什么不统一 50ms:20fps 意味着每秒 20 次 React 重渲染。 在「等用户输入」这种非关键状态下,这个开销没有换来任何用户价值—— 用户不需要 20fps 才能确认程序还活着,5fps 足够。 降到 5fps,CPU 开销直接降 4 倍。
为什么不统一 200ms:模型正在生成时,用户处于「等待焦虑」状态, 这时候流畅的动画是在传递「系统在积极工作」的信息,值得付 CPU。
📌 这个取舍的通用形态: 动画的帧率不该是一个常量,该是「用户此刻多在意」的函数。 面试里能说出这句,比说「我们做了帧率优化」强得多。
6.6 还有一层:无障碍模式要完全关掉动画
🔬 本仓 packages/cli/src/ui/accessibility/detect.ts 做的事, 和上面那条是同一个思想的极端情形:
a11y 模式下 spinner 用静态字符、token 计数直接跳终值——完全关动画,不是降速。
为什么是「完全关」而不是「降到 1fps」: 屏幕阅读器会把每一帧的字符变化读出来。 一个转圈的 spinner 在屏幕阅读器里是「斜杠 竖线 反斜杠 横线 斜杠……」 无限循环的噪声。降速只是让噪声变慢,噪声还在。
🔬 另一个细节值得注意:终端没有 prefers-reduced-motion。 Web 里浏览器会告诉你用户想要减少动画;终端里没有这个通道, 所以只能靠环境变量显式开启(本仓是 SID_ACCESSIBILITY / SID_SCREEN_READER)。
📌 这是「终端缺失 Web 基础设施」的又一例,和 §1.3 那条同源。 面试里如果被问「终端 UI 和 Web UI 的差别」, 举「没有
prefers-reduced-motion,无障碍只能靠环境变量」这种具体例子, 比说「终端能力弱」有说服力得多。
6.7 一个真实的性能事故:O(N²) 藏在流式渲染里
🔬 这是本仓 2026-07 的一次真实排查(memory tui-perf-cpu-heat-rootcause), 它是「级联优化在某一层断掉」的完美案例。
症状:用户反馈 TUI 模式电脑发热、风扇转。
第一个反直觉:实测 idle 态 0% CPU。 渲染节流和 observer 轮询都不烧 CPU——发热只发生在流式响应期间。 这条排除掉了「框架整体很重」这个最容易的猜测。
根因(三个,这里讲渲染那个):流式 markdown 每个 token 全量重解析。
机制是这样的:流式文本被切成「已完成的稳定前缀」+「还在变的尾巴」, 理论上只需要重新解析尾巴。但渲染稳定前缀的那个组件, 它的 useMemo 依赖整个前缀文本——于是每次一个块闭合, 就对整个累积前缀重跑一遍 lexer + 每个 token 重新格式化 + 每个代码块重新高亮。
实测数字(这就是 O(N²) 的体感):
正文从 491 → 6143 字符(长了 12.5 倍)
累计渲染耗时 8ms → 765ms(涨了 95 倍)
120 个块时,单 token 峰值 8.9ms > 半个 16ms 帧预算修法与效果:缓存已渲染的稳定块,边界推进时只处理增量。 120 块 658ms → 7ms,97 倍提速,O(N²) → O(N)。
还测出了瓶颈的精确位置:96–99% 的时间在 marked.lexer, 格式化几乎免费。这一步是关键——如果没测, 很可能去优化格式化函数,然后发现没用。
📌 三条可迁移的教训:
- 「idle 不烧 CPU」不代表没有性能问题。 要按状态分别测。
- O(N²) 在终端渲染里特别容易出现,因为「累积文本」是流式的天然形态。 每次拿全量文本去做任何处理,就是 O(N²)。
useMemo的依赖数组是 O(N²) 的高发地: 你以为只处理增量,但 memo 的 key 是全量 → 每次都失效 → 每次都全量重算。 这个 bug 不报错、测试全绿、逻辑上完全正确,只是慢。
6.8 本章自检
- 为什么虚拟化在终端里比在 Web 里更关键?(答案要提到布局和文本测量)
- overscan 和滚动量化的桶大小之间是什么关系?调错了会看到什么现象?
- 虚拟化和离屏冻结解决的分别是什么问题?为什么不能合成一层?
- a11y 模式下为什么是「完全关动画」而不是「降低帧率」?
useMemo怎么会造成 O(N²)?这种 bug 会报错吗?
§7 ★ 两种屏幕模式:本领域最重要的架构分岔
这一章是全文的架构核心。 前面六章讲的都是「怎么把界面画出来」, 这一章讲的是一个在画之前就必须做的选择——而且选错了, 后面所有工作都是补救。
它值得单列一章的理由:这不是一个性能优化或代码组织问题, 而是一个产品能力的分水岭。两条路各自天然具备一组能力、 天然缺失另一组,而缺失的那组不是靠努力能补上的。
7.1 先把两条路讲清楚
主屏模式(main screen)——就是你平时用 ls、git log 时的模式:
你写出去的内容 → 追加到终端的历史(scrollback)
用户往上滚 → 看到历史(终端自己实现的,不用你管)
用户拖选文字 → 终端自己处理复制(不用你管)
程序退出 → 内容还在屏幕上备用屏模式(alternate screen)——vim、htop、less 用的模式:
\x1b[?1049h → 终端抽出一张干净的新画布,你完全接管
你可以在任意位置画任意东西,随时改
用户往上滚 → 什么都没有(没有 scrollback)
用户拖选文字 → 需要你自己实现选择和复制
程序退出(\x1b[?1049l) → 新画布扔掉,露出原来的内容7.2 两条路各自免费得到什么、必然失去什么
这张表是本章的核心,值得记住:
| 能力 | 主屏 | 备用屏 |
|---|---|---|
| 无限历史回看 | ✅ 免费(终端的 scrollback) | ❌ 要自己实现虚拟滚动 |
| 鼠标拖选复制 | ✅ 免费(终端原生行为) | ❌ 要自己实现整套选择引擎 |
| 退出后内容保留 | ✅ 免费 | ❌ 天然消失 |
| 固定位置的输入框 | ❌ 做不到干净(见下) | ✅ 天然 |
| 精确的鼠标坐标 | ❌ 受 scrollback 偏移影响 | ✅ 坐标就是屏幕坐标 |
| 任意位置重绘 | ❌ 物理上不行(进了 scrollback 的改不了) | ✅ 天然 |
| 复杂布局(对话框插在中间) | ❌ 很难 | ✅ 天然 |
注意这张表的对称性:它几乎是完全互补的。 一边免费的,另一边就要自己造;一边天然的,另一边就做不到。 这不是巧合——两边免费的能力,本质上都是「谁拥有那片格子」的直接推论:
主屏:格子属于终端,所以终端的能力(滚动、选择)你免费得到, 但你改不了已经交出去的格子。备用屏:格子属于你,所以你能任意改, 但终端的能力你一个也拿不到,全部要自己重造。
把这句话说清楚,这一章就掌握了。
7.3 为什么主屏做不到「干净的固定输入框」
这是最容易被低估的一条,值得展开——因为很多人会觉得「不就是每次重画吗」。
主屏模式下你想做一个底部输入框,只能这样:
写:历史消息...
写:> 用户输入 ← 输入框在最后一行
用户按了一个键
→ 擦掉最后一行(\x1b[2K\r)
→ 重写:> 用户输入a只要输入框只占一行,这个方案能用。 问题出在三处:
- 输入框变多行时(用户输入换行、或文本太长自动折行), 你要擦掉 N 行 —— 而 N 需要你自己算(要知道终端宽度、要算宽字符、要算换行)。 算错一行,就会擦掉一行历史消息,而且永久性擦掉。
- 输入框上面还有东西时(spinner、权限确认框),整个「擦 N 行再重画」 的范围会变化,且范围的计算横跨多个组件——回到 §3.1 那个崩溃点。
- 一旦内容超过一屏,被擦的行可能已经滚进 scrollback。
\x1b[2A(上移 2 行)在这种情况下的行为取决于终端, 而你无法知道自己现在离视口顶部有多远。
所以主屏路线要成立,必须有一个额外机制: 把「不再变化的内容」和「还在变的内容」彻底分离。 这就引出下一节——主屏模式真正的解法。
7.4 主屏模式的解法:Static 分层
核心思想一句话:
已完成的内容「打印一次就永久放手」(print and forget), 只有「还在变的一小块」留在可重绘区。
┌────────────────────────────────────┐
│ 历史消息 1 │
│ 历史消息 2 ← 这些已经 print 出去 │ ← Static 区
│ 历史消息 3 进了 scrollback │ 不再重绘,零成本
│ ... 永远不再碰 │ 终端原生选择/滚动可用
├────────────────────────────────────┤
│ AI 正在输出的这一条(每 token 变) │ ← 动态区
│ spinner (esc 取消, 12s) │ 只有这几行走 log-update
│ > 用户输入 │ 擦掉重画的范围是可控的
└────────────────────────────────────┘关键收益:需要「擦掉重画」的只有底部几行,范围小且固定。 上面的历史一个字节都不动——所以边流式输出边拖选历史文字是可行的。
🔬 本仓的实现(Static 组件只有 32 行,但它是整个主屏路线的支点):
packages/tui-renderer/src/_vendor/Static.tsx 32 行
packages/cli/src/ui/components/MainScreenLayout.tsx ← 消费方Static 的机制:items 数组的引用不变时 memo 跳过重渲染。 所以应用层有一条硬约束:「已完成区」的 items 数组必须引用稳定, 不能每帧重建——否则 Static 每帧都重新打印,整个机制失效 (而且不报错,只是变成灾难性的重复输出)。
7.5 一次真实的架构决策:本仓为什么从备用屏切到主屏
🔬 本仓 adr/ADR-040(2026-06-04)记录了一次完整的路线切换, 这个案例的教学价值极高,因为它把「用户痛点 → 根因 → 四个被否决的方案」全写下来了。
用户痛点(一句话):
sid-code 用户必须按
Ctrl+S进入 Copy Mode 才能用鼠标选中复制文字, 而对照产品可以边流式输出边直接拖选,无需任何模式切换。
根因(这一步最漂亮):
sid-code:永远进 alt-screen(硬编码 alternateBuffer: true)
+ 全程抢鼠标(发 ?1002h/?1006h)
+ 但没有自绘的选择引擎
↓
抢了鼠标却不提供选择功能 → 用户没法复制
↓
于是被迫发明 Copy Mode(临时把鼠标还给终端)「抢了鼠标却没有自绘选择系统」——这就是根因。 注意它不是「缺少一个功能」,而是**「做了一个选择却没付它的代价」**: 抢鼠标是 alt-screen 路线的必要动作,自绘选择是 alt-screen 路线的必付成本, 只做前者不做后者,就掉进了两条路之间的缝里。
决策:默认改成主屏 Static 模式。
四个被否决的方案,每一个的否决理由都值得学:
| 方案 | 否决理由 | 可迁移的教训 |
|---|---|---|
① 只把 alternateBuffer 翻成 false(最小改动) | 内容区整个是虚拟列表(全动态区),没有 Static 分层。关掉 alt-screen 后每个 token 都 eraseLines(整屏) → 全屏闪烁;且动态区永不进 scrollback → 「原生滚动看历史」也失效 | 「翻一个 flag」的最小改动,往往是最贵的:它假设架构已经准备好了 |
| ② 移植对照产品的自绘选择引擎到 alt-screen | 那个引擎(🔬 本仓现在 842 行)焊死在自研渲染底座的 screen buffer 上(直接改 frame cells)。当时用的标准 ink 不暴露 cell 级 API → 要移植就得连底座一起 fork | 能力的可移植性取决于它焊在哪一层。焊在 cell 级的东西,不换底座搬不动 |
③ 保留 alt-screen + 自绘选择 + OSC 52 复制 | ① 选择是程序绘制而非终端原生;② OSC 52 在 iTerm2 默认关闭、tmux 要配置,跨终端不稳 | 默认关闭的能力 = 对用户不存在(§2.4 那条判据的实战应用) |
| ④ 保持现状 + Copy Mode(这是 3 个月前自己的结论) | 用户实测判定 Ctrl+S 是致命交互缺陷;且已确认对照产品的默认就是主屏 Static,证明这条路是成熟可交付的、不是实验性的 | 推翻自己的旧结论需要新证据,而「用户实测」和「竞品已验证」是两种不同的新证据 |
诚实记录的牺牲项(ADR 里单列了一节,这个习惯值得学):
默认模式没有应用内鼠标滚轮/拖拽滚动条(滚动的唯一消费者是鼠标, 关鼠标即失效)→ 退化为终端原生 scrollback(无限历史 + 原生选择,实际更优)。
注意最后那句「实际更优」:这是一个牺牲项反而变成收益的情形, 但 ADR 仍然把它记在「牺牲项」标题下——因为它确实是能力的丧失, 只是恰好被更好的东西替代了。这种诚实是 ADR 的价值所在。
7.6 两条路怎么选:一张判据表
别按「哪个更先进」选,按「你的产品最不能丢什么」选:
| 如果你的产品…… | 选 | 理由 |
|---|---|---|
| 输出是用户要复制走的内容(代码、日志、答案) | 主屏 | 原生选择复制是刚需,自绘选择永远差一档 |
| 用户需要回看很久以前的输出 | 主屏 | 终端 scrollback 是无限的,你自己实现的不是 |
| 退出后内容应该留在屏幕上 | 主屏 | 备用屏天然清空 |
| 是仪表盘 / 监控 / 全屏编辑器 | 备用屏 | 固定布局是核心需求,历史不重要 |
| 需要复杂的多区域布局(侧栏、弹窗、分屏) | 备用屏 | 主屏做不到 |
| 需要鼠标点击交互(点按钮、点标签页) | 备用屏 | 主屏的鼠标坐标不可靠 |
给 coding agent 的答案(这是一个具体结论,面试可以直接用): 默认主屏,备用屏作为 opt-in。 理由是 coding agent 的产出是「用户要复制走的代码和解释」, 而且会话可能持续数小时、需要回看——这两条都指向主屏。 全屏布局的诱惑是真实的(界面更整齐、更「像个 app」), 但它换掉的是产品的核心交付路径。
7.7 一个必须知道的连带事实:两条路要同时支持
上面讲得像是「选一条」,但成熟实现的现实是两条都要有:
🔬 本仓 fullscreen.ts:44:const alternateBuffer = opts?.alternateBuffer ?? false ——默认主屏,但 alt-screen 路径完整保留(ADR-040 明写「零删除」)。
为什么必须两条都留:
- 主屏是默认(服务绝大多数用户)
- 备用屏服务两类真实需求:需要虚拟滚动/鼠标滚轮的用户、以及未来的全屏视图
代价(诚实说):两套布局路径要各自测试, 而且很多组件要在两种模式下都正确。 🔬 本仓的做法是在一处分岔:App.tsx 按 alternateBuffer 二选一 (MainScreenLayout / DefaultAppLayout),分岔点只有一个—— 这是控制这种双路径成本的关键:分岔越晚、越集中,成本越低。
7.8 本章自检
- 用一句话说清「为什么主屏能免费得到滚动和选择,但改不了已输出的内容」。
- 主屏模式下做固定输入框,最危险的失败形态是什么?(提示:不是显示错,是数据丢)
- Static 分层的核心约束是什么?违反了会怎样(报错还是静默灾难)?
- 「抢了鼠标却没有自绘选择引擎」——为什么这不是「缺个功能」而是「掉进缝里」?
OSC 52看起来能解决复制问题,为什么被否决?这条判据还能用在哪?- 给一个 coding agent 选屏幕模式,你选哪个?理由要落在「产品交付什么」上。
§8 ★ 流式渲染与闪烁:本文技术含量最高的一章
上一章选好了屏幕模式。这一章处理主屏 Static 模式下最难的那件事: AI 一个 token 一个 token 地吐字,界面怎么不闪。
为什么这一章值钱:它是一个完整的「推翻自己」案例—— 本仓为这个问题做了一版修复,这版修复本身后来被认定为另一个 bug 的根因。 这个反转过程比任何抽象论述都有教学价值, 因为它示范了「修好了症状但搞错了机理」会付出什么代价。
8.1 先理解闪烁是什么
闪烁不是「屏幕闪了一下」这么模糊的东西,它有精确的机理:
正常情况(增量更新):
上一帧 5 行 → 这一帧 6 行 → 只需要「在末尾追加第 6 行」
用户看到:内容平滑地长出来
闪烁情况(全屏重打):
框架判断「没法增量更新了」
→ 擦掉整个屏幕(\x1b[2J 或 eraseLines(N))
→ 重新写全部内容
用户看到:整屏消失一瞬间,然后重新出现 ← 这就是闪关键认知:闪烁 = 触发了「全屏重打」这条路径。 所以「防闪烁」这件事的本质,是避免让 diff 算法走进那条路径。
🔬 本仓的源码把这件事写得极其明白——函数名本身就是文档:
packages/tui-renderer/src/log-update.ts:451
function fullResetSequence_CAUSES_FLICKER(next, reason, stylePool, ...)函数名里直接带 _CAUSES_FLICKER。 它在文件里被调用 5 次 (:137、:204、:228、:251、:352),每次都带一个 reason 参数 ("resize" / "offscreen")。
📌 这个命名值得单独夸一句,也值得抄: 把「这个函数会导致用户可见的坏结果」写进函数名, 意味着任何人调用它时都无法假装不知道后果。 比写在注释里强得多——注释会被跳过,函数名不会。 这是「让危险的东西看起来危险」的一个范例。
8.2 什么时候会触发全屏重打
🔬 读 log-update.ts 的 5 个调用点,触发原因归成两类:
第一类:"resize"(终端尺寸变了) 没得商量,宽度变了所有换行都变了,必须全部重画。
第二类:"offscreen"(要改的行已经滚进 scrollback 了)
这一类是主屏模式的固有难题,值得看懂。🔬 源码注释解释得很清楚(:179-199):
viewportY tells us how many rows are in scrollback from content overflow.
Additionally, the cursor-restore scroll pushes 1 more row into scrollback.
We need fullReset if any changes are to rows that are now in scrollback.翻译成人话:
你的内容有 50 行,终端只有 40 行高
→ 前 10 行已经滚进 scrollback 了
→ diff 算出「第 3 行要改」
→ 但第 3 行在 scrollback 里,你物理上碰不到它
→ 唯一的办法:全屏重打 ← 闪注意这里有一个反直觉的情形(源码专门处理了): :196 的注释说——「本该在 scrollback 里的内容,现在应该可见了」 (内容变短时)。终端的清屏操作没法把 scrollback 的内容拉回视野, 所以这种「收缩」也要全屏重打。
所以主屏模式下防闪烁的核心判据是一句话:
让「已经滚进 scrollback 的那些行」逐字节永不改变。
这句话是本章后面所有内容的地基。
8.3 第一版方案:按视口高度截断(后来被推翻)
🔬 本仓最初的解法(memory main-screen-streaming-flicker-rootcause 记录):
当时的机理判断(这部分是对的): 用标准 ink 时,动态区高度 ≥ 终端行数会触发每帧全屏重打。
当时的解法:既然动态区太高会闪,那就把动态区封死在视口内—— 按视口高度对流式内容做 tail 截断(只显示最后 N 行)。
结果:闪烁确实没了。✅ 上线。
但是:随后出现了一个「反复修不好」的 bug—— 「流式输出时只能看到尾部一小段,要等生成完才能看到全文」。
🔬 memory 里这句话是全篇最值钱的一句:
历次「修复」都在调裁剪预算,从没质疑裁剪本身该不该存在。
每次有人报「看不到全文」,工程师就去调那个截断预算 (tailToFitByBlocks / computeStreamBudgets / estimateChromeLines ——三个函数名本身就是「预算被反复调整」的化石证据)。 没人问:为什么这里要有截断?
8.4 推翻:底座换了,workaround 的前提没了
2026-06-26 的重大更正。真相是这样的:
时间线(commit 顺序是关键证据):
commit 6ff3d4f 加 tail 截断(因为标准 ink 会全屏重打)
↓
commit 0790f37 渲染底座换成 fork 版 ← 晚于上面那个
↓
fork 版的 log-update:纯增长帧走增量追加,旧行自然滚入 scrollback,
不触发 full-reset
↓
于是 tail 截断变成纯负债:既没必要,又把内容裁没了核心教训(这条是通用的,值得背下来):
🔬 改底座/依赖之后,必须回头重评所有为旧底座加的 workaround。
为什么这个坑如此隐蔽:
- workaround 的代码没有任何问题,它在自己的假设下完全正确
- 假设变了,但假设不在代码里——它在一个已经关闭的 issue 里、在某人的记忆里
- 而且 workaround 仍然在「工作」(它确实还在截断), 只是它防的那件事已经不会发生了
- 测试全绿:截断函数的单测测的是「截断得对不对」,而不是「该不该截断」
📌 这类 bug 的通用形态:「为了绕过 A 而加的 B,在 A 消失后变成新的 A」。 检测方法只有一个:每个 workaround 都要在代码里写清它绕的是什么, 这样底座变化时能回头检索。 「注释写清前提」在这里不是文档洁癖,是唯一的检测手段。
8.5 正解:稳定前缀 / 不稳定后缀切分
推翻之后的正确解法,也是这一章要教的核心技术。
思想:不要按「高度」切,要按 「解析上是否已经确定」 切。
AI 已经吐出的文本:
┌──────────────────────────────────────────────────────┐
│ ## 标题 │
│ │
│ 这是第一段,已经完整了。 │ ← 稳定前缀
│ │ (stable prefix)
│ ```typescript │ 已经不会再变
│ const x = 1 │ 冻结、缓存
│ ``` │
│ │
├──────────────────────────────────────────────────────┤
│ 这是最后一段,还在 │ ← 不稳定后缀
└──────────────────────────────────────────────────────┘ (unstable suffix)
每个 token 重解析怎么找那条线:🔬 本仓 StreamingMarkdown.tsx 的 computeStreamSplit(text, committed) 用 markdown lexer 取最后一个 token 的 raw 起点作为边界。 最后一个 token 是唯一可能还没写完的(可能是段落写一半、代码块没闭合), 它之前的都已经语法上闭合了。
三条不变量(都有单测守着):
① 前缀 + 后缀 == 原文 (不丢字符 —— 这条直接防「看不到全文」的旧 bug)
② 边界单调不回退 (已经冻结的绝不解冻)
③ 未闭合的代码围栏不切断 (否则前缀里有个没闭合的 ``` ,渲染会崩)为什么这个方案能防闪烁(要能推出这个链条,这是本章的核心推理):
冻结前缀 → 它对应的终端行逐字节稳定
→ 即便偶发的非增长帧触发重新解析,
也不会改变「已经滚入 scrollback 的早期行」
→ 不命中 log-update 的「scrollback 行变化 → full-reset」判据
→ 不闪🔬 memory 里这句话点破了本质,也是这一章最该记住的一句:
切分是闪烁安全机制,不是性能优化。
为什么这个区分重要:如果你以为它是性能优化, 那么在「性能够用」的时候你会觉得可以去掉它——然后闪烁回来了。 它顺带确实带来了 97 倍的性能提升(§6.7), 但那是副产品,不是目的。
注意这个方案渲染完整文本,没有任何视口封顶。 和第一版的根本区别:第一版靠减少内容防闪, 正解靠保证已有内容不变防闪。同样的目的,一个牺牲功能,一个不牺牲。
8.6 还有一个残留问题,以及为什么它可以接受
🔬 memory 里诚实地记了一条「已知残留(非 bug)」:
流式完成的瞬间:streamingText 被清空
→ 动态区从「超过视口」收缩
→ 命中 log-update 的「从 scrollback 收缩 → full-reset」(§8.2 那个反直觉情形)
→ 有一次性的重绘(闪一下)为什么可以接受,理由是量化的:
一次性单闪 ≠ 30fps 的持续闪烁风暴而且:对照产品的 Static 模型有同款问题。 「竞品也这样」在这里不是甩锅,是一个有效的判据—— 它说明这是该架构的固有代价,不是本实现的缺陷。
📌 这段的教学价值在于「怎么给残留缺陷定级」: 不要说「还有个小问题」,要说清 ① 触发条件(流式结束瞬间)② 频率(一次性) ③ 与被解决问题的量级对比(vs 30fps 风暴)④ 是否架构固有。 四条齐了,「可接受」才是一个结论而不是一个借口。
8.7 一条相关的铁律:Static 区的折叠必须同步
这是 §7.4 Static 机制的一个必然推论,而且本仓踩过。
🔬 memory write-diff-fold-static-safe 记录的事故:
症状:write 工具新建一个大文件时,把整份内容灌进 TUI, 没有 … +N lines (ctrl+o to expand) 折叠。
根因(两层):
ToolResultDisplay里isDiff && hasPatch分支直接渲染 DiffRenderer, 绕过了所有折叠机制DiffRenderer的折叠只折「未变更的上下文行」—— 新建文件全是+行、零上下文,什么都折不掉
🔬 而且有一个很典型的细节: DiffRenderer 尾部注释写着「高度限制交由上层处理」= 设计意图是对的, 但调用方从没接上。这是「未完成的接线」,不是「缺能力」。
Static 安全铁律(这条是本节的核心):
工具结果一完成即被
<Static>一次性打印进 scrollback,此后无法重渲。 所以折叠必须同步一次成型(渲染前就裁好行数)。
具体的坑:第一版修复用了一个靠 ResizeObserver 异步测高的折叠组件。 后果链条是这样的:
首帧:ResizeObserver 还没测出高度 → contentHeight = 0
→ 判定「没溢出」 → 不折叠
→ 整份内容先落进 scrollback
→ 然后异步测高完成,判定该折叠了
→ 但内容已经在 scrollback 里,擦不掉
= 大文件永久污染回滚区为什么这个坑值得单列:那个异步折叠组件在动态区完全好用 (动态区每帧重绘,测完高下一帧就折了)。 同一个组件,在两个区域里一个是正确方案、一个是灾难。
🔬 修完之后还做了一件很关键的事:把那段异步折叠的死代码删了。 理由写在 memory 里:
它不只是冗余,更是陷阱:未来有人给它传参数期望折叠, 就会用异步路径重蹈 Static 灌屏覆辙。
📌 通用教训:一个「在 A 场景正确、在 B 场景灾难」的 API, 如果类型系统区分不了 A 和 B,那它就是个陷阱。 处理方式有两种:让类型能区分,或者删掉那条路。 留着它并写注释「B 场景别用」是最差的选择—— 注释不会在 code review 里自动出现在读者眼前。
8.8 顺带一条:折叠不能一刀切
🔬 同一次审计(两个并行探索 agent + 直接排查)得出的分类, 这个分类本身是一份可复用的判据:
| 类别 | 该折叠吗 | 理由 |
|---|---|---|
| bash 输出、文件读取、搜索结果 | ✅ 折 | 自动生成的批量输出,用户扫读即可 |
| 错误正文、子代理通知、思考块 | ✅ 折 | 同上 |
| assistant 回复 | ❌ 全显 | 这是主内容/交付物。而且 Static 下 ctrl+o 无法重渲已打印项,折了反而展不开 |
| 用户输入 | ❌ 全显 | 用户自己写的东西 |
| 计划评审 | ❌ 全显 | 决策内容 |
| 权限确认框 | ❌ 全显 | 尤其 reason 字段截断会诱导误批准 ← 这是安全问题 |
判据一句话:
折叠只针对「自动生成、用户扫读即可」的批量输出; 凡决策/确认/主交付内容一律全显。
注意权限确认框那一条:截断一个「我要删除这些文件」的理由说明, 会让用户在信息不全的情况下点「允许」。 这已经不是 UI 问题,是安全问题—— 这是「更安全 ↔ 更省屏幕空间」这个 trade-off 里,安全必须赢的一格。
8.9 本章自检
- 用机理解释「闪烁」是什么,不要用「闪了一下」这种描述。
- 主屏模式下触发全屏重打的两类原因是什么?第二类为什么是固有难题?
- tail 截断防住了闪烁,为什么它是错的?错在哪一层(症状 / 机理 / 实现)?
- 「改了底座要重评所有 workaround」——为什么这类 bug 的测试会全绿?
- 稳定前缀切分为什么能防闪烁?把推理链条完整说一遍。
- 「切分是闪烁安全机制,不是性能优化」——搞混了会导致什么后果?
- 同一个异步折叠组件,为什么在动态区好用、在 Static 区是灾难?
- 为什么权限确认框的理由不能折叠?这属于哪两个方向的 trade-off?
§9 输入:从字节流到结构化事件
前面八章都在讲输出。这一章讲输入——它是一个独立的、 同等复杂度的子系统,而且有一个非常反直觉的核心难点。
9.1 问题的形态:你只有一根字节管
终端的输入就是 stdin 上的一串字节。 没有 keydown 事件, 没有 event.key,没有 event.ctrlKey。你拿到的是:
用户按 a → 收到 0x61
用户按 Ctrl+A → 收到 0x01 ← 控制字符
用户按 ↑ → 收到 0x1b 0x5b 0x41 ("\x1b[A")
用户按 Shift+Enter → 收到 "\x1b[13;2u" ← 只在支持的终端里
用户粘贴 "hi" → 收到 "\x1b[200~hi\x1b[201~"
终端回答你的查询 → 收到 "\x1b[?2026;1$y" ← 这不是用户输入!注意最后一行:终端对你的能力查询的回答,也从 stdin 进来, 混在用户按键里。你必须能把它们分开。
9.2 四个真实难点
难点一:转义序列可能被切成两半送到。
网络延迟(SSH)、终端缓冲、进程调度,都可能让 \x1b[1;2A 分两次 data 事件到达:
第一次 data 事件: \x1b[1;
第二次 data 事件: 2A如果你收到第一次就急着解析,会得出「用户按了 Esc」+ 一堆乱码。
难点二(本节最反直觉的一条):Esc 本身无法立即确定。
因为 Esc 的字节 0x1b 同时是所有转义序列的开头。 用户按了 Esc 键,和用户按了 ↑(\x1b[A),前一个字节完全一样。
唯一的区分办法是等一会儿:
- 如果
0x1b之后短时间内没有更多字节 → 是Esc键 - 如果紧跟着来了
[A→ 是方向键
🔬 本仓 components/App.tsx:153 就是这个超时:
readonly NORMAL_TIMEOUT = 50 // 普通转义序列的短超时
readonly PASTE_TIMEOUT = 500 // 粘贴操作用更长的超时所以「按 Esc 到程序响应」有 50ms 的固有延迟,这是协议决定的,不是实现不好。
这个延迟为什么是 50ms 而不是 5ms 或 500ms——两边都有代价:
太短(如 5ms):SSH 高延迟下,方向键的后续字节还没到就超时了
→ 用户按 ↑,程序以为按了 Esc(取消了正在进行的任务!)
太长(如 500ms):用户按 Esc 要等半秒才有反应,体感迟钝🔬 而且源码里记了一个更阴的失效情形(App.tsx:299 附近的注释):
A heavy render blocked the event loop past App's 50ms ...
一次很重的渲染阻塞事件循环超过 50ms,会导致这个超时误判。 也就是说:渲染性能问题会伪装成输入 bug。 这是 §6(性能)和 §9(输入)之间一条真实的耦合, 也是「界面卡的时候按键行为变怪」的一个真实成因。
难点三:粘贴内容里可能包含看起来像转义序列的字节。
用户从别处复制了一段包含 \x1b[A 字面量的文本(比如一份 ANSI 文档), 粘贴进来。如果你按转义序列解析,就会把它当方向键执行。
解法就是括号粘贴(bracketed paste): 开启 \x1b[?2004h 后,终端会把粘贴内容用 \x1b[200~ / \x1b[201~ 包起来。 看到开始标记后,直到结束标记之前的一切都当字面文本,不做转义解析。
这是「区分打字和粘贴」的唯一可靠手段—— 猜测式方案(比如「短时间内来了很多字符就是粘贴」)在慢速粘贴和快速打字时都会误判。 而且它有第二重价值:粘贴 1000 行代码时, 你可以跳过逐字符的编辑逻辑(自动补全、语法检查),一次性插入。
难点四:键盘协议有三套,要同时支持。
传统(legacy): \x1b[A = ↑ \x1b[1;2A = Shift+↑
CSI u(Kitty 协议): \x1b[13;2u = Shift+Enter (13 = Enter 的 keycode,2 = shift)
modifyOtherKeys: \x1b[27;5;99~ = Ctrl+C (5 = ctrl,99 = 'c')为什么需要后两套:传统编码表达不了某些组合。 最经典的例子:Tab 和 Ctrl+I 在传统编码里都是 0x09, Enter 和 Ctrl+M 都是 0x0d。所以传统终端上 「Shift+Enter 换行、Enter 提交」这个需求物理上做不到。
9.3 分层架构:四层
stdin 原始字节
↓
┌──────────────────────────────────────────────────────┐
│ ① 分词 + 协议解析 🔬 parse-keypress.ts (808 行) │
│ • 切出完整的转义序列(含不完整序列的超时处理) │
│ • 三套键盘协议解析 │
│ • 识别「终端响应」并与用户输入分离 │
│ • 括号粘贴 → 打上 isPasted 标记 │
│ 产出:ParsedKey { name, ctrl, shift, meta, isPasted }│
└──────────────────────┬───────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ ② 事件对象化 🔬 events/ (12 个文件) │
│ ParsedKey → InputEvent / KeyboardEvent / MouseEvent │
│ 修饰键标准化、特殊键映射、鼠标坐标解析 │
└──────────────────────┬───────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ ③ 分发:捕获 / 冒泡两阶段 🔬 events/dispatcher.ts │
│ 从目标节点到根收集监听器 → 捕获(外→内)→ 冒泡(内→外)│
│ 支持 stopPropagation / stopImmediatePropagation │
└──────────────────────┬───────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ ④ 快捷键解析 🔬 packages/cli/src/ui/keybindings/ │
│ 和弦(chord)、上下文感知、优先级、用户自定义合并 │
└──────────────────────────────────────────────────────┘9.4 为什么第 ③ 层要照搬浏览器的捕获/冒泡
新手会问:为什么不用 Node 的 EventEmitter?
因为 EventEmitter 是扁平的:所有监听器平等接收事件,没有传播控制。 但终端 UI 是有层次的:
权限确认框打开时,Esc 应该关闭它,而不是取消整个任务
搜索框打开时,↑↓ 应该在搜索结果里移动,而不是翻输入历史这需要「内层能拦截事件、阻止外层处理」的能力——这正是冒泡模型 + stopPropagation。
🔬 顺带一个必须配套的机制:焦点栈(focus.ts)。
输入框有焦点
→ 权限确认框打开,焦点移到它(压栈)
→ 用户确认,对话框关闭
→ 焦点自动恢复到输入框(弹栈)没有栈的话,对话框关闭后焦点会丢——用户打字没反应, 得手动点一下或按 Tab。这是那种「有了没人夸、没有天天骂」的机制。
9.5 一个不属于渲染但会杀死进程的坑:EIO
🔬 本仓的一次真实崩溃(研究文档 EIO崩溃-终端关闭导致Ink渲染管线崩溃.md), 它是输入/输出层最容易漏的一条,而且后果是进程直接死。
症状:在 VS Code 集成终端里跑了 14 轮 LLM 调用后进程突然崩溃, 错误是 EIO: i/o error, write。
链条:
VS Code 关闭集成终端 → PTY 关闭 → process.stdout 的 fd 失效
↓
渲染管线在 React commit 阶段同步写 stdout
↓
运行时返回 EIO
↓
❗ 流上没有 'error' 监听器 → 错误作为同步异常抛出
↓
穿透 React 的同步 commit → uncaughtException → 进程崩修法(两条,缺一不可):
// ① 在 render() 之前注册,让错误变成事件而不是同步异常
process.stdout.on('error', handler)
process.stderr.on('error', handler)
// ② uncaughtException 里的 process.exit() 也可能再抛 EIO
// → 需要回退到 SIGKILL🔬 本仓 cli.ts:984 的注释正是这一条: 「终端已死时 process.exit() 可能抛 EIO,此时回退到 SIGKILL」。
两个值得单独记的点:
① EPIPE 和 EIO 是两个不同的错误,覆盖一个不够。 研究文档里点破了这条:对照产品只处理了 EPIPE(管道断开), 没处理 EIO(终端关闭)——而 VS Code 集成终端关闭时返回的恰恰是 EIO。
📌 通用教训:
process.stdout.on('error')这一行代码, 在正常运行时永远不执行。它是那种「不加也测不出来」的防线—— 只在用户关掉终端窗口的那一刻才有用,而这个场景不在任何测试用例里。 这是 §12「静默失效」的又一个变体:缺失的防线不产生任何症状,直到它需要时。
② 有一个更隐蔽的兄弟情形:研究文档记录了另一个会话 「有心跳但没有 crash.json」——说明进程被 SIGKILL 直接杀掉, uncaughtException 处理器根本没机会跑。 所以崩溃诊断不能只依赖「异常处理器写文件」这一条路, 还要有外部的心跳/PID 扫描兜底。
9.6 应用层的输入体验:三条能直接用的规则
🔬 本仓 packages/cli/src/ui/CLAUDE.md 的 L4 层沉淀了一套交互规范, 挑三条**最能体现「终端特殊性」**的:
① 永不丢失用户输入。
ESC 取消流式 → 自动回填刚才的输入。 数据流:提交时 stash → ESC 且过守卫则标记待恢复 → loading→idle 边沿消费回填。
为什么这在终端里特别重要:Web 里用户输错了可以按浏览器后退、 可以从表单缓存恢复。终端里没有任何后备—— 输入框一清空,用户敲的那 200 字就是真的没了。
② 提示要渐进衰减。
同一条 onboarding 提示不要每次都显示。 🔬 本仓做法:按
hintKey计数持久化,shouldShowHint(key, maxShows)。 一次性提示 =maxShows: 1,看 N 次收敛 =maxShows: N。
为什么终端更需要这条:终端的屏幕空间比 Web 稀缺得多—— 一行提示占掉的是内容的位置。而且终端用户的「老手化」速度很快。
③ 确认框必须三条路径并存:↑↓+Enter / 数字直达 / 字母直达。
🔬 这条的踩坑记录特别有教学价值:
原状态:
if (!key.insertable) return false把方向键在第一步就挡掉了, 于是同一个 TUI 里,「提问选择」能上下选、「确认框」却不能—— 是两套交互模型。 只给字母会逼用户记键位;只给方向键则丢掉 y/n/a 的肌肉记忆和文档承诺的契约。
还有一个大小写敏感的坑,这条纯属终端协议的坑:
🔬 终端上报
Shift+A时key.name仍然是"a",靠key.shift区分。 所以匹配必须同时比对字母和 shift, 否则裸a(会话档授权)会把Shift+A(持久档授权)截胡。
注意这个 bug 的后果:用户想「永久允许」,结果只得到「本次会话允许」—— 或者反过来,用户想「本次允许」却给了永久权限。 这是一个权限语义被输入层 bug 改写的例子,比 UI 错位严重得多。
9.7 本章自检
- 用户按
Esc和按↑,第一个字节有区别吗?程序怎么区分? - 那个 50ms 超时调大调小分别有什么代价?
- 「渲染慢会导致按键行为变怪」——机理是什么?
- 括号粘贴解决了什么问题?为什么猜测式方案不行?它还有什么额外好处?
- 为什么不用
EventEmitter而要照搬捕获/冒泡? process.stdout.on('error')这行代码在正常运行时执行吗?那它防的是什么?EPIPE和EIO有什么区别?只处理一个会漏掉什么场景?- 终端上报
Shift+A时key.name是什么?不处理会导致什么权限后果?
§10 宽度与文本:为什么 .length 会让你的界面歪掉
这一章讲一个看起来最简单、实际最容易出错的问题: 「这段文字占几列?」
为什么它值得单列一章:因为它的错误形态是渐进的、不报错的、 而且用户会归因成「这个软件做得糙」。
10.1 三个数字,很少相等
以字符串 "中a👨👩👧" 为例:
"中a👨👩👧".length // 9 ← UTF-16 码元数(JS 的 .length)
[..."中a👨👩👧"].length // 7 ← 码点数
stringWidth("中a👨👩👧") // 5 ← 终端列宽 ← 你要的是这个三个数字对应三个不同的概念:
| 概念 | 是什么 | 什么时候用 |
|---|---|---|
| UTF-16 码元 | JS 字符串的内部存储单元 | 几乎从不(这是实现细节泄漏) |
| 码点(code point) | 一个 Unicode 字符 | 遍历字符时 |
| 字素簇(grapheme) | 用户感知的「一个字」 | 光标移动、删除一个字时 |
| 列宽(column width) | 终端占几格 | 所有对齐、换行、截断 |
新手的默认选择是 .length,而它三个都不是你要的。
10.2 错误的表现形态:渐进歪斜
为什么这个 bug 特别烦:它不会报错,而是让界面逐渐歪掉。
用它算对齐:
┌──────────────┐
│ 文件名 大小 │ ← ASCII 行,对齐正常
│ index.ts 1.2K │
│ 中文文档.md 3.4K │ ← 中文行,右侧列往右漂了
│ 说明书.txt 890B│ ← 漂的量取决于中文字数,每行不同
└──────────────┘
用它算换行:
你以为 80 个字符正好一行 → 实际 80 个中文占 160 列 → 折成两行
→ 你算的高度是 1 行,实际是 2 行 → 后面所有内容位置全错
→ 主屏模式下:擦除行数算错 → 擦掉了历史内容(§7.3)注意最后那条:宽度算错在主屏模式下会吃掉用户的内容。 这不是显示问题了。
10.3 为什么「算列宽」这么难
四个独立的难点,每个都能单独坑你:
① 宽字符(East Asian Width)。 CJK 汉字、日文假名、韩文、全角标点占 2 列。 Unicode 有一份属性表标注每个字符是 Narrow / Wide / Ambiguous。
「Ambiguous」那一档是灾难:这些字符(希腊字母、部分符号) 在东亚字体环境下占 2 列,在西文环境下占 1 列—— 同一个字符,同一份 Unicode 表,答案取决于用户的终端配置。
🔬 本仓 stringWidth.ts 的选择写在注释里:
The implementation uses eastAsianWidth directly with ambiguousAsWide: false,
which correctly treats ambiguous-width characters as narrow (width 1) as
recommended by the Unicode standard for Western contexts.这是一个「必须选一边,且知道自己选了什么」的决策—— 选 false 意味着在纯 CJK 环境的某些字符上会算窄,是有意识的取舍。
② Emoji 和字素簇。👨👩👧(一家三口)是 5 个码点用零宽连接符(ZWJ)拼起来的, 显示成 1 个 emoji、占 2 列。 必须先按字素簇分段,再算每段的宽度—— 🔬 本仓用 Intl.Segmenter(_vendor/intl.ts 的 getGraphemeSegmenter)。
③ ANSI 序列不占宽度。"\x1b[31m中\x1b[0m" 的列宽是 2,不是 11。 算宽度前必须 strip ANSI——这一步漏了, 所有带颜色的文本宽度都会虚高,界面会普遍性歪掉。
④ 各终端的 wcwidth 表版本不同。 这是最无解的一条:Unicode 每年新增 emoji, 终端内置的宽度表更新滞后。同一个新 emoji, 你算 2 列、终端画 1 列——之后这一行所有内容错位一格。
🔬 原始研究文档提到实现里有「宽字符补偿」逻辑,正是为这条。 这类补偿只能靠「已知终端 + 已知字符」硬编码,没有通用解法。
10.4 一个能立刻用上的实践清单
✅ 任何「算某段文本占几列 / 要不要换行 / 怎么 pad 对齐」的地方,
用 stringWidth(text),不用 .length
✅ 算宽度前 strip ANSI
✅ 光标移动、删除「一个字」按字素簇,不按码点
✅ 宽度函数在布局热路径上(§4.4),必须有缓存
🔬 本仓:line-width-cache.ts / measure-text.ts
✅ 纯 ASCII 快速路径(🔬 本仓 stringWidth.ts 第一步就是扫一遍是否纯 ASCII)
—— 95% 的情况能跳过全部复杂逻辑🔬 本仓 packages/cli/src/ui/CLAUDE.md 把这条列成了硬规范:
宽度计算用
stringWidth不用.length:含 CJK / emoji / 全角字符时.length给的是码点数,不是终端列宽,会导致对齐漂移和换行错位。
10.5 一条相关的规则:不要用彩色 emoji 做 UI 字形
🔬 本仓设计规范 L1.1 有一条很实用的禁令,它的第一条理由就是宽度:
彩色 emoji(✅🔄⬜📋⚙️❌🛑⏳💡 等)有三宗罪: ① 占位宽度跨终端不一致 → 对齐漂移; ② 色彩游离于主题之外 → 脏; ③ 与单色字形语言冲突 → 乱。一律不用。
替代方案是单色几何字形,🔬 实测本仓 constants/figures.ts:
BULLET = ⏺ (mac) / ● (其他) 状态点,靠颜色区分状态
TODO_* = ○ ◐ ● 清单状态,靠填充度递进
SUCCESS/ERROR = ✔ / ✘ 同一对纤细字形
THINKING_MARK = ✻ 思考
TREE_BRANCH = ⎿ 结果区缩进前缀注意 BULLET 那一行有个跨平台回退: 🔬 figures.ts:14 export const BULLET = isDarwin ? "⏺" : "●" ——mac 下 ⏺ 垂直对齐更好,其他平台部分字体不支持所以回退。
📌 这条规范的深层价值不在「别用 emoji」,在那个替代方案的设计原则: 「表达程度递进时,用同一字形族的填充度(○◐●),而不是换字形。」 换字形(比如 ⏳→🔄→✅)会让用户每次都要重新识别; 同族填充度是一眼可比的。这条在 Web 设计里同样成立, 但在终端里是刚需——因为终端没有大小、粗细、动画这些额外通道。
10.6 本章自检
"中a👨👩👧"的三个「长度」分别是几?你要用哪个?- 宽度算错在主屏模式下的最坏后果是什么?(不是显示错)
- Ambiguous 宽度为什么无解?成熟实现怎么处理?
- 算宽度前为什么要 strip ANSI?漏了会怎样?
- 为什么禁用彩色 emoji 做 UI 字形?第一条理由是什么?
- 「表达程度递进用填充度而不是换字形」——为什么这在终端里是刚需?
§11 视觉语言与主题:终端里怎么做设计系统
前面十章讲的是怎么把东西画出来。这一章讲画成什么样。
为什么这一章不能跳过:终端 UI 的设计约束比 Web 严苛一个量级—— 没有字号、没有字重(只有粗体一档)、没有阴影、没有圆角、 没有动画曲线、没有间距的小数值。你只有: 字形、颜色、粗体/斜体/下划线/删除线、留白、位置。
约束越少,一致性越重要——因为你没有多余的通道去补救混乱。
11.1 三条元原则
🔬 本仓设计规范提炼的三条,它们的顺序就是优先级:
① 同族递进 > 多样并列
状态/强度用同一字形族的填充度(○◐●)、同一色相的明度来表达
不要为每个状态引入新字形、新色相
② 排版 > 颜色 > 边框
能用粗细/划线/缩进/留白表达的,不用颜色
能用颜色点睛的,不用边框盒子
③ 克制点睛,不铺满
品牌色、状态色都是「点」出来的,整屏彩色 = 没有重点第 ② 条最值得展开,因为它和终端的物理限制直接相关:
边框在终端里就是文本。
一个 round 边框盒子 = 上下各一行 + 左右各一列
在 80 列的终端里,嵌套三层边框就吃掉 6 列(7.5% 的宽度)
而且它们会参与用户的文本选择(见 11.4)所以「盒子套盒子」在终端里不只是丑,是真的在吃资源。
11.2 语义化颜色 token:为什么不能写死 hex
规则:组件里禁止出现 #89b4fa 这种颜色值, 一律走语义 token:theme.text.* / theme.ui.* / theme.status.* / theme.border.*。
为什么:同一个「含义」在不同主题下需要不同的颜色值。 「成功」在深色主题下是亮绿(要在深背景上可读), 在浅色主题下是深绿。语义 token 把「含义」和「色值」解耦。
🔬 本仓的 token 分层:
text.* — 正文 / 次要 / 暗淡文本
ui.active — 品牌蓝,引导、进行中、「信息蓝」都用它
status.* — 只有 error / success / warning 三个
border.* / background.*注意 status 里刻意没有 info,🔬 而且这是个踩坑后的决定:
theme.status.info是 undefined,会静默回退终端默认色。 需要「信息蓝」用theme.ui.active。
为什么值得单独提:这是一个「静默失效」的微型样本—— 写 theme.status.info 不会报错、不会报类型错(如果类型不严), 只是颜色变成了终端默认色。在深色主题下可能刚好看起来正常, 在浅色主题下就是白底白字。
而且规范里给了一条更强的约束:
🔬 保持三状态体系,不要扩张状态色。
理由是设计层面的:状态色的价值在于「一眼分辨」。 三个颜色人眼能瞬间区分;扩到六个就需要「想一下」, 点睛效果消失(元原则③)。
11.3 无障碍主题:为什么把绿换成蓝
📄 原始研究文档提到 daltonized(色觉障碍友好)主题的关键差异: 把绿色系替换为蓝色系。
理由非常具体:diff 里「新增」用绿、「删除」用红。 红绿色盲用户无法区分这两个——而 diff 的全部意义就在于区分它们。 换成蓝色后,红蓝对比是可辨的。
🔬 复核本仓:themes/builtin/ 下确实有 daltonized-dark.ts / daltonized-light.ts(共 6 套主题: default / github / daltonized × dark / light)。
这一条的教学价值:它示范了无障碍不是「加个开关」, 而是要理解「这个颜色在承载什么信息」。 如果绿色只是装饰,色盲用户没有损失; 但绿色在这里承载「这行是新增的」这个唯一信息通道, 那它对色盲用户就是信息丢失。
配套的通用规则(🔬 本仓规范 L2.1):
双通道原则:状态最好同时用「字形 + 排版」两个通道表达,不要只靠颜色。 如 todo 完成 = 实心字形
●+ 划线,不是只把字变绿。
11.4 一个真实的设计决策:用户会复制的区域不画左右竖线
🔬 这是本仓一个非常终端特有的设计发现,Web 里根本不会遇到:
问题:输入框用 round 全框(四条边)看起来更整齐。 但——
终端里的框就是文本。左右竖线与内容处在同一行, 用户拖选内容复制时竖线一并被选中, 粘出去是
│ … │满屏线框(长文本换行越多越脏)。
解法:只留上下两条横线(borderStyle="single" + borderLeft/Right: false)。
横线独占自己的行,横向拖选选不到,复制干净。
判据(可以直接用的):
这块内容用户会复制吗? 会(输入框、代码/diff、命令输出)→ 只上下横线 不会(对话框、TodoPanel 这类纯展示面板)→ 仍用
round全框
还有一个连带的坑(🔬 规范里明写「粗细/深浅各调错一次」):
去掉竖线后横线跑满整个终端宽度,面积比带竖线时的短边框大得多, 于是字形和颜色在通宽下都会被放大:
❌ 用 bold 的 ━ ——「去掉竖线所以横线要更有存在感」是错的直觉
实测通宽 ━ 太抢眼,把视觉重心从输入内容上夺走
✅ 用 single 的 ─以及一个必须记的算宽度陷阱:
🔬 去掉竖线后可用宽度只需扣
paddingX(termWidth - 2), 别沿用带竖线时的-4。
——这就是 §10 那条「宽度算错」在实践中的具体形态。
11.5 ★ 按对比度反解颜色,而不是写死混合比例
这一节是本章技术含量最高的一条,也是最容易被忽略的。
问题:你需要一个「介于两个 token 之间」的颜色—— 比如「品牌蓝,但淡一档,当结构线用」。
错误做法一:拍一个 hex。 主题一切换就错。
错误做法二:错配一个近似 token。 现成 token 是语义锚点(正文/品牌/状态/边框),不是明度色阶。 拿 border.default 来当「淡蓝」,语义就串了。
错误做法三(最诱人的):写死混合比例。
mix(theme.ui.active, theme.background.primary, 0.6) // 混 60%为什么这个错——🔬 本仓实测给了理由:
项目有 6 套主题、背景亮度差异大,同一个
mix(c, bg, 60%)在各主题算出的对比度能差一倍。
正确做法:按目标 WCAG 对比度反解。
mixToContrast(theme.ui.active, theme.background.primary, 2.6)
// 「把品牌蓝朝背景混淡,直到对比度正好是 2.6」🔬 本仓 themes/color-utils.ts:206 就是这个函数(二分求解 + 缓存)。 实测效果:全 6 套主题收敛在 2.58~2.59,观感一致。
参考档位(这份数字很实用):
装饰性结构线 ~2.6
低于 ~2.0 糊进背景,框不住内容
高于 ~3.5 与正文抢重心🔬 而且规范里记了两个方向都撞过一次的实测:
theme.border.default (#45475a) 暗色下仅 1.80 → 太淡,框不住
theme.text.primary (#cdd6f4) 高达 11.34 → 比它框住的输入正文还亮,
视觉层次整个反了注意第二条的描述:「比它框住的内容还亮,视觉层次整个反了」—— 这是一个能自己发现的判据: 容器的边框不该比容器里的内容更显眼。
最后一个必须记的实现细节:
🔬 取色必须惰性求值。
theme是 themeManager 的 getter 代理, 写成模块级const C = theme.ui.active会在 import 时定死,/theme切暗亮后边框仍是旧色。 用函数(inputBorderColor())或在 render 内直接读。
这个 bug 的形态:主题切换后大部分颜色变了、少数几个没变—— 表现为「切主题之后界面看起来很怪」,但你很难定位到具体是哪几个。 因为它取决于「哪些颜色在模块顶层被求值过」,而这不是一个显式的东西。
11.6 设计系统:终端里的原子组件
📄 原始研究文档列了一套终端 UI 的原子组件, 这张对照表对理解「终端 UI 的组件库长什么样」很有帮助:
| 组件 | 职责 | Web 类比 |
|---|---|---|
ThemedText | 语义化颜色文本 | <span class="text-warning"> |
ThemedBox | 语义化颜色容器 | <div class="bg-surface"> |
Dialog | 确认/取消对话框 | <dialog> / Modal |
Pane | 带标题的面板 | Card |
Tabs | 标签页导航 | Tab 组件 |
FuzzyPicker | 模糊搜索选择器 | Combobox |
ProgressBar | 进度条 | <progress> |
但这里有一个终端特有的关键点,值得单独提:
📄 设计系统组件内置了键盘交互语义。 比如
Dialog自动处理 Enter(确认)和 Escape(取消),Tabs自动处理左右箭头切换。
为什么这条在终端里比 Web 里重要: Web 里 <button> 天生可聚焦、天生响应 Enter、天生被屏幕阅读器识别—— 浏览器免费给你。终端里这些全部要自己接线(§9), 所以如果不封装进组件,每个使用者都要重新写一遍键盘处理, 而且会写得不一致(§9.6 那个「同一个 TUI 里两套交互模型」的 bug 就是这么来的)。
11.7 ANSI 作为内部交换格式:一个有趣的架构模式
📄 原始研究文档里一个容易忽略但很聪明的设计:
Markdown 渲染器 ──→ ANSI 字符串 ──→ <Ansi> 组件 ──→ 结构化文本树
语法高亮器 ──→ ANSI 字符串 ──→ <Ansi> ──→ ...
Diff 渲染器 ──→ ANSI 字符串 ──→ <RawAnsi> ──→ 直接写入屏幕缓冲ANSI 不只是最终输出格式,也是内部模块之间的交换格式。
为什么绕这一圈:因为 Markdown 格式化器、语法高亮器 这类模块是独立的库,它们的天然输出格式就是 ANSI(终端生态的通用格式)。 强制它们输出框架特有的组件会增加耦合。 ANSI 作为中间格式,让这些模块保持独立可替换。
而 <RawAnsi> 是一个更激进的优化——🔬 本仓也有 (components/RawAnsi.tsx,被 DiffRenderer 使用):
接收「已按列宽换行的 ANSI 行数组」,单个 Yoga leaf + 常量 measure, 直接
output.write(),跳过<Ansi> → React 树 → Yoga → squash → 重序列化的往返。
用在哪:长 diff、高亮代码——这类内容已经是终端就绪的, 让框架再解析一遍纯属浪费。
代价(要能说出来):RawAnsi 绕过了框架的一切能力—— 它不参与布局(只有常量高度)、不参与文本选择的结构化处理、不参与主题解析。 所以只能用于「已经完全算好的内容」。 这又是一个「快路径必须自己保证前提」的例子。
11.8 本章自检
- 为什么「盒子套盒子」在终端里不只是审美问题?
theme.status.info不存在会导致什么?为什么这个 bug 难发现?- daltonized 主题为什么把绿换成蓝?这条决策背后的通用原则是什么?
- 「用户会复制的区域不画左右竖线」——理由是什么?判据是什么?
- 为什么不能写死混合比例,要按对比度反解?给出实测理由。
- 「容器边框不该比内容更显眼」——这条能推出什么可操作的对比度上界?
- 主题切换后「大部分颜色变了、少数没变」,最可能是什么原因?
- 为什么终端 UI 的设计系统组件必须内置键盘语义,而 Web 的不需要?
RawAnsi快在哪、代价是什么?
§12 ★★ 会静默坏掉的失效模式(本文最值钱的一章)
前面十一章讲的是「怎么做对」。这一章讲**「做错了但你不知道」**。
为什么这一章最值钱:终端 UI 的严重问题有一个惊人一致的共同结构——
它们全都不报错。 代码在、类型过、测试绿、机理讲得通、界面还能用, 而结论是错的、能力是丢的、用户是受损的。
这个结构和 Observability / Provider / Multi-Agent / Record&Replay 几个领域的教学文档得出的结论完全一致, 可以确认它是「基础设施类代码」的共性。
12.0 先看这张表:八种形态,一个共同点
| # | 形态 | 症状 | 为什么不报错 |
|---|---|---|---|
| 1 | workaround 的前提消失了 | 功能被裁掉(看不到全文) | workaround 在自己的假设下完全正确 |
| 2 | 优化被自动优化器破坏 | 只是变慢 | 引用相等的技巧被 memo 化,语义不变 |
| 3 | 缺失的防线 | 进程崩溃(只在特定场景) | 防线不执行时不产生任何症状 |
| 4 | 异步机制用在同步区 | 内容永久污染 scrollback | 组件本身没 bug,用错了区域 |
| 5 | 文档路径漂移 | 得出「能力不存在」的反向结论 | 文档每条结论都对,只有路径错 |
| 6 | 能力有代码但没接线 | 能力被记成资产,实际零调用 | 代码在、注释在、设计意图在 |
| 7 | 口径/单位算错 | 对齐渐进歪斜 / 擦掉历史 | 数字是数字,没有类型能拦 |
| 8 | 默认关闭 = 不存在 | 功能对大多数用户失效 | 在开发者自己的终端上好用 |
读法:不要背这八条,记住那个共同点, 然后对任何「我觉得这块没问题」的判断,问一句: 「如果它坏了,我会怎么发现?」 答不上来,就是这一章的候选。
12.1 形态 1:为绕过 A 而加的 B,在 A 消失后变成新的 A
§8.3–8.4 的完整案例,这里提炼成通用形态。
t0:底座有缺陷 A(动态区太高会全屏重打)
t1:加 workaround B(tail 截断)绕过 A ← 此时 B 是正确的
t2:换底座,缺陷 A 消失 ← 但没人回头看 B
t3:B 现在是纯负债(裁掉了内容),且成为新 bug 的根因
t4:反复「修复」B 的参数,从没质疑 B 该不该存在 ← 停在这里好几轮🔬 那句实测记录:
历次「修复」都在调裁剪预算,从没质疑裁剪本身该不该存在。
为什么难发现(三条,每条都要能说):
- B 的代码没有任何问题,它在自己的假设下完全正确
- 假设不在代码里——在一个关闭的 issue 里、在某人记忆里
- B 的单测测的是「B 做得对不对」,不是「B 该不该存在」
判据(唯一有效的):
每个 workaround 都要在代码里写清它绕的是什么, 这样底座变化时能回头检索。 「注释写清前提」在这里不是文档洁癖,是唯一的检测手段。
升级版判据(可以直接当团队规范):
改底座 / 换依赖的 PR,必须附一份「为旧底座加的 workaround」清单及其重评结论。
12.2 形态 2:靠引用相等的优化,会被任何自动 memo 化悄悄破坏
§3.6 的案例。
'use no memo' // ← 这一行是整个机制的支点
const cached = useRef(children)
if (isVisible) cached.current = children
return <Box>{cached.current}</Box> // 不可见 → 返回旧引用 → React 跳过整棵子树如果这行 'use no memo' 被删掉(或者升级了编译器版本、 改了配置、把组件重构成另一种形式):
代码依然能跑 ✅
测试依然全绿 ✅
界面依然正确 ✅
性能悄悄退回优化前 ❌ ← 没有任何信号为什么这类问题格外阴:性能回归没有断言。 功能有单测守着,性能通常没有。 而且性能回归是渐进被感知的——用户先觉得「有点卡」, 几个月后才有人报「这东西越来越慢了」,此时已无法定位到那次改动。
判据:
任何「靠引用/身份相等来跳过工作」的优化, 都必须有一个能失败的性能断言守着。 🔬 本仓的做法是留下可复跑的验证脚本 (
scripts/perf-verify-stream.ts之类), 这样「120 块 658ms → 7ms」这个数字可以随时重测。
一条更通用的表述:你的优化如果只在注释里存在,它就会消失。
12.3 形态 3:只在异常路径上有用的防线,缺了不产生任何症状
§9.5 的 EIO 案例。
process.stdout.on('error', handler) // 这一行在正常运行时永远不执行没有它会怎样:99.99% 的时间毫无差别。 只在用户关掉终端窗口的那一刻,进程崩溃。
为什么测不出来:
- 单测里 stdout 是个 mock,不会 EIO
- e2e 里终端不会中途关闭
- 「用户关掉终端窗口」这个场景不在任何测试用例里
同族的其他例子(都是「只在异常时有用」的防线):
SIGCONT 后重新设置终端模式 —— 只在用户按过 Ctrl+Z 再 fg 时才需要
tmux attach / SSH 重连后自愈 —— 只在断线重连时
退出时恢复终端状态(alt screen 退出、显示光标、关鼠标追踪)
—— 只在退出时;漏了会把用户的终端弄坏
uncaughtException 里 exit 失败回退 SIGKILL —— 只在终端已死时⚠️ 注意「退出时恢复」那一条的严重性: 如果你的程序崩溃时没有发 \x1b[?25h(显示光标)和 \x1b[?1049l(回主屏), 用户的终端会留在一个坏状态里——光标不见了、或者卡在备用屏, 只能 reset 或者关窗口。 这是「你的 bug 损坏了用户的环境」,比自己崩掉严重。
判据(这一条有明确操作方法):
🔬 验收「新增防线」不是「build 过 + 单测过」, 而是「真实会话里被触发过」。 ——本仓 CLAUDE.md 里这条是有事故的: 防线自己成了它当初要消灭的死功能。
具体做法:手动制造那个异常场景。 测 EIO 就真的在跑的时候关掉终端窗口; 测 SIGCONT 就真的按 Ctrl+Z 再 fg。 没试过一次的防线,等于没有。
12.4 形态 4:同一个组件,在 A 区正确、在 B 区灾难
§8.7 的 Static 折叠案例。
异步测高的折叠组件:
在动态区(每帧重绘)→ 首帧没折、下一帧折了 → ✅ 完全正确
在 Static 区(打一次就不可改)→ 首帧整份内容落进 scrollback → ❌ 永久污染为什么这类问题特别容易发生:因为组件的类型签名里没有「我在哪个区」这个信息。 <MaxSizedBox> 的 props 长得一模一样, 放在两个地方编译都通过、类型都对。
判据:
一个「在 A 场景正确、在 B 场景灾难」的 API, 如果类型系统区分不了 A 和 B,那它就是个陷阱。
处理方式(按优先级):
1. 让类型能区分(比如两个不同的组件名,或者一个必填的 mode 参数)
2. 删掉那条路 ← 🔬 本仓选的这个
3. 留着 + 写注释「B 场景别用」 ← 最差,注释不会自动出现在读者眼前🔬 本仓选 2 的理由记得很清楚,值得引用:
它不只是冗余,更是陷阱:未来有人给它传参数期望折叠, 就会用异步路径重蹈 Static 灌屏覆辙。
这是一个「删代码是为了防未来的人」的例子, 比「删代码因为它没用」高一档。
12.5 形态 5:文档路径漂移 → 你会得出反向结论
这是本文开头那个活体样本,放在这里作为完整案例。
🔬 复核事实(2026-09-02):
packages/cli/src/ui/CLAUDE.md 的 frontmatter:
paths: ["src/ui/**", "src/ink/**"]
全文 17 处提到 src/ink/
实测:
$ ls src/ink → No such file or directory
$ ls packages/cli/src/ink → No such file or directory
代码实际位置:packages/tui-renderer/
组件实际 import(MainScreenLayout.tsx:14):
import Box from "@sid-code/tui-renderer/components/Box.tsx"这份规范的每一条设计结论都还是对的,只有路径漂了。
但后果是反向的:一个新人(或 agent)照着规范去 src/ink/ 找 「fork 的渲染底座有没有 selection 能力」, 会发现目录都不存在 → 得出「这个能力不存在」→ 于是自己造一套,而规范里明明写着:
🔬「需要跟随滚动到底 / 文本选中复制时,用 fork 既有能力,别自造。」
这就是形态 5 的破坏力:它不是让你少知道一件事, 是让你得到相反的结论,然后按相反的结论行动。
📎 这份规范的路径失准其实有两类,
src/ink只是最危险的那一类。 完整实测(含另外两个「目录在但换了包」的)见 §13.3 漂移 ①, 可复跑命令见附录 B.5——那条命令我自己写错了三次, 三次都输出了整齐但错误的结论,过程记在 B.5 里。
判据(两条):
① 引用「现状」时回源码/轨迹核过,不照抄文档。 ② 零命中不等于能力不存在——只证明「没有以这些名字/在这些路径实现的东西」。 必须补第二个证据(换名搜索 / 从功能入口反查 / 读类型签名)。
第二条值得展开,因为它是调研类工作的核心纪律:
❌ 错的推理:grep "selection" src/ink/ → 0 命中 → 「没有选择功能」
✅ 对的推理:0 命中 →
① 这个路径存在吗?(本例:不存在!整个前提就错了)
② 换名搜索:select / highlight / mark / anchor
③ 从功能入口反查:「用户拖选后复制」这个行为,代码从哪进来
④ 读类型签名和导出列表,而不是搜关键词🔬 本例正确做法的结果:packages/tui-renderer/src/selection.ts,842 行, 能力完整存在。
12.6 形态 6:有代码 ≠ 有能力(接线没接上)
§8.7 的另一半,这是一个独立形态。
🔬 实测记录:
DiffRenderer尾部注释写着「高度限制交由上层 SlicingMaxSizedBox 处理」 = 设计意图,但调用方从没接上(未完成接线,非缺能力)。
这个形态的完整链条:
① 有能力实现(DiffRenderer 支持 maxLines) ✅
② 有设计意图(注释写明分工) ✅
③ 有调用方(ToolResultDisplay 会渲染它) ✅
④ 但调用方没传那个参数 ❌ ← 断在这里
↓
结果:所有 write/edit 新建大文件全部灌屏
↓
而如果你只是「读代码检查有没有折叠能力」→ 你会答「有」判据(这一条有明确的三档,比两档强):
❌ 两档:「有代码 / 没代码」 ← 会把死代码记成资产
✅ 三档:
① 代码在 + 有调用方 + 参数传了 = 真的在跑
② 代码在 + 有调用方 + 参数没传 = 未接线(本例)
③ 代码在 + 无调用方 = 死代码怎么检测:不要搜「这个能力的实现在不在」, 要搜「这个参数有几个调用点真的传了」。
🔬 本仓同一次审计里用这个方法抓到了一个真正的死代码:
colorizeCode的异步折叠分支是死代码—— 全仓 4 个调用点都不传availableHeight,测试也没有。
——四个调用点,零个传参。这个数字对比就是判据本身。
12.7 形态 7:口径/单位算错,没有任何类型能拦
§10 那一整章都是这个形态。这里只提炼最危险的形式:
用 .length 当列宽 → 主屏模式下擦除行数算错 → 擦掉用户的历史内容为什么类型系统拦不住:number 就是 number。 "中".length 是 1,stringWidth("中") 是 2, 两个都是合法的 number,编译器没有任何理由报警。
同族的其他例子(🔬 都是本仓实测的):
去掉左右竖线后,可用宽度该扣 2 还是 4 → 沿用 4 会白白少用两列
mix(color, bg, 60%) vs mixToContrast(2.6) → 前者在 6 套主题里对比度差一倍
overscan 与滚动量化桶大小的关系 → 配错会出现「滚过去了但内容没挂载」的空白判据:
凡是「同一个 number 有多种口径」的地方,把口径写进名字或类型。
width是歧义的;columnWidth/codePointCount不是。 🔬 更强的做法是用 branded type 让它们不可互换。
12.8 形态 8:默认关闭的能力,对用户等于不存在
§2.4 / §7.5 的 OSC 52 案例。
OSC 52(写系统剪贴板):
技术上:所有主流终端都「支持」
实际上:iTerm2 默认关闭、tmux 需要配置
↓
对绝大多数用户:不可用判据(一句话,非常好用):
一个能力如果默认关闭,那它对你的用户群体就等于不存在—— 你不能要求用户改终端配置才能用你的产品。
这条判据的适用面比 OSC 52 宽得多:
Kitty 键盘协议 → 只有部分终端支持 → 不能把核心交互建在它上面
DEC 2026 同步输出 → tmux 解析但不实现 → 不能假设它一定有效
真彩色 → tmux 透传不可靠 → 必须能降级
OSC 8 超链接 → Apple Terminal 不支持 → 链接必须同时给出裸 URL通用形态:「这个终端能力我这儿好用」是最不可靠的证据来源, 因为开发者的终端往往是最现代、配置最全的那一台。
配套的一条工程习惯:能力检测的返回值是猜测(§2.2), 所以成熟实现里一定有一张「已知终端的已知怪癖」硬编码表。 这不是丑陋的 hack,是这个领域的常态。
12.9 三条元判据:把这八条压成能记住的东西
八条太多。记住这三句就够,它们能推出上面全部八条:
① 「如果它坏了,我会怎么发现?」 答不上来 = 它已经可能坏了。 (能推出:形态 2 性能回归、形态 3 缺失防线、形态 6 未接线)
② 「这个结论的前提,现在还成立吗?」 前提写在代码里才检索得到,写在记忆里等于没有。 (能推出:形态 1 workaround 过期、形态 5 文档漂移)
③ 「这个数字/能力,换个环境还对吗?」 换终端、换主题、换屏幕模式、换区域(Static/动态)。 (能推出:形态 4 区域误用、形态 7 口径错、形态 8 默认关闭)
12.10 本章自检
- 用一句话说出这八种失效模式的共同点。
- 「为绕过 A 而加的 B」这类 bug,为什么它的单测会全绿?
'use no memo'被删掉之后,会有什么信号?- 「验收新增防线的标准」是什么?为什么「build 过 + 单测过」不够?
- 一个 API 在 A 区正确、B 区灾难,三种处理方式哪个最差?为什么?
- grep 零命中,你能得出「能力不存在」吗?要补哪几个证据?
- 「有代码 ≠ 有能力」的三档分类是什么?怎么检测第二档?
- 为什么类型系统拦不住「用
.length当列宽」?怎么才能拦住? - 「默认关闭 = 不存在」这条判据,除了 OSC 52 还能用在哪三处?
- 三条元判据分别是什么?各能推出哪几种形态?
§13 本仓现状对照:一份可照着做的自查模板
前面十二章讲的是「这个领域该有什么」。这一章把它变成一张打分表, 并把打分过程本身当教材——因为怎么打分比分数本身更值得学。
这一章的两个用途: ① 想知道一个终端 UI 实现「成熟到什么程度」时,照着这 20 项逐条核; ② 看一次「三档判定」(§12.6)在真实仓库上跑起来是什么样。
13.1 打分规则:为什么必须是六档不是两档
先立规则,否则这张表会骗人(这条纪律来自同族文档的踩坑):
✅ 能力在 + 有调用方 + 参数真的传了 = 真的在跑
⚠️ 能力在 + 接线不全 / 有已知限制 = 部分可用
🔌 能力在 + 有调用方 + 关键参数没传 = 未接线(形态 6 第 ② 档)
💀 能力在 + 零调用方 = 死代码(第 ③ 档)
🔒 能力在 + 默认关闭 = 对用户等于不存在(形态 8)
❌ 没有 = 缺为什么不能用星级:星级会把「有但没接线」和「有且在跑」压成同一格—— 而这两者的差别正是 §12.6 那一整节在讲的东西。 图例的粒度决定了你能发现什么。
13.2 20 项能力对照表(🔬 全部实测,2026-09-02)
协议与输出层
| # | 能力 | 判定 | 证据 |
|---|---|---|---|
| 1 | ANSI 序列生成(CSI/SGR/OSC/DEC) | ✅ | termio/ 5 文件:csi.ts 317 / osc.ts 476 / sgr.ts 294 / esc.ts 67 / dec.ts |
| 2 | 同步输出 BSU/ESU 包帧 | ✅ | termio/dec.ts:39-40 定义;terminal.ts:219 writeDiffToTerminal 使用 |
| 3 | tmux 跳过 BSU/ESU | ✅ | terminal.ts:76 注释 + skipSyncMarkers 参数 |
| 4 | 终端能力检测 + 人工纠偏 | ✅ | terminal.ts 6 个探测函数(isXtermJs / supportsExtendedKeys / hasCursorUpViewportYankBug …) |
| 5 | 颜色三档降级(真彩/256/16) | ✅ | colorize.ts + xterm.js boost / tmux clamp |
渲染管线层
| # | 能力 | 判定 | 证据 |
|---|---|---|---|
| 6 | React Reconciler 自定义宿主 | ✅ | reconciler.ts 488 行 → dom.ts 454 行 |
| 7 | Flexbox 布局 | ⚠️ | layout/yoga.ts 304 行适配 + _vendor/yoga-layout/ 纯 TS 移植 2594 行。⚠️ 注释自陈是 simplified single-pass,只覆盖 Ink 用到的子集 |
| 8 | 屏幕缓冲(紧凑存储) | ✅ | screen.ts 1395 行:Int32Array 双视图 + 位域打包(STYLE_SHIFT=17) |
| 9 | 三种池化(Char/Style/Hyperlink) | ✅ | screen.ts:12/48/103,ASCII 快速路径 initCharAscii() |
| 10 | 帧差分 + damage 限制 | ✅ | log-update.ts 688 行 |
| 11 | 全屏重打有显式命名 | ✅ | log-update.ts:451 fullResetSequence_CAUSES_FLICKER,5 个调用点各带 reason |
| 12 | blit / 节点缓存 | ✅ | node-cache.ts + render-node-to-output.ts 1385 行 |
屏幕模式与流式
| # | 能力 | 判定 | 证据 |
|---|---|---|---|
| 13 | 主屏 Static 模式(默认) | ✅ | fullscreen.ts:44 alternateBuffer ?? false;_vendor/Static.tsx 32 行;ADR-040 |
| 14 | 备用屏全屏模式(opt-in) | ✅ | components/AlternateScreen.tsx;ink.tsx:520 拼 ?1049h |
| 15 | 稳定前缀切分防闪烁 | ✅ | StreamingMarkdown.tsx computeStreamSplit;三条不变量有单测 |
| 16 | Static 区同步折叠 | ✅ | SlicingMaxSizedBox.tsx + DiffRenderer maxLines(DIFF_COLLAPSE_MAX_LINES=16);异步折叠死代码已删 |
输入与交互
| # | 能力 | 判定 | 证据 |
|---|---|---|---|
| 17 | 三套键盘协议 | ✅ | parse-keypress.ts 808 行,:19 CSI u / :25 modifyOtherKeys / legacy |
| 18 | 不完整转义序列超时 | ✅ | components/App.tsx:153 NORMAL_TIMEOUT=50 / PASTE_TIMEOUT=500 |
| 19 | 捕获/冒泡分发 + 焦点栈 | ✅ | events/ 12 文件 + focus.ts |
| 20 | 文本选择引擎 | ✅ | selection.ts 842 行;hit-test.ts 119;searchHighlight.ts 83 |
汇总:✅ 18 / ⚠️ 1 / 🔌 0 / 💀 0 / 🔒 0 / ❌ 1(第 20 项在主屏模式下由终端接管,见下)
13.3 打分过程中抓到的三个漂移(这一节比上面那张表值钱)
这三个都是本文写作时新发现的,不是抄来的—— 它们正是 §12 那几种形态的活体样本。
漂移 ①(形态 5):设计规范的路径失准,而且是两种不同的失准
🔬 packages/cli/src/ui/CLAUDE.md frontmatter: paths: ["src/ui/**", "src/ink/**"]
全文 17 处提到 src/ink/
🔬 实测(附录 B.5 的命令):
❌ src/ink 全仓无此目录 —— 能力搬到 packages/tui-renderer/ 且换了包名
✅ src/permission → packages/core/src/permission ← 目录在,但不在 cli 包下
✅ src/tool → packages/core/src/tool ← 同上
✅ src/ui → packages/cli/src/ui ← 只有这条对得上两种失准要分开看,因为破坏力不同:
① 目录彻底消失(src/ink)
→ 照着找会「什么都找不到」→ 得出「能力不存在」的反向结论 ← 最危险
② 目录在但换了包(src/tool、src/permission 搬到 packages/core/)
→ 照着找也找不到,但换个包名搜就能找到 → 只是浪费时间规范每一条设计结论都还对,错的只有路径。 后果见 §12.5: 第 ① 类会让人得出「能力不存在」,然后自己造一套—— 而规范自己写着「用 fork 既有能力,别自造」。
漂移 ②(形态 5 变体):主题数量对不上
🔬 设计规范说「项目有 6 套主题、背景亮度差异大」
🔬 实测 themes/builtin/: dark/{default,github,daltonized} + light/{同三个} = 确实 6 套 ✅——这一条核对下来是对的。放在这里是为了说明: 核对的结果不总是「文档错了」。 上一条错了不代表这一条也错,每条都要单独核。
漂移 ③(形态 6 第 ② 档,本次新发现):一个只有框架内部在用的 hook
这一条最有教学价值,因为它示范了三档判定怎么救回一个错误结论:
第一步(错的推理):
搜 OffscreenFreeze(原始研究文档里的离屏冻结组件)→ 0 命中
❌ 如果停在这里,结论是「本仓没有离屏冻结能力」
第二步(换名反查,§12.5 的判据②):
离屏冻结靠的是「知道自己在不在视口里」
→ 搜这个能力本身 → 找到 hooks/use-terminal-viewport.ts
✅ 能力在
第三步(三档判定,§12.6):
🔬 grep 全仓调用方 → 只有一个:
hooks/use-animation-frame.ts:34
const [viewportRef, { isVisible }] = useTerminalViewport()
🔬 应用层(packages/cli/src/ui/)调用方:0
→ 判定:能力在、有调用方,但调用方在框架内部,
且只被动画 hook 用来「不可见时停动画」
——不是原始文档里那种「冻结整棵消息子树」的用法正确结论(三档里的第 ② 档,不是 ①也不是③):
useTerminalViewport提供的视口可见性能力在, 且已接线到动画节流(这是一条真实收益:不可见时不烧 CPU), 但**「离屏冻结消息子树」这个用法尚未接线**。主屏 Static 模式下这个缺口影响很小—— 已完成消息走
Static本来就不重渲(§7.4), 离屏冻结要解决的问题被另一个机制解决了。 但 alt-screen 模式(虚拟列表全动态区)没有 Static,这个缺口是实的。
注意这个结论的形状:它不是「有」也不是「没有」, 而是**「能力在、一处接线在、另一处没接、而且没接的那处只在特定模式下要紧」**。 这才是一次诚实的能力盘点该长的样子。
同理,📄 原始研究文档提到的 StickyPromptHeader / NewMessagesPill (粘性提示头、「N 条新消息」药丸), 🔬 在本仓只在注释里出现(ink.tsx:627、render-node-to-output.ts:1184)—— 渲染底座为它们预留了处理(不透明矩形、静态文本判定), 但应用层没有这两个组件。这是「底座能力就绪、上层未建」的又一例。
13.4 第 20 项那个 ❌ 是怎么来的:架构选择不是缺陷
上面汇总里有一个 ❌,但它需要解释——这是评估纪律的一部分:
文本选择引擎:✅ 842 行,完整存在
但在默认(主屏)模式下:不启用
因为主屏模式不抢鼠标 → 终端原生选择接管 → 自绘引擎不需要所以这一格该记 ✅ 还是 ❌?
答案:记 ✅(能力在且在 alt-screen 下在跑), 把「主屏下不启用」写进备注,不记成缺陷。
理由(这条纪律很重要):
矩阵只填事实,价值判断留到结论章。 「主屏模式没有自绘选择」是那个架构选择的代价,不是缺陷—— 它换来的是终端原生选择(§7.5 那次 ADR 的全部目的)。 填进矩阵当缺点是方法错误: 同一张表会因此把「刻意的取舍」和「没做完的工作」混成一档。
所以汇总那行的 ❌ 1 项,指的是另一件事: 🔬 HITL / 权限决策的 UI 层差异化警示(危险命令标红、 危险确认默认聚焦「拒绝」)——设计规范里标着 ⚠️ 待补, 且规范明说「项目已有命令危险性检测能力,但 UI 层的差异化警示尚未接全」。 这是真缺口:底层判断在、UI 没接——又一个形态 6。
13.5 这张表怎么用在别的项目上
照着做的顺序(这是模板部分):
1. 先定图例(六档,别用星级)
2. 按层列能力(协议 / 管线 / 屏幕模式 / 输入),别按文件列
3. 每一项必须填「证据」列,且证据必须是 文件:行号 或 命令输出
—— 填不出证据的项,说明你在凭印象打分
4. 遇到 0 命中,走三步:换名 → 从功能入口反查 → 读导出签名
5. 遇到「有代码」,走三档:有调用方吗 → 关键参数传了吗
6. 最后单列一节「刻意的取舍」,把架构代价从缺陷里摘出来一条容易忽略的收尾:
一次性核查不是门禁。 这张表是 2026-09-02 的快照。它会腐坏,而且腐坏时不报错 (漂移 ① 就是一份腐坏了的规范)。 想让它不腐坏,要么变成 CI 检查(比如断言
src/ink不存在时报错), 要么在文档头写清last_verified并接受它会过期。
13.6 本章自检
- 为什么图例必须六档而不能用星级?
- 「grep
OffscreenFreeze零命中」之后,正确的三步是什么? - 「能力在、有调用方、但调用方在框架内部」——这该判几档?
- 主屏模式下不启用自绘选择引擎,该记 ✅ 还是 ❌?为什么?
- 「矩阵只填事实,价值判断留到结论章」——违反它会导致什么?
- 这张表明年还准吗?想让它保持准,有哪两条路?
§15 动手:从零实现一个 mini 终端 UI
看懂 ≠ 会写。 这一章给一条五阶段路线, 每个阶段都标明你会亲手撞到哪个坑—— 撞一次比读十遍有用,而且这些坑正好对应前面的章节。
总耗时估计:一周左右(每天几小时)。 不需要写完,写到阶段 3 你对这个领域的理解就已经超过大多数只读文档的人。
阶段 1:不用框架,纯手写(半天)
目标:亲手体验 §3.1 那个崩溃点。这一步不要跳过。
1. 进 raw mode,逐字节读 stdin,把按键打印出来
→ 你会立刻发现:方向键是 3 个字节,Ctrl+A 是 1 个字节
2. 做一个「底部固定输入框 + 上方日志」的界面
用 \x1b[nA(上移)+ \x1b[J(擦到底)+ 重写
3. 让日志能不断追加你会撞到的坑(按出现顺序):
① 按 Esc 和按 ↑ 收到的第一个字节一样 → §9.2 难点二
② 输入框文本超过一行宽度后,擦除行数算错 → §7.3 + §10.2
③ 输入中文,对齐立刻歪 → §10.1
④ 界面闪 → §8.1
⑤ Ctrl+C 退出后,终端光标不见了 → §12.3(你没恢复终端状态)验收:你能说清「为什么这条路走不下去」。 这个体验是后面所有章节的锚点—— 没有它,「React 帮你算行数」这句话只是一句话。
阶段 2:接上 React(一天)
目标:填一份 host config,跑通最小管线。
1. 用 react-reconciler 建一个自定义渲染器
实现 createInstance / createTextInstance / appendChild / commitUpdate
2. 自己的节点树先不要布局,就按「一个节点一行」输出
3. 每次 React commit → 遍历树 → 全量重绘(先别优化)你会撞到的坑:
① host config 的必填项比你想的多(有十几个方法,大部分可以 noop,
但哪些能 noop 需要试)
② 文本节点只能在 <Text> 里 —— 你会想「为什么不能直接放 <Box> 里」,
答案是布局:文本需要 measure function(§4.4),Box 不需要
③ 全量重绘 → 闪得没法用 → 你会自然而然想要 diff验收:能用 JSX 写出阶段 1 那个界面,且不再手算任何行数。
阶段 3:布局 + 屏幕缓冲 + Diff(两天)
目标:管线的完整五阶段跑通。这是整条路线的核心阶段。
1. 接 yoga-layout(npm 上有),把样式翻译成 Yoga API
2. 建一个 Screen:先用最朴素的二维数组,别急着 Int32Array
3. 实现 renderNodeToOutput:递归 + 裁剪 + 文本换行
4. 实现 diff(prev, next) → patch → ANSI你会撞到的坑:
① measure function 的循环依赖 → §4.4
(Yoga 问你「40 列宽要多高」,你得先会换行才能回答)
② 换行算法本身就很难:宽字符不能切一半、ANSI 序列不占宽度 → §10.3
③ diff 出来的 patch 顺序错了会画错
(必须先移光标再改样式再写字符)
④ 样式没重置 → 后面所有文字都是红的验收:改一个字符,只有那个位置被重写(用 set -x 或把输出重定向到文件看字节)。
💡 一个非常有用的调试技巧(🔬 本仓有一份专门的方法论文档
TUI渲染调试方法论-假stdout驱动真实ink管线.md): 用一个假的 stdout(收集字节到数组)驱动真实的渲染管线, 然后断言输出的字节序列。 这样渲染逻辑变成可单测的纯函数,不需要真终端。 这是这个领域最重要的一个工程技巧—— 没有它,渲染 bug 只能靠肉眼看,而肉眼看不出「多写了 3 个字节」。
阶段 4:选屏幕模式,做流式(一到两天)
目标:撞上本领域最难的那个问题。
1. 选主屏模式(推荐,因为坑更有教育意义)
2. 做 Static 分层:已完成内容 print-and-forget,只重绘底部动态区
3. 接一个模拟的流式源(setInterval 每 50ms 吐几个字)
4. 让它渲染 markdown你会撞到的坑(这些正是 §8 那一章):
① 动态区一超过视口高度就疯狂闪 → §8.2
② 你的第一反应会是「截断」→ 恭喜,你独立发明了那个错误方案 → §8.3
③ 正确做法:稳定前缀切分 → §8.5
④ 每个 token 全量重解析 markdown → O(N²) → 风扇开始转 → §6.7
⑤ Static 区的内容折叠不了(因为已经打出去了)→ §8.7验收:终端里流式输出 200 行 markdown, 不闪、能看到全文、CPU 不飙、且能用鼠标拖选已完成部分。 四条同时满足才算过。
阶段 5:兼容性与防线(一天)
目标:把「只在异常时有用」的东西补上。
1. process.stdout.on('error') + uncaughtException → SIGKILL 回退
2. 退出时恢复:显示光标、退出备用屏、关鼠标追踪、恢复终端模式
3. SIGWINCH(resize)+ SIGCONT(Ctrl+Z 后恢复)
4. 括号粘贴(?2004h)
5. 在至少三个终端里测:iTerm2 / VS Code 内置 / tmux你会撞到的坑:
① 前四条你都测不出来 → §12.3(这就是重点)
必须手动制造场景:真的关掉终端窗口、真的按 Ctrl+Z 再 fg
② tmux 里颜色不对 → §2.2(要 clamp 到 256 色)
③ tmux 里 BSU/ESU 白发 → §2.3
④ VS Code 里检测成 256 色但实际支持真彩 → §2.2(要 boost)验收:在 tmux 里跑,然后从另一台机器 SSH 进来 attach, 界面正常、按键正常。这一条能过,说明兼容性这块你真的做了。
阶段总览:你会亲手撞到的坑与对应章节
| 阶段 | 耗时 | 撞到的核心坑 | 章节 |
|---|---|---|---|
| 1 手写 | 半天 | Esc 歧义、行数算错、中文歪、终端状态没恢复 | §9.2 §7.3 §10 §12.3 |
| 2 接 React | 一天 | host config、文本节点限制、全量重绘闪 | §3.3 §4.4 |
| 3 管线 | 两天 | measure 循环依赖、换行算法、patch 顺序 | §4.4 §10.3 §5.5 |
| 4 流式 | 一到两天 | 闪烁、独立发明错误的截断方案、O(N²)、Static 折叠 | §8 §6.7 |
| 5 兼容 | 一天 | 测不出来的防线、tmux/VS Code 纠偏 | §12.3 §2.2 |
最值得撞的是阶段 4 的第 ② 个坑: 你会独立发明「按视口截断」这个方案,然后独立发现它裁掉了内容。 走完这个来回,§8 那一整章就再也不会忘。
附录
A. 术语速查表(按首字母)
| 词 | 一句话 | 详见 |
|---|---|---|
| alt screen | 备用屏,?1049h 进入,程序完全接管、无 scrollback | §7 |
| ANSI escape | ESC 开头的指令字节串,终端的唯一 API | §2 |
| blit | 把上一帧某矩形整块内存复制到这一帧 | §5.4 |
| bracketed paste | ?2004h,终端给粘贴内容加包裹标记 | §9.2 |
| BSU / ESU | DEC 2026 同步输出的开始/结束标记,防撕裂 | §2.3 |
| cell | 屏幕网格的一格,存字符 + 样式 | §5.3 |
| CSI | ESC [,控制序列,管屏幕里面 | §2.1 |
| damage | 这一帧发生变化的矩形范围,限制 diff 范围 | §5.5 |
| DECSTBM | CSI top;bottom r,设置滚动区域,硬件滚动用 | §0.2 |
| East Asian Width | Unicode 宽度属性表,Ambiguous 那档是灾难 | §10.3 |
| EIO / EPIPE | 终端关闭 / 管道断开,两个不同的错误都要处理 | §9.5 |
| grapheme cluster | 用户感知的「一个字」,可能多码点 | §10.1 |
| host config | 自定义渲染器要填的那组回调 | §3.3 |
| main screen | 主屏,输出进 scrollback,改不了已输出内容 | §7 |
| OSC | ESC ],管屏幕外面(标题/剪贴板/超链接) | §2.4 |
| overscan | 虚拟化时多挂载的缓冲区,防滚动空白 | §6.2 |
| PTY | 伪终端,一关就 EIO | §0.1 |
| raw mode | 关掉行缓冲与回显,逐字节拿按键 | §0.1 |
| Reconciler | React 的可插拔渲染层 | §3.3 |
| scrollback | 终端保存的历史行,属于终端不属于你 | §7 |
| SGR | CSI 里以 m 结尾的,管颜色字形 | §2.1 |
| soft wrap | 终端自动折的行,复制时要合并 | §0.5 |
| Static | 打印一次就不再重绘的容器,主屏模式的支点 | §7.4 |
| stable prefix | 流式内容里已语法闭合、可冻结的前缀 | §8.5 |
| Yoga | Meta 的 Flexbox 引擎 | §4.2 |
B. 可复跑命令清单
⚠️ 使用纪律:下面每条都在 2026-09-02 于本仓实跑通过。 但代码每天在变,引用任何数字前请自己跑一遍。 路径以 sid-code 仓库根为基准。
B.1 底座与应用层规模
# 渲染底座:文件数 + 行数
find packages/tui-renderer/src \( -name '*.ts' -o -name '*.tsx' \) | wc -l
find packages/tui-renderer/src \( -name '*.ts' -o -name '*.tsx' \) -print0 \
| xargs -0 wc -l | tail -1
# 实测 2026-09-02:122 文件 / 23464 行
# 应用层 UI
find packages/cli/src/ui \( -name '*.ts' -o -name '*.tsx' \) | wc -l
find packages/cli/src/ui \( -name '*.ts' -o -name '*.tsx' \) -print0 \
| xargs -0 wc -l | tail -1
# 实测:176 文件 / 36365 行
# 最大的十个文件(排除 vendor)
find packages/tui-renderer/src \( -name '*.ts' -o -name '*.tsx' \) \
| grep -v _vendor | xargs wc -l | sort -rn | sed -n '2,11p'B.2 关键机制定位
# 全屏重打(闪烁)的所有触发点 —— 注意结果含 1 处定义
grep -n 'fullResetSequence_CAUSES_FLICKER' packages/tui-renderer/src/log-update.ts
# 实测:6 处 = 5 个调用点 + 1 个函数定义(:451)
# ⚠️ 这就是为什么不能直接用 grep -c 当「调用点数」
# cell 的位域布局
sed -n '317,332p' packages/tui-renderer/src/screen.ts
# Esc / 粘贴超时
grep -n 'NORMAL_TIMEOUT\|PASTE_TIMEOUT' packages/tui-renderer/src/components/App.tsx
# 屏幕模式默认值
grep -n 'alternateBuffer' packages/cli/src/ui/fullscreen.ts
# 同步输出常量定义
grep -n 'BSU\s*=\|ESU\s*=' packages/tui-renderer/src/termio/dec.tsB.3 §12.6 三档判定模板(最实用的一条)
这是「有代码 ≠ 有能力」的可复跑做法,以 colorizeCode 的折叠参数为例:
# 第一步:这个函数有几个真实调用点?
grep -rn 'colorizeCode(' packages/cli/src | grep -v 'export function'
# 实测 5 处,但其中 :194 是注释 → 真实调用点 4 个
# 第二步:有几个调用点真的传了那个关键参数?
grep -rn 'availableHeight:' packages/cli/src | grep -v '^\s*//' | grep -v '\* '
# 实测:0 个
# → 判定第 ③ 档:死代码(本仓已删,见 CodeColorizer.tsx:200 的注释)
# ⚠️ 反例:不要直接数关键词总命中
grep -rn 'availableHeight' packages/cli/src packages/tui-renderer/src | wc -l
# 实测 18 —— 但绝大多数是 Yoga 内部的同名变量和一个不同的
# availableHeightLimit。裸命中数会让你得出完全相反的结论。📌 B.3 这一段本身就是 §12 的教材: 同一个能力,三条命令给出三个不同的数字(18 / 5 / 0), 而只有最后一个是答案。 前两个不是「不够精确」,是会让你得出反向结论。
B.4 主题与颜色
# 主题套数
ls packages/cli/src/ui/themes/builtin/*/
# 实测:dark/{default,github,daltonized} + light/{同三个} = 6 套
# 对比度反解函数
grep -n 'export function mixToContrast' packages/cli/src/ui/themes/color-utils.ts
# 字形常量(禁用彩色 emoji 的替代方案)
grep -n 'export const' packages/cli/src/ui/constants/figures.ts | head -20
# 主题实际解析值(验证「定义 ≠ 生效」)
bun -e 'import { themeManager } from "./packages/cli/src/ui/themes/theme-manager.ts"; console.log(themeManager.getSemanticColors())'B.5 文档漂移自查(§12.5)
# 设计规范里引用的路径,在全仓还能解析到吗?
grep -o 'src/[a-z-]*/' packages/cli/src/ui/CLAUDE.md | sort -u | sed 's:/$::' \
| while read -r p; do
hit=$(find packages -type d -path "*/$p" \
-not -path '*/node_modules/*' -not -path '*/tests/*' 2>/dev/null | head -1)
[ -n "$hit" ] && echo "✅ $p → $hit" || echo "❌ $p ← 全仓无此目录"
done实测 2026-09-02 的输出:
❌ src/ink ← 全仓无此目录(规范全文 17 处引用它)
✅ src/permission → packages/core/src/permission
✅ src/tool → packages/core/src/tool
✅ src/ui → packages/cli/src/ui读法(这份输出比它看起来复杂):只有 src/ink 是真的消失了 (能力搬去了 packages/tui-renderer/,换了包名); 另外三个是搬了包但目录名还在——src/tool 现在在 packages/core/ 下, 不在规范自己声明的 packages/cli/ 下。 所以规范的 frontmatter paths: ["src/ui/**", "src/ink/**"] 有两处不准: 一处指向不存在的目录,一处漏掉了 monorepo 前缀。
⚠️ 这条命令我写错过三次,过程本身是教材: ① 第一版用
[ -d "$p" ]直接测相对路径 → 四条全报「已漂移」, 但其中三条只是少了packages/cli/前缀,假阳性; ② 第二版改成测packages/cli/$p→src/tool仍报漂移, 因为它其实在packages/core/下,又是假阳性; ③ 第三版用find -path "*/$p",但没去掉grep -o带出的尾部斜杠 →-path "*/src/ui/"永不匹配目录 → 四条全报「不存在」,全是假阴性。三个版本都「跑通了」、都输出了整齐的结论,而三个结论都是错的。 这正是 §12 和附录 B.3 讲的同一件事: 一条会输出漂亮结果的错命令,比报错的命令危险得多。
D. 三十秒自检清单
上线一个终端 UI 改动前,过一遍这十条:
□ 宽度用 stringWidth 不是 .length,且算前 strip 了 ANSI
□ 颜色走语义 token,没有硬编码 hex,且取色是惰性求值的
□ 进 Static 区的内容,折叠是同步一次成型的(不是异步测高)
□ 已完成区的 items 数组引用稳定(没有每帧重建)
□ 新加的 workaround 在代码里写清了「它绕的是什么」
□ 用了「引用相等跳过工作」的优化 → 有性能断言守着
□ 新加的防线 → 手动制造异常场景真的触发过一次
□ 退出路径(含崩溃)会恢复终端状态:光标、alt screen、鼠标追踪
□ 在 tmux 和 VS Code 内置终端里各跑过一次
□ 决策/确认类内容(尤其权限框的 reason)没有被折叠或截断最后:这份文档想让你记住的三件事
如果一周后你只记得三件事,我希望是这三件。
第一件:那个选择比所有优化都重要。
主屏还是备用屏,归结于「谁拥有那片格子」。 主屏:格子属于终端,所以终端的能力(无限滚动、原生选择)你免费得到, 但你改不了已经交出去的格子。 备用屏:格子属于你,所以你能任意改,但终端的能力一个也拿不到。
这个选择在写第一行代码前就要定,而且它是唯一不可逆的决策。 后面十四章讲的技术,全部是在你选定之后才有意义的。
第二件:机理比结论重要,因为结论会过期而机理不会。
本文核对时发现,一份设计规范的路径全错了(17 处引用一个已不存在的目录), 但每一条设计判据都还对。
这告诉你老文档该怎么读: 判据照用,机理理解后用,数字和路径一律复跑。 也告诉你新文档该怎么写:把前提写进代码,不要写进记忆。
第三件(最重要的):这个领域的严重问题全都不报错。
八种失效模式——过期的 workaround、被 memo 破坏的优化、 缺失的防线、用错区域的组件、漂移的文档、没接线的能力、 算错的口径、默认关闭的能力—— 它们的共同点是:代码在、类型过、测试绿、机理讲得通, 而结论是错的、能力是丢的、用户是受损的。
所以最有用的三个问题不是「怎么做对」,而是:
① 如果它坏了,我会怎么发现? ← 答不上来 = 它已经可能坏了 ② 这个结论的前提,现在还成立吗? ③ 这个数字/能力,换个环境还对吗?这三个问题在终端 UI 上有效,在 provider 层、可观测性、 评测系统上同样有效——它们是基础设施类工作的共性。
文档信息 撰写日期:2026-09-02 · 最后核对:2026-09-02 主要来源:
claude-code/docs/chapter-12-terminal-ui.md(📄 二手)
- sid-code 源码实读(🔬 一手,
packages/tui-renderer/与packages/cli/src/ui/)adr/ADR-040与bugfixes/done/TUI渲染与交互/十份踩坑记录(🔬 一手)本文所有 🔬 标记的数字均由附录 B 的命令实跑得出,非估算。 发现任何数字与实测不符,请以复跑结果为准并更新本文—— 一份自己都不复跑的文档,正是它第 §12.5 节所批评的那种东西。