终端不是浏览器,但它可以成为浏览器。
2.1 核心问题
第 1 章我们有了一个进程,可以收发文字。但 Coding Agent 需要的不是单纯的行输入——
它需要同时展示流式 LLM 输出、响应键盘事件、保持历史记录滚动,还要实时更新进度指示器。
终端不是浏览器,但我们能在终端里用 React 吗?
答案是:可以。这就是 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> |
换行 |
// Ink 的 <Box> 就是 Flexbox
<Box flexDirection="column" paddingX={1}>
<Text color="green" bold>Mini Agent</Text>
<Box borderStyle="round">
<Text>{input}</Text>
</Box>
</Box>
useInput:捕获原始键盘事件
// 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 对象的结构:
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/):
// 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 普通渲染的关键区别
// 错误做法:每次 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:启动入口
// 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)
- 多行粘贴检测
- 语法高亮(
highlightsprop) - 语音录制状态下的波形光标动画
- 无障碍模式(
CLAUDE_CODE_ACCESSIBILITY=1时关闭光标动画)
2.4 最小化产出物
代码骨架位于
../chapters/02/src/,参考实现位于../chapters/02/solution/。
本章要实现什么
在 ../chapters/02/src/app.tsx 中完成 App 组件。
接口规范(已提供,不要修改):
// 消息类型
type Message = { id: number; role: 'user' | 'agent'; content: string }
// 主组件(接受可选的初始 prompt)
export function App({ initialPrompt }: { initialPrompt?: string }): JSX.Element
你需要实现:
- 用
useState管理messages、input、nextId三个状态 - 用
useInput处理键盘事件:字符追加、Backspace 删除、Enter 提交、Ctrl-C 退出 - Enter 提交时:追加用户消息 +
(echo) <input>的 agent 回复,清空输入框 - Banner 显示
state.sessionId.slice(0, 8)(来自第 1 章state.ts) - 若有
initialPrompt,初始化时预填一条对话(用户消息 + echo 回复)
关键约束:
- 必须 import 第 1 章的
state(../../01/src/state.js)并调用registerSignalHandlers() App组件必须export,供第 3 章 import
验收
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 工具。