# 终端里的 React：Ink Fork 深度解构

本章解析 Claude Code 如何将完整的 React 应用运行在纯文本终端中——通过深度 fork Ink 框架的 96 个文件，把虚拟 DOM 翻译为 ANSI 转义序列，实现流式渲染、权限弹窗和复杂布局。

> **🌍 行业背景**：终端 UI 框架是一个小众但活跃的领域。Node.js 生态有 Ink（Vadim Demedes 创建的 React-for-CLI 框架）和更老的 Blessed/blessed-contrib（功能丰富但已停止维护）；Go 生态有 Bubbletea（Charm 团队出品，声明式架构）和 Charm 系列 CLI 美化工具；Python 生态有 Textual（基于 Rich 库的全功能 TUI 框架）。但绝大多数 AI 编码工具根本不构建自定义终端 UI——Cursor 和 Windsurf 使用 VS Code 的 Webview/Extension API，本质上是浏览器渲染；Aider 使用纯文本输出，没有任何 TUI 框架；Codex（OpenAI）底层已用 Rust 重写（具体比例以官方为准），使用自有终端接口；**OpenCode** 使用 Go+Zig 构建的高级 TUI，通过 Tab 键切换 Build/Plan 模式。Claude Code 选择 fork Ink 并构建一个 19,842 行的自定义框架，在行业中是一个明显的异类——这个决策的驱动力是需要在终端中实现 React 级别的组件组合能力（状态管理、事件冒泡、虚拟滚动）。这类似于游戏引擎为什么要自建渲染器而不用浏览器 DOM——当渲染目标和交互模型与 Web 根本不同时，复用现有框架的成本反而高于自建。

---

## 引子：在记事本里运行 Photoshop

想象有人在 Windows 记事本的窗口里运行了完整的 Photoshop——有图层面板、工具栏、颜色选择器、预览窗口。你以为是文本界面，但实际上是一个完整的图形应用。

Claude Code 做的就是这件事。终端（Terminal）本质上只能显示文本字符和 ANSI 颜色码——没有按钮、没有弹窗、没有下拉菜单。但 Claude Code 在终端里运行了一个完整的 **React 应用**——有组件树、状态管理、事件处理、生命周期钩子。

这怎么做到的？答案是一个 **完整 fork 的 Ink 框架**。不是简单的依赖引用，而是 96 个文件、19,842 行代码的深度重写。

> **城市类比：** 如果 Claude Code 是一座城市，Ink 就是它的 **显示屏与广告牌系统**。城市里发生的一切——车流量变化（流式 AI 输出）、交通信号灯切换（权限弹窗）、路牌更新（状态变化）——最终都要通过这套显示系统变成肉眼可见的信息。而这套系统本身不做任何业务决策，它只负责一件事：**把 React 虚拟 DOM 翻译成终端可以显示的 ANSI 转义序列**。
>
> 💡 **通俗理解**：终端 UI 就像**电影字幕系统**——演员的台词 = AI 输出的内容，字幕组 = Ink 渲染器（负责把内容变成屏幕上看得见的画面），荧幕 = 你的终端窗口，实时翻译 = 流式渲染（AI 说一个字，屏幕就显示一个字，而不是等全说完才一起出现）。

---

## 1. 为什么 Fork Ink？

### 1.1 问题：原版 Ink 满足不了什么需求？

社区版 Ink（由 Vadim Demedes 创建）是一个轻量级框架，设计目标是让开发者用 React 写简单的 CLI 工具——进度条、表格、交互式菜单。但 Claude Code 的 UI 复杂度远超"简单 CLI"：

| 需求 | 原版 Ink | Claude Code 需要 |
|------|---------|-----------------|
| 布局引擎 | Yoga WASM（需要加载 .wasm 文件） | 纯 TypeScript Yoga（零 WASM 开销） |
| 滚动 | 不支持 | 虚拟滚动 + DECSTBM 硬件加速 |
| 鼠标事件 | 不支持 | 完整的点击、拖拽、hover |
| 文本选择 | 不支持 | 跨行选择、双击选词、拖拽选择 |
| 搜索高亮 | 不支持 | 全屏搜索、逐匹配导航 |
| 焦点管理 | 简陋的 Tab 切换 | 完整的焦点栈、自动焦点、点击焦点 |
| 双向文本 | 不支持 | Unicode Bidi 算法 |
| 事件系统 | 无 | 完整的捕获/冒泡 DOM 事件模型 |
| 性能优化 | 全屏重绘 | blit 脏区域更新、Screen 双缓冲 |
| React 版本 | React 18 | React 19（Concurrent Root） |

这不是"打几个补丁"能解决的差距。Anthropic 团队做了一个务实的工程决策：**完整 fork，深度重写**。

### 1.2 数字：Fork 的规模

| 指标 | 数值 | 含义 |
|------|------|------|
| `ink/` 目录总文件数 | 96 | 几乎是一个独立框架的规模 |
| 总代码行数 | 19,842 | 比很多开源 UI 框架的核心代码都多 |
| `ink.tsx` 单文件行数 | 1,722 | 框架的"大脑"——所有协调逻辑集中于此 |
| `screen.ts` | 1,486 | 屏幕缓冲区实现——终端界面的 Canvas |
| `render-node-to-output.ts` | 1,462 | 渲染管线核心——DOM 树到屏幕缓冲区 |
| `selection.ts` | 917 | 文本选择系统——比大多数编辑器的选择逻辑都复杂 |
| `output.ts` | 797 | 操作队列——收集渲染指令并应用到 Screen |
| `log-update.ts` | 773 | 差异计算——Screen 缓冲区到终端 ANSI 序列 |
| `styles.ts` | 771 | CSS Flexbox 属性映射——40+ 样式属性 |
| `reconciler.ts` | 512 | React Reconciler 适配——连接 React 19 Fiber |
| `layout/` 子目录（4 文件） | ~560 | 纯 TS Yoga 布局引擎适配层 |
| `components/` 子目录（18 文件） | ~1,200 | 基础 UI 组件库 |
| `events/` 子目录（10 文件） | ~500 | DOM 风格事件系统 |
| `hooks/` 子目录（12 文件） | ~400 | React Hooks 集合 |
| `termio/` 子目录（9 文件） | ~800 | 终端 I/O 原语（CSI、OSC、DEC 序列） |

**19,842 行代码意味着什么？** 作为参考，社区版 Ink 的核心代码大约 3,000-4,000 行。Claude Code 的 Fork 版本是原版的 **5 倍以上**。这不是简单的 fork——增量部分（约 16,000 行）的规模相当于重新建造了一个框架。但需要注意，这种"fork + 深度定制"的模式在工程实践中并不罕见——Linux 内核中的各种 vendor fork（如 Android 内核）、Chrome 的 Blink 引擎（fork 自 WebKit）都是类似案例。Claude Code 的独特之处在于：它在一个 **CLI 工具** 中投入了通常只有 GUI 应用才会做的 UI 工程量。

