Claude Code 源码揭秘:用 React 写终端界面,Ink 框架到底有多香
💡 阅读前记得关注+星标,及时获取更新推送
「Claude Code 源码揭秘」系列的第十七篇,上一篇《Claude Code 源码揭秘:GitHub、VS Code、Chrome,三大外挂如何接入》。
你用 Claude Code 的时候,有没有觉得这个命令行工具不太一样?
不是那种黑底白字、一问一答的古老风格,而是有进度条、有语法高亮、有实时滚动的输出、还有交互式的选择器。像个现代 Web 应用,但跑在终端里。
翻源码才发现,Claude Code 的终端 UI 是用 React 写的。
是的,那个写网页的 React。靠一个叫 Ink 的框架,把 React 组件渲染到终端里。组件化、状态管理、声明式 UI,前端那套东西全搬过来了。

我之前做过一个 C++ 的命令行工具,手写 ANSI 控制码,\x1b[32m 是绿色,\x1b[0m 是重置,光标移动、清屏、滚动全靠记忆那堆转义序列。写得痛苦,维护更痛苦。看到 Claude Code 用 React 写终端,第一反应是「还能这么玩?」
Ink 是什么
Ink 做的事情说白了就一句话:让你用写 React 的方式写终端 UI。
传统终端开发,你得跟 ANSI 转义码打交道——\x1b[1;32m 是加粗绿色,\x1b[H 是光标回到左上角,\x1b[2J 是清屏。这些东西写起来像在编密码,别说维护了,隔两周自己都看不懂。
Ink 把这层脏活全封装了。你只管写 JSX,它负责翻译成终端能懂的控制码。就像 React DOM 把组件渲染成 HTML 元素一样,Ink 把组件渲染成终端输出。
React DOM: <div> → HTML 元素 → 浏览器页面React Native: <View> → 原生组件 → 手机界面Ink: <Box> → ANSI 控制码 → 终端界面
三个不同的渲染器,同一套编程模型。这就是 React 架构的牛逼之处——核心的组件模型和 Hooks 是通用的,渲染目标随便换。
Ink 的核心原语
Ink 提供的基础组件就两个:<Box> 和 <Text>。
<Box> 对应网页里的 <div>,是布局容器。最关键的是,它支持 Flexbox 布局。没错,CSS 里那个 Flexbox,搬到终端里来了。
// 垂直布局<Box flexDirection="column"><Text>第一行</Text><Text>第二行</Text></Box>// 水平布局,两端对齐<Box justifyContent="space-between" paddingX={1}> <Text color="gray">? for shortcuts</Text> <Text color="gray">Ready</Text></Box>// flexGrow 占满剩余空间<Box flexDirection="column" flexGrow={1}> {messages.map(msg => <Message key={msg.id} {...msg} />)}</Box>
<Text> 对应网页里的 <span>,负责文字样式。颜色、加粗、下划线,不用再记 ANSI 码了:
<Text color="green" bold>成功</Text><Text color="red">失败</Text><Text color="gray" dimColor>次要信息</Text>
就这两个积木块,组合出整个 Claude Code 的界面。
Claude Code 的整体布局
有了 Box 和 Text,看看 Claude Code 怎么搭界面的:
<Box flexDirection="column"> {/* 顶部 Header */} <Header model={model} status={connectionStatus} /> {/* 消息区域,flexGrow={1} 占满剩余空间 */} <Box flexDirection="column" flexGrow={1} marginY={1}> {messages.map(msg =><Message key={msg.id} {...msg} />)} {streamBlocks.map(block => ( block.type === 'text' ?<Message key={block.id} content={block.text} /> :<ToolCall key={block.id} {...block.tool} /> ))} {isProcessing &&<Spinner type="dots" />} </Box> {/* 输入框 */} <Box marginTop={1}><Input onSubmit={handleSubmit} /> </Box> {/* 底部状态栏 */} <Box justifyContent="space-between" paddingX={1}><Text color="gray">? for shortcuts</Text><Text color="gray">{isProcessing ? 'Processing...' : 'Ready'}</Text> </Box></Box>
是不是跟写网页差不多?flexDirection="column" 垂直排列,flexGrow={1} 让消息区域占满所有可用空间,justifyContent="space-between" 把状态栏两端对齐。窗口大小变化时,Ink 自动重新计算布局,不用你操心。
整个 UI 拆成了 21 个组件——Header、Message、ToolCall、Input、Spinner、ProgressBar、DiffView… 每个都很小,关注点分离,跟前端组件化开发一模一样。
Hooks 在终端里一样好使
Ink 完整支持 React Hooks,这意味着 useState、useEffect、useMemo 都能用。Claude Code 的状态管理就是纯 Hooks,没用 Redux 那些重型库:
const [messages, setMessages] = useState<MessageItem[]>([]);const [isProcessing, setIsProcessing] = useState(false);const [currentResponse, setCurrentResponse] = useState('');
Ink 还提供了几个终端专用的 Hooks。比如 useInput 处理键盘输入,useApp 控制应用生命周期:
import { useInput, useApp } from 'ink';// 监听键盘输入useInput((input, key) => { if (key.escape) { // 按 Esc 退出 exit(); } if (key.ctrl && input === 'c') { // Ctrl+C process.exit(); }});
不用再手动监听 process.stdin,不用自己解析键盘事件的字节序列。写过原生终端键盘处理的都知道,方向键是 \x1b[A,Ctrl+A 是 \x01,各种特殊键的编码记都记不住。Ink 把这些全抹平了。
流式响应:Ink + React 状态驱动的教科书案例
Claude Code 的回复是一个字一个字蹦出来的,像打字机。这个效果在 Ink 里实现起来特别自然,因为 React 本身就是状态驱动 UI 的——状态变了,UI 自动更新。
API 返回的是 SSE 流,每个事件包含一小段文本。收到事件就更新状态,Ink 自动重新渲染对应的组件:
for await (const event of loop.processMessageStream(input)) { if (event.type === 'text') { accumulatedResponse += event.content || ''; setCurrentResponse(accumulatedResponse); // 状态更新 → Ink 自动重渲染 } else if (event.type === 'tool_start') { setStreamBlocks(prev => [...prev, { type: 'tool', id: event.toolId, tool: { name: event.toolName, status: 'running' }, }]); }}
没有定时器,没有动画库,就是事件驱动的状态更新。React 的声明式模型在这里完美契合——你只管描述「状态是什么」,Ink 负责把变化高效地刷新到终端。
性能方面,Claude Code 用了几个标准的 React 优化手段:React.memo 避免无关组件重渲染,useMemo 缓存 Markdown 解析结果,流式内容拆成独立的块(文本块、工具块),更新一个不影响其他。流式输出的时候每秒可能更新几十次,这些优化是必须的。
工具调用的显示
工具执行状态是 Claude Code 里很有特色的一个 UI,用颜色圆点表示状态:
export const ToolCall: React.FC<ToolCallProps> = React.memo(({ name, status, input, result, duration,}) => { const statusColor = status === 'running' ? 'cyan' : status === 'success' ? 'green' : 'red'; return (<Box flexDirection="column"> <Box> <Text color={statusColor}>• </Text> <Text>{name}({formatInput(input)})</Text> {duration && <Text color="gray"> {duration}s</Text>} </Box> </Box> );});
青色圆点表示执行中,绿色表示成功,红色表示失败。用 React.memo 包裹,只在 props 变化时重渲染。这就是 Ink 的好处——用声明式的方式描述 UI 状态,比手动拼 ANSI 字符串清晰太多了。
Spinner 动画:setInterval + useState
加载动画在 Ink 里的实现很典型,展示了怎么在终端里做「动画」:
const SPINNER_FRAMES = { dots: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], line: ['-', '\\', '|', '/'], arc: ['◜', '◠', '◝', '◞', '◡', '◟'],};useEffect(() => { const frames = SPINNER_FRAMES[type]; let i = 0; const timer = setInterval(() => { setFrame(frames[i % frames.length]); i++; }, interval); return () => clearInterval(timer);}, [type, interval]);
setInterval 定时切换 Unicode 字符,useState 触发重渲染,Ink 把新字符刷到终端。就是 React 基础操作,但在终端里跑起来了。
为什么选 Ink 而不是其他方案
终端 UI 框架不止 Ink 一个。Node.js 生态里还有 blessed、terminal-kit 这些老牌库。但 Claude Code 选 Ink 是有道理的。
blessed 功能很强大,能画窗口、滚动条、弹窗,接近 ncurses 的能力。但它的 API 是命令式的——创建对象、设置属性、手动刷新。代码量大了之后,状态同步是噩梦。
Ink 的核心优势是声明式。你描述界面长什么样,框架帮你处理更新。状态变了,UI 自动跟着变。不用手动追踪哪个地方需要刷新,不用担心漏掉某个角落的更新。这在 AI 应用这种状态变化频繁的场景下,太重要了。
而且 Ink 背后是 React 生态。你会 React,就会 Ink。组件化、Hooks、Context,这些知识直接复用。对 Claude Code 这种 TypeScript 全栈项目来说,前后端共用一套心智模型,开发效率高很多。
写在最后
看完这套终端 UI 代码,最大的感受是:选对抽象层,事半功倍。
终端 UI 是几十年前的东西,ANSI 控制码是上世纪 70 年代的标准。但 Ink 在上面加了一层 React 抽象,组件化、状态管理、Flexbox 布局,现代前端的最佳实践全用上了。代码可读性和可维护性,跟手写 ANSI 码完全不是一个量级。
如果你在做 CLI 工具,特别是需要复杂交互的那种,真的值得看看 Ink。用 React 的方式写终端,一旦上手就回不去了。
下一篇是最后一篇,聊聊整个源码分析的总结和心法。17 篇文章看下来,Claude Code 的架构设计有什么共性?能提炼出什么可复用的模式?
本文基于 Claude Code 源码分析,主要文件:src/ui/(21 个组件)、src/streaming/。
夜雨聆风
