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

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


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> 文本节点,支持 colorbolditalic
<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 forksrc/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):支持浅色/深色终端主题
  • 鼠标事件onClickonMouseEnter):在 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)
  • 多行粘贴检测
  • 语法高亮(highlights prop)
  • 语音录制状态下的波形光标动画
  • 无障碍模式(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

你需要实现

  1. useState 管理 messagesinputnextId 三个状态
  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

验收

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 命令路由