> **数字核验说明**：上表中逐个列出的核心文件（`ink.tsx`、`screen.ts`、`render-node-to-output.ts`、`selection.ts`、`output.ts`、`log-update.ts`、`styles.ts`、`reconciler.ts`）加上 5 个子目录的约数，合计约 11,900 + 3,460 ≈ 15,360 行；剩下的约 4,480 行分散在 `dom.ts`（484 行）、`ink-box.tsx`、`types.ts`、多个小型辅助文件、以及 `keybindings/`（14 文件）和 `vim/`（5 文件）等没有单独列出的目录中。总数 19,842 是对 `ink/` 目录所有 96 个 TypeScript/TSX 文件执行 `wc -l` 的汇总——读者若要独立验证，命令为 `find src/ink -name '*.ts' -o -name '*.tsx' | xargs wc -l`。

### 1.3 技术栈全景

```
React 19（声明式 UI 逻辑 + Concurrent Root）
  ↓ createReconciler() — 512 行自定义 Reconciler
ink-root / ink-box / ink-text（自定义 Host 元素）
  ↓ dom.ts — 484 行虚拟 DOM 操作
Yoga Layout Engine（纯 TypeScript 移植，非 WASM）
  ↓ layout/ — CSS Flexbox 计算
render-node-to-output.ts（DOM 树 → 屏幕操作队列）
  ↓ 1,462 行递归遍历 + blit 优化
output.ts → screen.ts（屏幕缓冲区写入）
  ↓ 双缓冲、字符池、样式池
log-update.ts（屏幕缓冲区 diff → ANSI 补丁序列）
  ↓ optimizer.ts — 7 条优化规则
Terminal stdout（最终 ANSI 转义序列输出）
```

---

## 2. 纯 TypeScript Yoga 布局引擎

### 2.1 为什么不用 WASM Yoga？

**Q：原版 Ink 使用 yoga-wasm-web 或 yoga-layout 的 WASM 构建。为什么 Claude Code 要改用纯 TypeScript？**

答案在 `layout/yoga.ts` 的注释中：

> "The TS yoga-layout port is synchronous — no WASM loading, no linear memory growth, so no preload/swap/reset machinery is needed."

WASM Yoga 的三个痛点：

1. **启动延迟**：WASM 模块需要异步加载和编译。对一个 CLI 工具来说，每次启动多等几十毫秒意味着用户体验的退化。
2. **内存管理**：WASM 的线性内存（linear memory）会增长但不能收缩。长时间运行的 Claude Code 会话会看到 WASM 内存持续攀升。
3. **并发安全**：WASM 通过指针访问节点。如果 React 的并发模式（Concurrent Mode）在布局计算期间回收了一个节点，后续访问会碰到已释放的 WASM 内存——经典的 use-after-free 漏洞。

纯 TypeScript 实现（通过 `src/native-ts/yoga-layout/` 导入）把布局计算变成了普通的 JavaScript 对象操作——**同步、可预测、GC 友好**。

> 📚 **课程关联**：Yoga 布局引擎实现的是 CSS Flexbox 规范的子集——这在前端开发课程中是基础内容。但在终端环境中，"渲染目标"从浏览器像素变成了 ANSI 字符单元格，约束完全不同：每个字符占固定宽度（CJK 字符占两个），没有子像素渲染，没有浮动布局。理解 Flexbox 如何在字符网格上工作，是理解这套 UI 系统的基础。

### 2.2 布局适配层架构

`layout/` 子目录只有 4 个文件，但它是一个精心设计的抽象层：

```
layout/
├── node.ts      — LayoutNode 接口定义（152 行，50+ 个方法签名）
├── yoga.ts      — YogaLayoutNode 适配器（308 行，映射到具体 Yoga 实现）
├── engine.ts    — 工厂函数（7 行，创建入口）
└── geometry.ts  — 几何原语（98 行，Point/Size/Rectangle/Edges）
```

**`node.ts`** 定义了 `LayoutNode` 接口——这是一个 **桥接模式（Bridge Pattern）** 的经典应用。接口声明了 50+ 个方法：树操作（`insertChild`、`removeChild`）、布局计算（`calculateLayout`、`markDirty`）、测量（`setMeasureFunc`）、布局结果读取（`getComputedLeft/Top/Width/Height`）、以及完整的 CSS Flexbox 样式设置器（`setFlexDirection`、`setAlignItems`、`setJustifyContent` 等）。

接口包含 8 种代表性的**布局相关枚举值**映射（包括 `flex-direction`、`align-items`、`justify-content`、`position`、`overflow`、`display` 等多类布局属性——"对齐方式"是作者对这张表的俗称，严格说表里既有对齐也有其他布局属性）：

| LayoutNode 枚举 | CSS 等价 | 用途 |
|-----------------|---------|------|
| `LayoutFlexDirection.Column` | `flex-direction: column` | 默认垂直排列（终端内容自上而下） |
| `LayoutFlexDirection.Row` | `flex-direction: row` | 水平排列（如按钮行） |
| `LayoutAlign.Center` | `align-items: center` | 居中对齐 |
| `LayoutAlign.FlexStart` | `align-items: flex-start` | 顶部/左侧对齐 |
| `LayoutJustify.SpaceBetween` | `justify-content: space-between` | 均匀分布 |
| `LayoutPositionType.Absolute` | `position: absolute` | 绝对定位（覆盖层） |
| `LayoutOverflow.Scroll` | `overflow: scroll` | 可滚动区域 |
| `LayoutDisplay.None` | `display: none` | 隐藏节点 |

**`yoga.ts`** 是具体适配器——`YogaLayoutNode` 类包装了纯 TS Yoga 实例，提供 Edge/Gutter 映射和 MeasureMode 转换。核心工厂函数只有一行：

```typescript
export function createYogaLayoutNode(): LayoutNode {
  return new YogaLayoutNode(Yoga.Node.create())
}
```

为什么要多此一举搞抽象层，而不直接用 Yoga？因为这给了团队 **替换底层引擎的自由**。如果未来纯 TS Yoga 的性能不够用，可以换回 WASM 版本——只需要写一个新的 `LayoutNode` 实现，上层代码（dom.ts、reconciler.ts、render-node-to-output.ts）不用改动一行。

### 2.3 文本测量：终端布局最困难的部分

布局引擎需要知道每个节点的尺寸。对于 `ink-box`，尺寸由 CSS Flexbox 规则决定。但对于 `ink-text`，尺寸取决于 **文本内容本身的宽度和高度**——而终端中的文本宽度计算远比浏览器复杂。

`dom.ts` 中的 `measureTextNode` 函数（第 332-374 行）实现了文本测量逻辑：

