第 2 章:终端 UI 外壳(Ink + React)

# 第 2 章:终端 UI 外壳(Ink + React)

> 终端不是浏览器,但它可以成为浏览器。

---

## 2.1 核心问题

第 1 章我们有了一个进程,可以收发文字。但 Coding Agent 需要的不是单纯的行输入——  
它需要**同时**展示流式 LLM 输出、响应键盘事件、保持历史记录滚动,还要实时更新进度指示器。

**终端不是浏览器,但我们能在终端里用 React 吗?**

答案是:**可以**。这就是 [Ink](https://github.com/vadimdemedes/ink) 的作用。

---

## 2.2 原理讲解

### ANSI 转义码:终端的"CSS"

终端通过在普通文本中嵌入**ANSI 转义序列**(ESC + `[` + 参数 + 命令字母)来实现样式和定位:

```
ESC[31m   → 设置前景色为红色
ESC[0m    → 重置所有样式
ESC[2J    → 清屏
ESC[3;10H → 将光标移到第 3 行第 10 列
```

手写这些序列既繁琐又脆弱。**chalk** 封装了颜色相关的序列,**Ink** 在此之上提供了完整的布局系统。

### Ink 的工作原理

```
React 组件树(JSX)
        ↓  React reconciler(协调器)
   虚拟 DOM 树
        ↓  Ink 自定义渲染器
  Yoga 布局引擎(Flexbox)
        ↓  计算每个节点的 x/y/width/height
   ANSI 转义序列字符串
        ↓  写入 stdout
      终端显示
```

Ink 实现了一个自定义 React **渲染器**(`react-reconciler`)——和 React DOM、React Native 同级的东西。  
它不操作 DOM,而是操作一个内存中的"终端节点树",最终由 Yoga 布局引擎计算坐标,输出 ANSI 序列。

### Ink 的核心原语

| Ink 组件 | 对应 HTML | 作用 |
|----------|----------|------|
| `<Box>` | `<div style="display:flex">` | 布局容器,支持 Flexbox 属性 |
| `<Text>` | `<span>` | 文本节点,支持 `color`、`bold`、`italic` |
| `<Static>` | 不变区域 | 已渲染内容不再重绘,用于历史消息 |
| `<Newline>` | `<br>` | 换行 |

```tsx
// Ink 的 <Box> 就是 Flexbox
<Box flexDirection="column" paddingX={1}>
  <Text color="green" bold>Mini Agent</Text>
  <Box borderStyle="round">
    <Text>{input}</Text>
  </Box>
</Box>
```

### `useInput`:捕获原始键盘事件

```typescript
// src/ink/hooks/use-input.ts(简化)
const useInput = (handler: (input: string, key: Key) => void) => {
  // useLayoutEffect(不是 useEffect)确保在 React commit 阶段同步开启 raw mode
  // 否则在下一个事件循环 tick 前,键盘输入会被终端回显
  useLayoutEffect(() => {
    setRawMode(true)     // 关闭终端行缓冲和回显
    return () => setRawMode(false)
  }, [])

  // 监听 stdin 的 InputEvent
  useEventCallback(handler, ...)
}
```

**Raw mode** 是关键:默认终端处于"cooked mode",按 Enter 才把一行数据发给程序;  
Raw mode 下每个按键都立即发送,让 Ink 可以拦截箭头键、Tab、Ctrl 等特殊键。

`Key` 对象的结构:

```typescript
type Key = {
  upArrow: boolean    // ↑
  downArrow: boolean  // ↓
  leftArrow: boolean  // ←
  rightArrow: boolean // →
  return: boolean     // Enter
  escape: boolean     // Esc
  ctrl: boolean       // Ctrl 修饰键
  // ...
}
```

### Claude Code 的 Ink 封装层

Claude Code 没有直接使用 npm 上的 Ink,而是**内置了一个深度定制的 Ink fork**(`src/ink/`):

```typescript
// src/ink.ts —— Claude Code 的 Ink 入口
export async function render(node: ReactNode, options?: RenderOptions) {
  // 自动用 ThemeProvider 包裹,所有组件都能通过 useTheme() 访问主题
  return inkRender(withTheme(node), options)
}
```

扩展了原版 Ink 的功能包括:
- **主题系统**(`ThemeProvider` / `useTheme`):支持浅色/深色终端主题
- **鼠标事件**(`onClick`、`onMouseEnter`):在 AlternateScreen 模式下支持鼠标点击
- **React Compiler 优化**:`Box` 等热路径组件用 `react/compiler-runtime` 的 `_c` 缓存渲染
- **FocusManager**:Tab 键在组件间切换焦点
- **`<Static>` 优化**:历史消息区域标记为 Static,Ink 不重绘已渲染内容,节省 CPU

### `<Static>` vs 普通渲染的关键区别

```tsx
// 错误做法:每次 messages 更新都重绘全部历史
<Box flexDirection="column">
  {messages.map(m => <Message key={m.id} {...m} />)}
</Box>

// 正确做法:历史消息用 <Static>,只追加渲染新消息
<Static items={messages}>
  {(m) => <Message key={m.id} {...m} />}
</Static>
// 输入框在 <Static> 之外,只有它需要频繁重渲染
<TextInput value={input} onChange={setInput} />
```

这是 Claude Code 能流畅渲染长对话的核心技巧:`<Static>` 内容**只渲染一次**,之后对 stdout 只追加新行,不清屏重绘。

---

## 2.3 Claude Code 源码中的关键细节

### `launchRepl`:启动入口

```typescript
// src/replLauncher.tsx(编译后)
export async function launchRepl(root, appProps, replProps, renderAndRun) {
  const { App } = await import('./components/App.js')
  const { REPL } = await import('./screens/REPL.js')
  await renderAndRun(root, <App {...appProps}><REPL {...replProps} /></App>)
}
```

整棵 UI 树的结构:

```
<App>              ← 全局上下文提供者(状态、主题、快捷键)
  <REPL>           ← 主屏幕,管理消息历史和输入框
    <Static>       ← 历史消息(不重绘)
    <TextInput>    ← 输入框(每次按键重渲染)
    <StatusBar>    ← 底部状态栏(成本、模型名)
```

### `TextInput`:输入框组件

`src/components/TextInput.tsx` 是一个功能完备的终端输入框,支持:
- 光标移动(← →、Home/End、Ctrl+A/E)
- 多行粘贴检测
- 语法高亮(`highlights` prop)
- 语音录制状态下的波形光标动画
- 无障碍模式(`CLAUDE_CODE_ACCESSIBILITY=1` 时关闭光标动画)

---

## 2.4 最小化产出物

> 代码骨架位于 `../chapters/02/src/`,参考实现位于 `../chapters/02/solution/`。

### 本章要实现什么

在 `../chapters/02/src/app.tsx` 中完成 `App` 组件。

**接口规范**(已提供,不要修改):

```typescript
// 消息类型
type Message = { id: number; role: 'user' | 'agent'; content: string }

// 主组件(接受可选的初始 prompt)
export function App({ initialPrompt }: { initialPrompt?: string }): JSX.Element
```

**你需要实现**:
1. 用 `useState` 管理 `messages`、`input`、`nextId` 三个状态
2. 用 `useInput` 处理键盘事件:字符追加、Backspace 删除、Enter 提交、Ctrl-C 退出
3. Enter 提交时:追加用户消息 + `(echo) <input>` 的 agent 回复,清空输入框
4. Banner 显示 `state.sessionId.slice(0, 8)`(来自第 1 章 `state.ts`)
5. 若有 `initialPrompt`,初始化时预填一条对话(用户消息 + echo 回复)

**关键约束**:
- 必须 import 第 1 章的 `state`(`../../01/src/state.js`)并调用 `registerSignalHandlers()`
- `App` 组件必须 `export`,供第 3 章 import

### 验收

```bash
cd docs/chapters/02
npm install
npm test
```

> 注意:Ink UI 是交互式的,验收脚本只检查代码结构,不启动 UI。
> 手动验收:`npm start` 后应出现带颜色 Banner 和输入框,输入文字 Enter 后回显。

卡住时查看 `../chapters/02/solution/app.tsx`。

---

## 2.5 理解 Ink 渲染循环

Ink 的渲染循环与 React DOM 不同,理解这一点对调试至关重要:

```
键盘事件 → stdin 数据 → InputEvent
                          ↓
                    useInput handler
                          ↓
                    setState() 调用
                          ↓
              React 调度重渲染(同步,在当前事件循环内)
                          ↓
              Ink 重新计算布局(Yoga)
                          ↓
              输出 ANSI diff(只更新变化的行)
                          ↓
                    终端刷新
```

整个过程在**同一个事件循环 tick** 内完成,因此终端 UI 响应几乎是即时的(< 1ms)。

---

## 2.6 本章小结

| 概念 | 要记住的要点 |
|------|-------------|
| ANSI 转义码 | 终端的"CSS",控制颜色、光标、清屏 |
| Ink | React 的自定义终端渲染器,JSX → Yoga 布局 → ANSI 序列 |
| `<Box>` / `<Text>` | Ink 的基础原语,对应 `display:flex` 容器和文本节点 |
| `<Static>` | 标记历史消息区域,只追加渲染,不整屏重绘 |
| `useInput` | 捕获原始键盘事件,需要 raw mode |
| Raw mode | 关闭终端行缓冲和回显,让每个按键都立即触发事件 |
| ThemeProvider | Claude Code 对 Ink 的最大扩展,支持浅/深色主题 |
| `app.tsx` 独立导出 | `App` 组件被拆分到独立文件,接受 `initialPrompt` prop,供第 3 章 import 复用 |

这一章我们有了一个可以与用户实时交互的终端 UI,并且它已经复用了第 1 章的 `state`(sessionId 显示在 Banner 中)和 `registerSignalHandlers`(Ctrl-C 退出时打印会话时长)。接下来,我们给它加上命令行参数解析,让它变成一个真正的 CLI 工具。

---

## 下一章

→ [第 3 章:CLI 命令路由](03-cli-routing)