第 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)