1. **提取文本**：通过 `squashTextNodes` 将嵌套的 `<Text>` 树扁平化为纯字符串
2. **Tab 展开**：`expandTabs(rawText)` 将制表符展开为空格（最坏情况每个 Tab 8 个空格）
3. **初始测量**：`measureText(text, width)` 计算自然宽高
4. **换行处理**：如果文本超宽，根据 `textWrap` 模式（wrap/truncate/truncate-end 等）调用 `wrapText`
5. **MeasureMode 感知**：`Undefined` 模式下多行文本使用自然宽度；`Exactly`/`AtMost` 模式下必须尊重约束

这里有一个精妙的细节：当 `widthMode === LayoutMeasureMode.Undefined` 且文本包含换行符时，函数 **不** 按约束宽度重新换行。为什么？因为 Yoga 在计算最小/最大尺寸时会用 `Undefined` 模式探测——如果这时候按照一个很窄的宽度重新换行，会导致 **高度膨胀**，让布局引擎认为这个节点比实际需要的更高。

---

## 3. Reconciler 管线：从 React 到终端的完整路径

### 3.1 管线总览

```
用户交互/API 数据 → React setState
                         ↓
            React Fiber 调度（Concurrent Root）
                         ↓
            Reconciler 创建/更新/删除 DOMElement
                         ↓ resetAfterCommit()
            Yoga calculateLayout（纯 TS，同步）
                         ↓ onComputeLayout()
            renderNodeToOutput（DOM → Output 操作队列）
                         ↓
            output.get() → Screen 缓冲区
                         ↓
            LogUpdate diff（Screen vs prevScreen → Diff 补丁）
                         ↓ optimizer.optimize()
            writeDiffToTerminal → stdout.write(ANSI sequences)
```

> 💡 **通俗理解**：整个渲染管线就像**印刷厂的流水线**——编辑给排版指令（React setState）→ 排版工人按指令调整版面（Reconciler 更新 DOM）→ 用尺子量好每个文字的位置（Yoga 布局计算）→ 制版师把内容刻到印版上（render-node-to-output 写入 Screen 缓冲区）→ 校对员对比新旧版本只标出差异（LogUpdate diff）→ 最后只修改有差异的地方（ANSI 补丁输出到终端）。绝大多数时候只有最后一行在变化，所以"只印一行"就够了。

### 3.2 Step 1：React Reconciler（reconciler.ts, 512 行）

`reconciler.ts` 调用 `createReconciler<>()` 创建一个 React 19 兼容的自定义宿主环境。这是 React 的 **宿主适配器模式**——就像 `react-dom` 是 React 在浏览器 DOM 上的宿主，这个 reconciler 是 React 在终端上的宿主。

7 种宿主元素类型（定义在 `dom.ts` 第 19-27 行）：

| 元素名 | 用途 | 浏览器等价 |
|--------|------|-----------|
| `ink-root` | 根节点 | `<html>` |
| `ink-box` | 布局容器 | `<div style="display:flex">` |
| `ink-text` | 文本容器 | `<span>` |
| `ink-virtual-text` | 嵌套文本 | 嵌套 `<span>` |
| `ink-link` | 超链接 | `<a>` |
| `ink-progress` | 进度条 | `<progress>` |
| `ink-raw-ansi` | 预渲染 ANSI 内容 | `<canvas>` 的预渲染模式 |

> 📚 **课程关联**：React Reconciler 是 React 架构课中的高级主题。React 的核心理念——声明式 UI、虚拟 DOM diff、Fiber 调度——与渲染目标无关。`react-dom` 渲染到浏览器，`react-native` 渲染到移动端，这里的自定义 Reconciler 渲染到终端 ANSI 序列。理解 Reconciler 的宿主适配机制，就理解了 React "一次学习，到处编写" 的架构本质。

Reconciler 中几个关键决策：

**严格的嵌套校验**（第 338-339 行）：`<Box>` 不能嵌套在 `<Text>` 内部。如果尝试这么做，会直接 `throw new Error`。这是对 Ink 语义的硬性约束——在终端中，布局容器不可能出现在文本流内部。

**文本必须包裹在 `<Text>` 中**（第 365-368 行）：裸字符串 `"hello"` 如果不在 `<Text>` 内部出现，同样会 throw。这保证了所有文本都有可追溯的样式上下文。

**React 19 的 commitUpdate 新签名**（第 427-458 行）：React 19 不再使用 `updatePayload`（React 18 的 `prepareUpdate` 返回值），而是直接传入 `oldProps` 和 `newProps`。reconciler 使用自定义 `diff()` 函数比较属性变化，只应用差异部分。

**性能埋点**（第 189-222 行）：`COMMIT_LOG` 环境变量开启时，reconciler 会记录每次 commit 的耗时、Yoga 节点统计（visited/measured/cacheHits/live），以及超过 30ms 间隔或 20ms reconcile 时间的慢帧。这是生产级性能监控。

### 3.3 Step 2：虚拟 DOM（dom.ts, 484 行）

`dom.ts` 实现了一个极简的虚拟 DOM。`DOMElement` 类型定义了 30+ 个字段（第 31-91 行），其中大部分与滚动相关：

- `scrollTop`、`pendingScrollDelta`、`scrollClampMin/Max`、`scrollHeight`、`scrollViewportHeight`——这些字段让 `DOMElement` 同时扮演了 **DOM 节点** 和 **滚动容器** 双重角色
- `dirty` 标志用于脏区域追踪：只有 dirty 的子树才需要重新渲染
- `_eventHandlers` 独立于 `attributes` 存储——这是一个关键优化：事件处理函数的身份（identity）每次 React render 都会变化，如果存在 attributes 里就会导致 `markDirty`，进而让 blit 优化失效

`markDirty` 函数（第 393-413 行）是整个脏区域追踪系统的核心：它沿着 `parentNode` 链向上冒泡 `dirty = true`，同时只对叶子文本节点（`ink-text`、`ink-raw-ansi`）调用 `yogaNode.markDirty()` 触发 Yoga 重新测量。这保证了 **向上冒泡** 的脏传播——渲染器从根节点开始，沿着 dirty 路径向下遍历，跳过所有 clean 子树。

### 3.4 Step 3：渲染器管线（renderer.ts → render-node-to-output.ts → output.ts → screen.ts）

**renderer.ts**（178 行）是渲染的入口。`createRenderer()` 返回一个闭包，每帧调用一次。关键设计：

- **Output 对象复用**：`let output: Output | undefined` 在帧间持久化，这样 `charCache`（文本行的 grapheme 聚类缓存）不会每帧重建——这意味着对于未变化的文本行，解析成本为零
- **双缓冲**：`frontFrame` 和 `backFrame` 交替使用，当前帧写入 backFrame.screen，完成后交换
- **Alt-screen 高度钳制**（第 97-104 行）：当 Yoga 计算的高度超过终端行数时，强制钳制为 `terminalRows`，防止溢出写入导致终端光标位置混乱

**render-node-to-output.ts**（1,462 行）是最复杂的单个文件。它递归遍历 DOM 树，对每个节点执行：

