Ink 终端 UI 入门
循环的"大脑"讲过了,今天看"脸"——用 React 写终端界面。贴真实的 App.tsx、Messages.tsx、REPL.tsx 代码,不懂 React 也能跟上。昨天(Day 03)讲了心脏怎么跳,今天讲这颗心跳如何呈现在"脸"上,Day 05 就把脑、脸、手串成一次完整对话。
<Box>(框)、<Text>(字)这些积木块,只不过拼出来的不是网页,而是终端里的字符界面。而今天的性能主线可记成一句话:仪表盘很大、重画很贵,所以要把"一直在跳动的小表针"(流式文本)单独拎出来刷,别为了动一根针把整块盘重画一遍。Ink 是什么:用 React 写终端界面
Ink = "把 React 渲染到终端"。你写的还是 React 组件、JSX、Hooks,但它渲染出来的不是网页 DOM,而是终端里的字符。对照理解:
<div><Box>(flexbox 布局)<span><Text color=... dimColor>onKeyDown 事件useInput(handler) Hook<Box><Text color="green">✔ 完成</Text></Box>:在浏览器里 → 变成一个绿色的
<div><span>;在 Ink 里 → 变成终端里一行绿色字符
✔ 完成。同一套写法,两种"画布"——这就是 Ink 的全部魔法。use 开头的函数(如 useState),在组件里"挂"状态和副作用。JSX 是那种"在 JS 里写标签"的语法(<Box>...</Box>)。这就是为什么一个终端 CLI 里有 700+ 个 .tsx 组件文件。本项目用的是自研 fork 的 Ink——工作区包 packages/@ant/ink,通过别名 @anthropic/ink 引用。src/ 下很多同名文件只是再导出(如 src/hooks/useTerminalSize.ts 全文就一行 export { useTerminalSize } from '@anthropic/ink')。
根组件树:4 层 Provider 包着 REPL(真实代码)
Day 02 见过 replLauncher 挂载 App > REPL。App.tsx 只做一件事——套 4 层 Provider。真实全文(src/components/App.tsx:20):
export function App({ getFpsMetrics, stats, initialState, children }) {
return (
<FpsMetricsProvider getFpsMetrics={getFpsMetrics}> {/* 帧率统计 */}
<StatsProvider store={stats}> {/* 运行统计 */}
<AppStateProvider initialState={initialState} ...> {/* ★ 全局状态 store */}
<ThemeProvider initialState={getGlobalConfig().theme} ...> {/* 主题 */}
{children} {/* ← 里面就是 REPL */}
</ThemeProvider>
</AppStateProvider>
</StatsProvider>
</FpsMetricsProvider>
);
}
{children} = "外面塞进来的内容"(这里就是 <REPL/>)。层层嵌套的 Provider 从外到内:帧率 → 统计 → 全局状态 → 主题 → REPL。AppStateProvider 提供全局状态(设置/模型/任务/权限,Day 16),ThemeProvider 提供配色主题。最外面 replLauncher 还包了个 SentryErrorBoundary(错误边界)——里面组件崩了它兜住,不让整个界面白屏。REPL 组件树全貌
screens/REPL.tsx(6000+ 行)是主界面。它挂了一串"零高度"的键位处理器 + 一个三区布局。简化版:
三个核心组件:Messages(消息历史,L04)、PromptInput(输入框,Day 18 精讲)、SpinnerWithVerb(那个转圈 + "Thinking…")。整个界面只有两种屏 'prompt' | 'transcript'(Ctrl+O 切换)。
GlobalKeybindingHandlers 这种组件不渲染任何可见内容,只是"挂载时注册一批快捷键、卸载时注销"。用组件而非普通函数做,是为了借用 React 的生命周期自动管理注册/清理——这是 Ink/React 应用里管理键盘绑定的常见技巧(Day 18 细讲)。消息渲染链路:数组 → 一条条组件
你看到的对话记录,就是一个消息数组被渲染成一条条组件。链路是 REPL → Messages → MessageRow → Message:
- REPL 不自己 map,把数组交给
<Messages>。 Messages.tsx归一化/分组/折叠后,用renderMessageRow(Messages.tsx:787)把每条转成一个带唯一key的<MessageRow>(memo 化)。消息多时走虚拟滚动VirtualMessageList(:119)。- 单条消息由
Message.tsx的switch(message.type)分派:assistant / user / system / tool_use / … assistant 内部再按 block 类型分派到components/messages/下的组件。
流式文本怎么"逐行冒出来"(真实代码)
Day 03 讲了 API 层流式吐字。到界面这层有个巧妙处理——已定稿的消息走列表,正在流式的文本作为列表后面一个"独立兄弟节点"实时渲染。真实代码(Messages.tsx:954):
{streamingText && !isBriefOnly && (
<Box alignItems="flex-start" flexDirection="row" marginTop={1} width="100%">
<Box flexDirection="row">
<Box minWidth={2}><Text color="text">{BLACK_CIRCLE}</Text></Box> {/* 那个 ● */}
<Box flexDirection="column">
<StreamingMarkdown>{streamingText}</StreamingMarkdown> {/* ← 正在流的文本 */}
</Box>
</Box>
</Box>
)}
{条件 && (...)} 是 React 里"条件渲染":条件为真才渲染后面那块。所以 只有当 streamingText 有内容(正在流式)时,才在消息列表后面渲染这一小块;定稿后这块消失、内容并入正式列表。visibleStreamingText 只截到"最后一个换行",所以你看到的是"逐行"平滑冒出,而非"逐字"抖动。ref 当真值源、state 当渲染投影(真实代码)
REPL 里一个值得学的模式——消息数组的"真值"存在 ref 里,state 只是给渲染看的投影。真实代码(REPL.tsx:1486):
const [messages, rawSetMessages] = useState(initialMessages ?? []);
const messagesRef = useRef(messages);
// ... 包装 setMessages:
function setMessages(action) {
const next = typeof action === 'function' ? action(messagesRef.current) : action;
messagesRef.current = next; // ← 立刻同步写 ref(马上能读到最新)
// ...
rawSetMessages(next); // ← 再触发 state 更新(异步提交、驱动重渲染)
}
messagesRef.current(同步,立刻生效),再调 rawSetMessages(触发界面重画)。所以"刚追加完立刻读"读的是 ref(最新),"界面显示"靠 state。setState 是异步的——你刚 setMessages(...),紧接着读 messages 变量还是旧值(React 还没提交)。但 Agent 循环里经常需要"刚追加完消息立刻读到最新的"。于是用 ref(messagesRef.current,同步可读可写的"盒子")当真正的数据源,state 只负责触发界面重画。ref = 一个"不触发重渲染、随时可读写"的盒子;state = 一个"一改就触发重画"的值。源码注释(:1493)明说这么做是为了"messagesRef 在写的那一刻就是最新的"。同理流式文本长度用 responseLengthRef——每个字增量只更新 ref、不触发重渲染,spinner 靠动画帧去读它显示进度。这样几千个字增量不会引发几千次重画。
👶 小白:ref 和 state 都能存数据,为啥非要分两份?听着好绕。
👨🏫 老师:一句话——ref 是"白板",写上去立刻能读到;state 是"公告栏",改了要等下一次刷新才贴出来(异步)。Agent 循环刚追加完消息、马上就要读最新的那一份,等不起公告栏的刷新,就读白板(ref);而界面显示不急、交给公告栏(state)触发重画。两份数据、各司其职,不矛盾。
性能手法总览:终端 UI 为什么处处小心
把今天的性能手法串一下——它们都在解决同一问题:终端重画很贵,必须把"高频变化"和"大而稳定"的部分隔离开。
| 手法 | 解决什么 |
|---|---|
| 虚拟滚动 | 几百条消息只渲染可视区那几条 |
| React.memo(MessageRow) | props 没变的消息行跳过重渲染 |
| 流式文本独立兄弟节点(L05) | 高频更新区域只有一小块,列表不动 |
| ref 当真值源(L06) | 字增量不触发整树重画 |
| useDeferredValue | 长列表重渲染降优先级,保输入流畅 |
今日小结 + 动手
🧠 今天你应该能回答
- Ink 是什么?Box/Text/useInput 对应浏览器的什么?
- 根组件树的 4 层 Provider 各是什么?Provider 干嘛用?
- 消息渲染链路?(REPL → Messages → MessageRow → Message 的 switch)
- 流式文本为什么作为独立兄弟节点渲染?(隔离高频区)
- "ref 当真值源、state 当投影"解决了什么?(setState 异步、要立刻读最新)
✋ 动手:对着真实代码读一遍
# 1. 根组件树 4 层 Provider(L02)
cat src/components/App.tsx
# 2. REPL 三区布局(搜关键组件)
grep -n "FullscreenLayout\|<Messages\|<PromptInput\|SpinnerWithVerb" src/screens/REPL.tsx | head
# 3. 流式文本独立渲染(L05)
sed -n '954,966p' src/components/Messages.tsx
# 4. ref 当真值源(L06)
sed -n '1486,1531p' src/screens/REPL.tsx