Day 04 / 共 20 天 · 第 1 周 建立心智 · 加深版

Ink 终端 UI 入门

循环的"大脑"讲过了,今天看"脸"——用 React 写终端界面。贴真实的 App.tsxMessages.tsxREPL.tsx 代码,不懂 React 也能跟上。昨天(Day 03)讲了心脏怎么跳,今天讲这颗心跳如何呈现在"脸"上,Day 05 就把脑、脸、手串成一次完整对话。

📍 你在整门课的位置 · 第 1 周 建立心智
D01 全景地图 D02 启动链 D03 Agent 循环 D04 Ink UI D05 完整旅程
💡 用一个类比先兜住今天(本日世界观:用积木搭一块"仪表盘") Ink 就是"用搭网页的积木(React 组件)去搭一块终端里的仪表盘"。你还是拼 <Box>(框)、<Text>(字)这些积木块,只不过拼出来的不是网页,而是终端里的字符界面。而今天的性能主线可记成一句话:仪表盘很大、重画很贵,所以要把"一直在跳动的小表针"(流式文本)单独拎出来刷,别为了动一根针把整块盘重画一遍。
L01

Ink 是什么:用 React 写终端界面

Ink = "把 React 渲染到终端"。你写的还是 React 组件、JSX、Hooks,但它渲染出来的不是网页 DOM,而是终端里的字符。对照理解:

浏览器 <div>
Ink <Box>(flexbox 布局)
浏览器 <span>
Ink <Text color=... dimColor>
onKeyDown 事件
useInput(handler) Hook
📝 举个例子:同一段 JSX,渲染到哪 你写 <Box><Text color="green">✔ 完成</Text></Box>
在浏览器里 → 变成一个绿色的 <div><span>
在 Ink 里 → 变成终端里一行绿色字符 ✔ 完成同一套写法,两种"画布"——这就是 Ink 的全部魔法。
React 一句话(0 基础) 一个"数据变了、界面自动重画"的库。你把界面拆成一个个组件,每个组件根据它的"状态(state)"渲染;状态一变,React 自动重新渲染那部分。Hook 是以 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')。

L02

根组件树:4 层 Provider 包着 REPL(真实代码)

Day 02 见过 replLauncher 挂载 App > REPLApp.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。
Provider 是什么? React 里"往下传全局数据"的机制——包在外面的 Provider 提供数据,里面任何深度的组件都能直接取用,不用一层层传参。这里 AppStateProvider 提供全局状态(设置/模型/任务/权限,Day 16),ThemeProvider 提供配色主题。最外面 replLauncher 还包了个 SentryErrorBoundary(错误边界)——里面组件崩了它兜住,不让整个界面白屏。
L03

REPL 组件树全貌

screens/REPL.tsx(6000+ 行)是主界面。它挂了一串"零高度"的键位处理器 + 一个三区布局。简化版:

REPL └─ KeybindingSetup # 键位环境 ├─ GlobalKeybindingHandlers # 全局快捷键(零渲染) ├─ CancelRequestHandler # Ctrl+C 打断 └─ FullscreenLayout # 三个具名插槽 ├─ overlay: 权限弹窗 # 工具权限覆盖层 ├─ scrollable(消息滚动区): │ ├─ ★ Messages # 消息历史列表 │ └─ ★ SpinnerWithVerb # 转圈"Thinking…" └─ bottom(底部固定区): ├─ 各类对话框 ├─ ★ PromptInput # 输入框(Day 18) └─ MessageSelector # 历史回溯选择器

三个核心组件:Messages(消息历史,L04)、PromptInput(输入框,Day 18 精讲)、SpinnerWithVerb(那个转圈 + "Thinking…")。整个界面只有两种屏 'prompt' | 'transcript'(Ctrl+O 切换)。

为什么有一堆"零高度处理器组件"?GlobalKeybindingHandlers 这种组件不渲染任何可见内容,只是"挂载时注册一批快捷键、卸载时注销"。用组件而非普通函数做,是为了借用 React 的生命周期自动管理注册/清理——这是 Ink/React 应用里管理键盘绑定的常见技巧(Day 18 细讲)。
L04

消息渲染链路:数组 → 一条条组件

你看到的对话记录,就是一个消息数组被渲染成一条条组件。链路是 REPL → Messages → MessageRow → Message

  • REPL 不自己 map,把数组交给 <Messages>
  • Messages.tsx 归一化/分组/折叠后,用 renderMessageRowMessages.tsx:787)把每条转成一个带唯一 key<MessageRow>(memo 化)。消息多时走虚拟滚动 VirtualMessageList:119)。
  • 单条消息Message.tsxswitch(message.type) 分派:assistant / user / system / tool_use / … assistant 内部再按 block 类型分派到 components/messages/ 下的组件。