1. **脏检查 + blit 优化**：如果节点不 dirty 且 prevScreen 有缓存，直接从上一帧拷贝像素（`blitRegion`）——O(1) 而非 O(节点内容)
2. **滚动处理**：计算 `overflow: scroll` 容器的可见区域，应用 `scrollTop` 偏移，处理 `pendingScrollDelta` 的逐帧排空（drain）
3. **DECSTBM 硬件滚动提示**：当 ScrollBox 的 scrollTop 变化时，记录 `ScrollHint`，告诉 log-update 可以用终端硬件滚动指令（CSI S/CSI T）代替逐行重写
4. **absolute 定位特殊处理**：绝对定位节点可能跨越兄弟子树绘制，移除时必须禁用 blit 全帧重绘

滚动的 **逐帧排空机制**（第 106-157 行）值得特别关注。快速滚动会积累大量 `pendingScrollDelta`，如果一次性应用会导致画面跳跃。代码实现了两种排空策略：

- **原生终端**（iTerm2/Ghostty）：比例排空——每帧消耗剩余量的 3/4，对数收敛（log4 帧赶上）
- **xterm.js**（VS Code）：自适应排空——低 pending（<=5 行）一帧全部消耗（慢滚动应该是即时的），高 pending 按 2-3 行/帧的固定步长消耗（快滚动保持动画流畅）

**screen.ts**（1,486 行）实现了核心数据结构 `Screen`——终端界面的 **帧缓冲区**。三种内存池确保了高效的字符串去重：

| 池 | 类型 | 作用 | 为什么需要 |
|----|------|------|-----------|
| `CharPool` | 字符串池 | 将 grapheme cluster 映射为整数 ID | 比较两个 cell 的字符只需比较 int，不需要字符串比较 |
| `StylePool` | ANSI 样式池 | 将 AnsiCode[] 映射为整数 ID | diff 时只需比较 styleId，不需要逐个比较 SGR 参数 |
| `HyperlinkPool` | 超链接池 | 将 OSC 8 超链接 URL 映射为整数 ID | 每 5 分钟重置，防止长会话内存泄漏 |

`CharPool` 的 ASCII 快速路径（第 29-40 行）是一个微优化典范：ASCII 字符（charCode < 128）使用直接数组索引 `this.ascii[code]`，跳过 `Map.get` 的哈希开销。对于终端 UI 中 95%+ 的内容都是 ASCII 的场景，这是有意义的。

### 3.5 Step 4：差异计算与终端输出（log-update.ts, 773 行）

`LogUpdate` 类把两帧之间的 Screen 差异计算为一组 **补丁指令（Diff）**：

```typescript
type Diff = Array<
  | { type: 'stdout'; content: string }
  | { type: 'cursorMove'; x: number; y: number }
  | { type: 'cursorTo'; col: number; row: number }
  | { type: 'styleStr'; str: string }
  | { type: 'hyperlink'; uri: string | undefined }
  | { type: 'cursorShow' }
  | { type: 'cursorHide' }
  | { type: 'clear'; count: number }
  | { type: 'carriageReturn' }
>
```

补丁序列经过 **optimizer.ts**（93 行）的 7 条优化规则后才写入终端：

1. **移除空 stdout 补丁**：`content === ''` 直接跳过
2. **合并连续 cursorMove**：两次移动合并为一次（减少 CSI 序列数量）
3. **坍缩连续 cursorTo**：只保留最后一次定位
4. **拼接相邻 style 补丁**：两个 SGR 序列合并为一个字符串写入
5. **去重连续 hyperlink**：相同 URL 的 OSC 8 不重复发送
6. **抵消 cursor hide/show 对**：一次隐藏后立即显示 = 什么都不做
7. **移除空 clear 补丁**：`count === 0` 直接跳过

> 📚 **课程关联**：这种"只更新变化部分"的策略本质上就是图形学课程中的**脏矩形渲染**（dirty rectangle rendering）——游戏引擎不会每帧重绘整个屏幕，而是只重绘发生变化的区域。Claude Code 的四层过滤（React dirty → node dirty → screen cell diff → patch optimize）和游戏引擎的渲染管线（场景图遍历 → 可见性剔除 → 脏区域收集 → GPU 提交）是同构的。

这些优化看似简单，但对终端性能至关重要。每条 ANSI 序列都是一次 `stdout.write` 调用，而 Node.js 的 `write()` 涉及 libuv I/O 层、系统调用、以及终端模拟器的解析开销。减少 20-30% 的补丁数量直接转化为 20-30% 的帧延迟降低。

---

## 4. 自定义组件库

### 4.1 12 个基础组件 + 6 个上下文 Provider

`components/` 目录包含 18 个文件——**12 个基础组件**（视觉/交互原子）+ **6 个上下文 Provider**（跨组件状态共享），这是 Ink Fork 的 **原子组件库**，上层的 389 个业务组件全部基于这些原子构建。

| 组件 | 文件 | 角色 |
|------|------|------|
| `Box` | `Box.tsx` | 布局容器，等价于 `<div style="display:flex">` |
| `Text` | `Text.tsx` | 文本渲染，支持颜色、粗体、下划线等 |
| `ScrollBox` | `ScrollBox.tsx` | 可滚动容器，支持虚拟滚动和硬件加速 |
| `App` | `App.tsx` | 应用根组件，管理事件分发和上下文 |
| `AlternateScreen` | `AlternateScreen.tsx` | 备用屏幕模式（全屏 UI） |
| `Button` | `Button.tsx` | 可点击按钮（终端中的 GUI 按钮） |
| `Link` | `Link.tsx` | OSC 8 超链接 |
| `RawAnsi` | `RawAnsi.tsx` | 预渲染 ANSI 内容直通 |
| `Spacer` | `Spacer.tsx` | 弹性空间填充 |
| `Newline` | `Newline.tsx` | 换行占位 |
| `NoSelect` | `NoSelect.tsx` | 禁止选择区域（如行号栏） |
| `ErrorOverview` | `ErrorOverview.tsx` | 错误展示 |

以及 6 个上下文 Provider：`AppContext`、`StdinContext`、`ClockContext`、`TerminalSizeContext`、`TerminalFocusContext`、`CursorDeclarationContext`。

### 4.2 Box：终端里的 Flexbox div

`Box` 组件（`Box.tsx`）是使用最频繁的组件。它接受的 Props 直接对应 CSS Flexbox 属性：

```typescript
type Props = Except<Styles, 'textWrap'> & {
  tabIndex?: number;
  autoFocus?: boolean;
  onClick?: (event: ClickEvent) => void;
  onFocus?: (event: FocusEvent) => void;
  onBlur?: (event: FocusEvent) => void;
  onKeyDown?: (event: KeyboardEvent) => void;
  onMouseEnter?: () => void;
  onMouseLeave?: () => void;
};
```

注意 `onClick`、`onMouseEnter`/`onMouseLeave`、`onKeyDown`——这些是 **浏览器 DOM 风格的事件**，在终端中实现。Box 在终端里不仅是布局容器，还是交互容器。

### 4.3 ScrollBox：终端中最复杂的组件

`ScrollBox.tsx` 实现了终端中的可滚动区域——这是浏览器用户毫不在意但终端实现极度困难的功能。它的 `ScrollBoxHandle` 接口暴露了 10 个命令式方法：

- `scrollTo(y)`/`scrollBy(dy)`/`scrollToBottom()` — 滚动控制
- `scrollToElement(el, offset)` — 滚动到指定 DOM 元素（延迟到 render 时读取 Yoga 坐标，避免过时数据）
- `getScrollTop()`/`getScrollHeight()`/`getViewportHeight()` — 状态查询
- `getFreshScrollHeight()` — 绕过缓存直接读取 Yoga（在 `useLayoutEffect` 后需要最新值时使用）
- `isSticky()` — 是否跟随底部（自动滚动到最新内容）
- `setScrollClamp(min, max)` — 虚拟滚动的视口钳制

**为什么 `scrollToElement` 比 `scrollTo(number)` 更优？** 因为 `scrollTo` 的参数是一个在调用时确定的数字，但 React 的渲染是异步的——调用 `scrollTo(500)` 后，可能经过 16ms 的节流延迟才执行 render，此时布局已经变化，500 不再是正确的位置。`scrollToElement` 传入一个 DOM 元素引用，render 时再读取 `el.yogaNode.getComputedTop()`——同一帧 Yoga 布局中计算出的坐标，保证精确。

---

## 5. 事件系统与焦点管理

### 5.1 DOM 风格事件模型

`events/` 目录（10 文件）实现了一个完整的 **捕获/冒泡事件系统**——这是终端 UI 中极为罕见的设计。

- `dispatcher.ts` — 事件分发器，管理优先级和 `discreteUpdates`（连接 React 的更新调度）
- `keyboard-event.ts` — 键盘事件（`onKeyDown`/`onKeyDownCapture`）
- `focus-event.ts` — 焦点事件（`onFocus`/`onBlur` + capture 变体）
- `click-event.ts` — 点击事件（col/row + localCol/localRow 坐标）
- `input-event.ts` — 输入事件

事件处理函数存储在 `DOMElement._eventHandlers` 中（与 `attributes` 分离），reconciler 的 `applyProp` 函数在第 122-143 行判断 `EVENT_HANDLER_PROPS.has(key)` 来决定走哪条路径。

### 5.2 点击命中测试（hit-test.ts, 130 行）

`hitTest()` 函数实现了经典的 **从叶子到根的碰撞检测**：

1. 从 `nodeCache`（WeakMap）读取节点的屏幕坐标矩形
2. 判断 (col, row) 是否在矩形内
3. **逆序** 遍历子节点（后绘制的在上层，优先命中）
4. 返回最深层的命中节点

`dispatchClick()` 在命中后做两件事：

- **点击聚焦**：沿 parentNode 向上找到第一个 `tabIndex >= 0` 的祖先，调用 `FocusManager.handleClickFocus`
- **事件冒泡**：从命中节点向上遍历 parentNode，对每个有 `onClick` 处理器的节点触发事件，支持 `stopImmediatePropagation()` 终止冒泡

`dispatchHover()`（第 102-130 行）实现了 `mouseenter`/`mouseleave` 语义——不冒泡的，类似浏览器 DOM：在子节点之间移动不会在父节点上重新触发。

### 5.3 焦点管理器（focus.ts, 181 行）

`FocusManager` 类是一个完整的 DOM 风格焦点管理器：

- **焦点栈**（最大 32 层）：记录焦点历史，节点被移除时从栈中恢复焦点
- **Tab/Shift+Tab 循环**：`collectTabbable(root)` 收集所有 `tabIndex >= 0` 的节点，`moveFocus(direction)` 实现循环导航
- **自动焦点**：`autoFocus` 属性在 reconciler 的 `commitMount` 阶段触发
- **失焦恢复**：`handleNodeRemoved()` 不仅清理当前焦点，还从焦点栈中找到最近的仍在树上的节点恢复焦点——防止 React 动态渲染导致焦点"消失"

焦点栈有一个去重机制（第 34 行）：`Tab` 循环会反复把同一个节点推入栈，所以 `focus()` 会先移除已存在的条目再推入，保持栈有界。

---

## 6. 文本选择系统

### 6.1 为什么终端需要自建选择？

终端模拟器（iTerm2、Terminal.app 等）有自己的文本选择功能。但 Claude Code 运行在 **Alt-screen 模式**（DECSET 1049），这个模式下：

- 终端的选择只能选 **当前屏幕的字符**，无法选择已滚出屏幕的内容
- 终端不知道 Claude Code 的虚拟滚动——它选中的可能是已被覆盖的旧内容
- 终端的选择无法和 Claude Code 的搜索高亮配合

所以 Claude Code 自建了完整的选择系统——**`selection.ts`（917 行）** 是 `ink/` 目录中第五大的文件。

### 6.2 选择状态模型

```typescript
type SelectionState = {
  anchor: Point | null;       // 鼠标按下位置
  focus: Point | null;        // 当前拖拽位置
  isDragging: boolean;        // 是否在拖拽中
  anchorSpan: {               // 双击/三击的初始范围
    lo: Point; hi: Point;
    kind: 'word' | 'line'
  } | null;
  scrolledOffAbove: string[]; // 向上滚出视口的文本
  scrolledOffBelow: string[]; // 向下滚出视口的文本
  scrolledOffAboveSW: boolean[]; // 并行的软换行标记
  scrolledOffBelowSW: boolean[];
  virtualAnchorRow?: number;  // 钳制前的锚点行
  virtualFocusRow?: number;   // 钳制前的焦点行
  lastPressHadAlt: boolean;   // macOS Option 键状态
};
```

这个状态模型的复杂度值得关注：`scrolledOffAbove/Below` 数组解决了 **拖拽选择时内容滚动** 的问题。当用户向下拖拽超出视口底部，ScrollBox 开始自动滚动——屏幕缓冲区只保存当前可见内容，滚出去的行会丢失。`captureScrolledRows` 在滚动前从 frontFrame 中提取将要滚出的行文本并保存到这些数组中，`getSelectedText` 最终把它们和当前屏幕文本拼接起来。

`softWrap` 标记（`scrolledOffAboveSW`/`BelowSW`）解决了另一个微妙问题：当文本因为终端宽度限制被自动换行（soft-wrap），选择结果应该把这些"假换行"还原——`getSelectedText` 用这些标记决定是插入 `\n` 还是直接拼接。

### 6.3 双击选词

`charClass()` 函数（第 151-155 行）将字符分为三类：

- 空白（0）：空格、空串
- 词字符（1）：Unicode 字母/数字 + `/-+\~_.`
- 符号（2）：其他

选词规则匹配 iTerm2 的默认行为：`/usr/bin/bash` 整体算一个词（包含 `/`），`~/.claude/config.json` 也是一个词。这是有意为之的——macOS 终端用户的肌肉记忆是双击路径选中整个路径。