虚拟滚动 & memo(0 基础) 对话可能几百条消息。虚拟滚动只渲染"当前屏幕能看到"的那几条,其余不渲染,省性能。React.memo 让一个组件在"props 没变"时跳过重渲染。终端渲染比浏览器还敏感(每次重画都要重写字符),所以这里到处用 memo + 虚拟滚动。map 是数组方法:"把每个元素转成另一个东西"(这里把每条消息转成一个组件)。key 是 React 用来区分列表项的唯一标识。
L05

流式文本怎么"逐行冒出来"(真实代码)

🤔 痛点:每蹦一个字就把整块盘重画一遍? 模型流式吐字,一秒钟几十个字。如果每来一个字都触发"整个消息列表(可能几百条)重新渲染",终端就得反复重写满屏字符——卡成幻灯片。怎么让"针在跳"但"盘不动"?

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>
)}
● 已定稿的消息(在 Messages 列表里)
● 我正在分析这段代码,发现
↑ 这行就是上面那段 streamingText,独立渲染、逐字更新
{条件 && (...)} 是 React 里"条件渲染":条件为真才渲染后面那块。所以 只有当 streamingText 有内容(正在流式)时,才在消息列表后面渲染这一小块;定稿后这块消失、内容并入正式列表。
为什么要分开渲染? 如果每来一个字就更新整个消息列表,几百条消息的树会被反复重画,卡到不行。把"正在流的那一小段"单独拎出来实时更新、定稿后再并入列表,就只有一小块在高频重画,列表本身不动。"隔离高频更新区域"是经典性能手法。而且 visibleStreamingText 只截到"最后一个换行",所以你看到的是"逐行"平滑冒出,而非"逐字"抖动。
大盘不动,只有小针在高频刷新 Messages 列表(几百条,稳定 · 不重画) ● 已定稿消息 1…… ● 已定稿消息 2…… ● 已定稿消息 3…… ● 正在流式的文本(独立兄弟节点 · 每几毫秒重画这一小块)▋
图注:把"正在流的一小块"从列表里拎出来单独刷;定稿后并入上面的稳定列表,那一小块消失。
L06

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。
为什么要这样?(0 基础也能懂) React 的 setState异步的——你刚 setMessages(...),紧接着读 messages 变量还是旧值(React 还没提交)。但 Agent 循环里经常需要"刚追加完消息立刻读到最新的"。于是用 refmessagesRef.current,同步可读可写的"盒子")当真正的数据源,state 只负责触发界面重画。ref = 一个"不触发重渲染、随时可读写"的盒子;state = 一个"一改就触发重画"的值。源码注释(:1493)明说这么做是为了"messagesRef 在写的那一刻就是最新的"。

同理流式文本长度用 responseLengthRef——每个字增量只更新 ref、不触发重渲染,spinner 靠动画帧去读它显示进度。这样几千个字增量不会引发几千次重画。

👶 小白:ref 和 state 都能存数据,为啥非要分两份?听着好绕。

👨‍🏫 老师:一句话——ref 是"白板",写上去立刻能读到;state 是"公告栏",改了要等下一次刷新才贴出来(异步)。Agent 循环刚追加完消息、马上就要读最新的那一份,等不起公告栏的刷新,就读白板(ref);而界面显示不急、交给公告栏(state)触发重画。两份数据、各司其职,不矛盾。

L07

性能手法总览:终端 UI 为什么处处小心

把今天的性能手法串一下——它们都在解决同一问题:终端重画很贵,必须把"高频变化"和"大而稳定"的部分隔离开

手法解决什么
虚拟滚动几百条消息只渲染可视区那几条
React.memo(MessageRow)props 没变的消息行跳过重渲染
流式文本独立兄弟节点(L05)高频更新区域只有一小块,列表不动
ref 当真值源(L06)字增量不触发整树重画
useDeferredValue长列表重渲染降优先级,保输入流畅
useDeferredValue:React 18 的 API,把某个值的更新标记为"低优先级"。这里对消息列表用它——当你打字(高优先级)时,庞大消息列表的重渲染会被延后,保证输入不卡。输入永远比历史刷新重要,这是交互体验的取舍。读 UI 代码时看到 memo/ref/deferred,别当噪音——它们是作者跟"终端重画开销"斗争的痕迹。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • 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
明天预告 · Day 05:大脑(Day 03/06)和脸(Day 04)都认识了。Day 05 把它们串成一条完整旅程——从你按回车,到 Agent 改完代码把结果显示在屏幕上,代码在各层之间怎么流转。第 1 周收官。

← Day 03 Agent 循环 Day 05 · 一次对话的完整旅程 →