---

## 7. 双向文本支持（bidi.ts, 139 行）

### 7.1 问题：终端不懂阿拉伯语

**Q：为什么需要软件 Bidi？终端不支持 Unicode 吗？**

终端支持 Unicode **字符**，但不一定支持 **Unicode Bidi Algorithm**。Bidi 算法决定了混合方向文本（如 "Hello مرحبا World"）中每个字符的 **视觉位置**。

macOS 的 Terminal.app 和 iTerm2 原生实现了 Bidi。但 **Windows Terminal 没有**（GitHub issue #538）。Claude Code 在这些终端上运行时，RTL 文本（希伯来语、阿拉伯语等）会显示为反转。

### 7.2 解决方案：条件 Bidi 重排

`bidi.ts` 实现了三层判断：

1. **平台检测**（`needsBidi()`）：`process.platform === 'win32'` 或 `WT_SESSION` 环境变量（WSL in Windows Terminal）或 `TERM_PROGRAM === 'vscode'`（VS Code 集成终端使用 xterm.js）
2. **RTL 快速检测**（`hasRTLCharacters()`）：正则匹配希伯来语（U+0590-U+05FF）、阿拉伯语（U+0600-U+06FF 等 5 个范围）、叙利亚语、塔纳语字符——纯 LTR 文本零开销
3. **标准 Bidi 重排**：使用 `bidi-js` 库获取 embedding levels，然后从最高 level 到 1 逐级反转——标准 Unicode Bidi 算法的 L2 规则

`reorderBidi()` 操作的是 `ClusteredChar[]`（已解析的 grapheme cluster 数组），而不是原始字符串。这确保了重排后每个 grapheme 的宽度、样式 ID、超链接信息都保持正确。

---

## 8. 终端探测器（terminal-querier.ts, 212 行）

### 8.1 问题：不同终端能力差异巨大

Claude Code 需要知道用户的终端支持什么功能：同步输出（mode 2026）？Kitty 键盘协议？grapheme clustering（mode 2027）？背景色是亮色还是暗色？

传统做法是靠 `TERM_PROGRAM` 环境变量猜。但这不可靠——SSH 隧道会丢失环境变量，tmux 会覆盖 TERM 值。

### 8.2 解决方案：DA1 哨兵查询

`TerminalQuerier` 实现了一个 **零超时** 的终端能力探测协议：

1. **发送查询序列**（如 DECRQM CSI ? 2026 $ p）到 stdout
2. **发送 DA1 哨兵**（CSI c）——这是 VT100 以来 **每个终端都必须回应** 的请求
3. **等待响应**：终端按顺序回应（FIFO）
   - 如果查询的响应在 DA1 之前到达 → 终端支持该功能
   - 如果 DA1 先到达 → 终端不支持，resolve 为 undefined

这个设计消除了超时猜测。传统做法是"发送查询，等 100ms，如果没收到就算不支持"——但 100ms 在 SSH 高延迟场景下可能不够，在本地又是不必要的等待。DA1 哨兵把"等多久"变成了"等到哨兵响应"——**自适应延迟**。

```typescript
// 使用示例（来自注释）
const [sync, grapheme] = await Promise.all([
  querier.send(decrqm(2026)),    // 同步输出模式
  querier.send(decrqm(2027)),    // grapheme clustering 模式
  querier.flush(),                // DA1 哨兵
]);
```

> 💡 **通俗理解**：DA1 哨兵就像**排队叫号**——你同时问了三个问题（查询 1、查询 2、哨兵），如果你的号在哨兵之前被叫到，说明终端回答了你的问题（支持该功能）；如果哨兵的号先被叫到，说明终端跳过了你的问题（不支持）。不需要设闹钟等多久——哨兵到了就有答案。

支持 7 种查询类型：`decrqm`（DEC 私有模式状态）、`da1`（主设备属性）、`da2`（次设备属性）、`kittyKeyboard`（Kitty 键盘协议标志）、`cursorPosition`（光标位置，使用 DEC 私有标记避免与 Shift+F3 冲突）、`oscColor`（动态颜色查询，用于检测暗色/亮色主题）、`xtversion`（终端版本，穿透 SSH 识别客户端终端）。

---

## 9. 终端焦点状态（terminal-focus-state.ts, 47 行）

这是一个简洁但重要的模块。通过 DECSET 1004（焦点事件报告），Claude Code 知道用户的终端窗口是否处于前台：

- **focused**：终端窗口在前台
- **blurred**：终端窗口在后台
- **unknown**：终端不支持焦点报告（视同 focused）

这个信息有什么用？**渲染节流**。当终端在后台时，没有人看屏幕——可以降低或暂停渲染，减少 CPU 和 TTY I/O 开销。`subscribeTerminalFocus` 提供了 `useSyncExternalStore` 兼容的订阅接口，让 React 组件能响应焦点变化。

---

## 10. 搜索高亮（searchHighlight.ts, 93 行）

### 10.1 实现策略

搜索高亮不是在 React 组件树中实现的——它是一个 **后渲染覆盖层（post-render overlay）**，直接操作 Screen 缓冲区。

`applySearchHighlight()` 的工作流程：

1. 逐行扫描 Screen 缓冲区
2. 为每行构建 **code-unit→cell 映射**——因为宽字符（CJK、emoji）占两个 cell，第 N 个字符不在第 N 列
3. 大小写不敏感搜索（`toLowerCase` 在每个字符级别执行，不是在拼接字符串后执行——避免 Turkish İ 等特殊情况导致的位置偏移）
4. 匹配的 cell 通过 `stylePool.withInverse(styleId)` 反转显示（SGR 7）
5. 非重叠推进（类似 less/vim/grep 的行为：`pos + qlen` 而非 `pos + 1`）

为什么不在 React 层实现？因为搜索结果需要横跨多个组件——一个匹配可能起始于消息文本，结束于代码块。在 React 层实现意味着每个组件都要感知搜索状态，这是 **横切关注点（cross-cutting concern）** 的经典场景。后渲染覆盖层让搜索逻辑与组件解耦。

三种 cell 被跳过：`SpacerTail`（宽字符的第二个 cell）、`SpacerHead`（换行填充）、`noSelect`（行号栏等不可选区域）。这与文本选择使用相同的排除规则——"搜索能找到的 = 选择能选到的 = 用户能看到的内容"。

---

## 11. 性能优化体系

### 11.1 三层缓存架构

| 缓存层 | 文件 | 粒度 | 命中场景 |
|--------|------|------|---------|
| 行宽缓存 | `line-width-cache.ts` (24 行) | 文本行 → 终端宽度 | 流式输出中已完成的行不变 |
| 节点缓存 | `node-cache.ts` (54 行) | DOMElement → 屏幕矩形 | blit 脏检查、命中测试 |
| 字符缓存 | `output.ts` 中的 charCache | 文本行 → ClusteredChar[] | 避免重复 tokenize + grapheme 聚类 |

**line-width-cache.ts** 是最简洁的缓存——一个 `Map<string, number>`，上限 4,096 条，满时全部清空（LRU 对于下一帧就全部重建的场景没有意义，简单清空反而更快）。在流式 AI 输出中，已完成的行是不可变的（只有最后一行在增长），所以缓存命中率极高——注释中提到 **约 50 倍** 的 `stringWidth` 调用减少。

**node-cache.ts** 是 blit 优化的基础。`nodeCache`（WeakMap<DOMElement, CachedLayout>）保存每个节点上一帧的屏幕坐标。`pendingClears`（WeakMap<DOMElement, Rectangle[]>）跟踪被移除子节点的矩形——下一帧需要清除这些区域。`absoluteNodeRemoved` 标志表示有绝对定位节点被移除——这种情况必须禁用 blit，因为绝对定位节点可能跨子树绘制。

### 11.2 脏区域追踪

整个渲染系统围绕"只更新变化部分"设计：

1. **React Reconciler 层**：`markDirty()` 向上传播 dirty 标志
2. **render-node-to-output 层**：遍历树时跳过 clean 子树（blit 从 prevScreen 拷贝）
3. **LogUpdate 层**：`diffEach()` 逐 cell 比较两个 Screen，只输出变化的 cell
4. **optimizer 层**：合并、去重、抵消冗余的 ANSI 补丁

稳态帧（spinner 旋转、时钟刷新、文本流追加到固定高度容器）经过这四层过滤后，输出的 ANSI 序列量极小——只有几个变化 cell 的样式和字符。

### 11.3 渲染节流

`constants.ts` 定义了 `FRAME_INTERVAL_MS = 16`——约 60fps 的渲染上限。`ink.tsx` 中使用 `throttle(scheduleRender, FRAME_INTERVAL_MS)` 确保即使 React 频繁 commit（流式输出中每个 token 都可能触发 setState），实际终端输出不超过 60fps。

`ink.tsx` 的 `Ink` 类（1,722 行）还实现了 **双缓冲**（`frontFrame`/`backFrame` 交替）和 **HyperlinkPool 定期重置**（每 5 分钟清空超链接池，防止长会话内存增长——`lastPoolResetTime` 字段追踪上次重置时间）。

### 11.4 Commit 性能监控

reconciler.ts 中的 commit instrumentation（第 189-198 行）提供了生产级性能监控。当设置 `CLAUDE_CODE_COMMIT_LOG` 环境变量时：

- 记录每次 commit 的时间戳和间隔（gap）
- 超过 30ms 间隔或 20ms reconcile 时间的帧被标记
- Yoga 计数器（visited/measured/cacheHits/live 节点数）在慢帧时被记录
- 超过 20ms 的 Yoga 布局被标记为 `SLOW_YOGA`
- 超过 10ms 的渲染被标记为 `SLOW_PAINT`
- 每秒汇总一次 commits/s 和 maxGapMs

这套监控让开发者能精确定位性能瓶颈：是 React reconcile 慢？是 Yoga 布局慢？还是终端渲染慢？

---

## 12. Ink 核心类（ink.tsx, 1,722 行）

### 12.1 Ink 类的职责

`ink.tsx` 中的 `Ink` 类是整个 UI 系统的 **总调度器**——城市类比中，它是 **中央交通管理中心**。它的实例字段清单揭示了协调范围：

| 字段分类 | 关键字段 | 用途 |
|----------|---------|------|
| 渲染管线 | `renderer`, `scheduleRender`, `log` | 渲染器、节流调度、终端输出 |
| React 集成 | `container` (FiberRoot), `rootNode` | React 19 Concurrent Root |
| 内存管理 | `stylePool`, `charPool`, `hyperlinkPool` | 三种字符串去重池 |
| 双缓冲 | `frontFrame`, `backFrame` | 帧缓冲区交替 |
| 交互状态 | `selection`, `searchHighlightQuery`, `searchPositions` | 选择和搜索 |
| 鼠标状态 | `hoveredNodes` (Set) | 当前 hover 的 DOM 节点集 |
| Alt-screen | `altScreenActive`, `altScreenMouseTracking` | 全屏模式状态 |
| 终端光标 | `cursorDeclaration`, `displayCursor` | CJK 输入法和 a11y |
| 脏追踪 | `prevFrameContaminated`, `needsEraseBeforePaint` | 全帧重绘触发器 |

`cursorDeclaration` 字段值得注意——它让组件通过 `useDeclaredCursor` hook 声明终端光标应该停在哪里。终端模拟器在光标位置渲染 IME 预编辑文本，屏幕阅读器/放大镜跟踪光标位置——所以把光标停在文本输入框的插入点让 CJK 输入法内联显示，也让辅助技术能正常工作。

### 12.2 onRender 流程

每帧的渲染在 `onRender()` 中完成（简化版）：

1. 调用 `renderer(options)` 生成新帧（render-node-to-output → output → screen）
2. **后渲染覆盖层**：
   - `applySearchHighlight()` — 搜索高亮反转
   - `applySelectionOverlay()` — 文本选择反转
   - `applyPositionedHighlight()` — 当前搜索匹配黄色高亮
3. 生成 Diff 补丁：`log.render(frame, prevFrame)`
4. 优化补丁：`optimize(diff)`
5. 写入终端：`writeDiffToTerminal(terminal, diff)`
6. 交换缓冲区：`frontFrame ↔ backFrame`

覆盖层在渲染之后、diff 之前应用——这意味着选择和搜索高亮被 **当作普通内容变化** 处理，diff 引擎不需要知道它们的存在。这是一个优雅的解耦。

---

## 13. 三个"页面"

### 13.1 REPL（主界面）

Claude Code 99% 时间显示的界面——对话窗口、消息列表、工具调用展示、权限弹窗。

REPL 运行在 `<AlternateScreen>` 中，这意味着：
- 使用备用屏幕缓冲区（DECSET 1049）
- 鼠标追踪启用（mode 1002/1003）
- 退出时自动恢复原屏幕内容

### 13.2 Setup（初始化向导）

首次使用时的设置流程——选择模型、配置 API key、同意条款。

### 13.3 Login（登录）

OAuth 登录流程的 UI。

---

## 14. 流式渲染：AI 在"打字"

```
API stream chunk → React state update → Ink re-render → ANSI output → Terminal display
```

每个 stream chunk（可能只有几个 token）触发一次 React 重新渲染。渲染管线的四层优化确保了只有变化的 cell 被写入终端。在稳态流式输出中（文本追加到一个固定高度的 ScrollBox），典型帧只需要更新最后一行的几个字符——一次 `stdout.write` 可能只有几十字节的 ANSI 序列。

**帧间隔 16ms（60fps）** 是一个精心选择的数字：太低会导致 CPU 忙等和 TTY I/O 过载，太高会让用户感觉到延迟。16ms 恰好是人眼不感知的更新间隔，同时也匹配大多数显示器的刷新率。

---

## 15. 权限弹窗：终端里的模态对话框

当 AI 需要执行需要确认的操作时，界面上出现一个"弹窗"——实际上是一个覆盖在消息流上方的 React 组件。

```
+------------------------------------------+
|  Claude wants to run: Bash(rm -rf build/)|
|                                          |
|  [Allow]  [Deny]  [Always Allow]         |
+------------------------------------------+
```

这个"弹窗"利用了 Ink Fork 的多个能力：
- **焦点管理**：弹窗挂载时自动获取焦点（`autoFocus`），按钮通过 Tab 切换
- **键盘事件**：`onKeyDown` 处理 Enter（确认）和 Escape（拒绝）
- **点击事件**：按钮支持鼠标点击
- **绝对定位**：弹窗使用 `position: absolute` 覆盖在内容上方

---

## 16. Buddy 系统：一只小动物的工程学

Buddy 是 Claude Code 的宠物系统——一只小动物坐在输入框旁边。

### 16.1 Bones/Soul 分离架构

```
Bones（确定性外表）
  +-- 物种：18 种（兔子、猫、狐狸...）
  +-- 颜色：基于 hash 确定
  +-- 稀有度：Gacha 机制（Common 60% / Uncommon 25% / Rare 10% / Epic 4% / Legendary 1%）

Soul（AI 生成性格）
  +-- 名字：AI 生成
  +-- 性格特征：AI 生成
  +-- 对话风格：AI 生成
```

外表由确定性哈希决定（同一用户永远看到同一只动物），性格由 LLM 生成。

### 16.2 18 种物种用 `String.fromCharCode()` 编码

物种名称不是直接写死的字符串，而是用 `String.fromCharCode()` 编码。原因：避免和模型的内部代号字符串碰撞。

---

## 17. 键盘处理

`keybindings/`（14 文件）定义了 Claude Code 的快捷键系统：

| 快捷键 | 功能 |
|--------|------|
| Enter | 提交消息 |
| Ctrl+C | 中断当前操作 |
| Ctrl+J | 插入换行 |
| Esc | 取消/返回 |
| Tab | 自动补全 / 焦点循环 |
| 上/下 | 历史消息导航 |

**Vim 模式**（`vim/`，5 文件）：Claude Code 支持 Vim 风格的输入模式——Insert/Normal/Visual 模式切换。

`hooks/` 目录提供了 12 个 React Hooks，其中 `use-input.ts` 是键盘输入的核心、`use-selection.ts` 管理文本选择状态、`use-search-highlight.ts` 管理搜索高亮、`use-declared-cursor.ts` 声明光标位置、`use-terminal-focus.ts` 订阅终端焦点状态。

---

## 18. 主题和样式

`styles.ts`（771 行）定义了 40+ 种 CSS Flexbox 映射属性。`applyStyles()` 函数将 React props 中的样式翻译为 Yoga 布局指令：

- 尺寸：`width`、`height`、`minWidth`、`maxHeight`（支持数字和百分比）
- 弹性：`flexDirection`、`flexGrow`、`flexShrink`、`flexBasis`、`flexWrap`
- 对齐：`alignItems`、`alignSelf`、`justifyContent`
- 间距：`padding`、`margin`、`gap`（支持分别设置四边）
- 边框：`borderStyle`（6 种边框样式）、`borderColor`
- 定位：`position`（relative/absolute）、`top/right/bottom/left`
- 溢出：`overflow`（visible/hidden/scroll）
- 显示：`display`（flex/none）

`termio/` 子目录（9 文件）封装了终端 I/O 原语：CSI 控制序列（光标移动、滚动区域、清屏）、OSC 操作系统命令（超链接、剪贴板、Tab 标题）、DEC 私有模式（Alt-screen、鼠标追踪、焦点报告）、SGR 图形渲染（颜色、粗体、反转）。

---

## 19. 设计取舍

### 优秀

1. **完整 Fork 而非 monkey-patch** 让团队对渲染管线有完全控制权——从 React Reconciler 到 ANSI 输出的每一层都可以优化
2. **纯 TS Yoga** 消除了 WASM 的启动延迟和内存管理问题，同时保留了通过 `LayoutNode` 接口切换回 WASM 的可能性
3. **四层脏区域追踪**（React dirty → node dirty → screen cell diff → patch optimize）让稳态帧的输出降到最小
4. **DA1 哨兵查询协议** 是终端能力探测的正确做法——零超时、自适应延迟、生产可靠
5. **后渲染覆盖层设计**（搜索高亮、文本选择）把横切关注点从组件树中抽离——diff 引擎完全不感知
6. **事件处理函数与属性分离存储** 防止函数身份变化触发不必要的 dirty 标记——这个微优化对流式渲染的性能影响巨大

### 代价

1. **19,842 行自建 UI 框架** 是巨大的维护负担——每次 React 升级都需要手动适配 reconciler
2. **selection.ts 的 917 行** 暴露了在终端中重建浏览器级选择的复杂度——scroll accumulator、soft-wrap 标记、virtual row 恢复都是浏览器免费提供但终端需要从零实现的功能
3. **双缓冲 + 三种内存池 + 帧节流** 增加了调试难度——一个 UI Bug 可能跨越多个帧的状态，复现需要理解整个管线
4. **条件 Bidi 重排** 只在 `needsBidi()` 返回 true 的平台（Windows 原生 / Windows Terminal 下的 WSL / VS Code 集成终端——xterm.js 不原生支持 Bidi）才实际激活，在 macOS Terminal/iTerm2 等已原生支持 Bidi 的终端上 `needsBidi()` 返回 false，整条重排链路被 early-return 跳过。所以它不是"所有非 Windows 平台的死代码"——在 VS Code 集成终端（无论运行在什么操作系统上）都会激活。
5. **1,722 行的 Ink 类** 职责过重——它同时管理渲染管线、鼠标事件、选择状态、搜索高亮、焦点系统、光标声明——是一个值得拆分的"上帝对象"

---

> **[图表预留 2.12-A]**：React → Reconciler → DOM → Yoga → Output → Screen → LogUpdate → Terminal 的完整渲染管线图
> **[图表预留 2.12-B]**：REPL 主界面的组件树结构图 — 从 `<App>` → `<AlternateScreen>` → `<ScrollBox>` → 消息组件的层级
> **[图表预留 2.12-C]**：流式渲染的时序图 — API stream chunk → React state → throttled render → ANSI output，标注 16ms 帧间隔和四层优化
> **[图表预留 2.12-D]**：脏区域追踪四层过滤示意图 — 展示稳态帧如何从"全树 1000+ 节点"收敛到"3 个 cell 的 ANSI 补丁"
> **[图表预留 2.12-E]**：TerminalQuerier 的 DA1 哨兵时序图 — 查询发送 → 响应接收 → 哨兵到达的三种情况
