为什么 Agent REPL 要上 Ink:好处、用法与内部设计
上一篇:对照 Claude Code 修漂移
相关:REPL · CLI / readline · Interrupt示例仓库:react-agent-mini
默认交互已换成Ink。本文假定你熟悉 React(组件、state、hooks);重点讲:Ink 相对 readline 解决什么问题、终端侧原语怎么用,以及 Reconciler / Yoga / 屏缓冲在 Ink 里各干什么。
先说结论
| 对比 | readline+ 手写 stdout | Ink |
|---|---|---|
| 模型 | 命令式打印 / 清行 / 挪光标 | 同一套 React 组件模型,宿主换成终端 |
| 布局 | 字符串拼接 | Flex(Yoga)→ 字符网格 |
| 多区域 | 流式正文、输入框、权限问句互相踩脚 | 组件树分区共存 |
| 刷新 | 容易整段重打、闪屏 | 屏缓冲diff后写 ANSI |
Ink = 把 React 画到终端上的 UI 运行时。
Agent 引擎(对话、工具、权限规则)照旧;人看得见的 REPL 用 Ink 画。
1. 终端画布:字符格,不是像素
浏览器按像素排版;终端按行 × 列的字符格子排版,每格一个字 + 样式(颜色、粗体等)。
列 → 0 1 2 3 4 5 … 行 ↓ 0 r e a c t - … 1 > h e l … 2含义直接决定后面几件事:
- 没有原生 Button,只有字符拼出来的外观
- 「布局」= 某段内容从第几行第几列起、占多宽
- 「刷新」= 改若干格子,再发 ANSI 让终端重画
这类「用字符格子搭起来的交互界面」就叫TUI(Text User Interface,文本用户界面)——对照 GUI(图形界面)。htop、vim 的屏幕、Claude Code / 本仓的 Ink REPL,都是 TUI;console.log一行行往下滚,一般不叫 TUI。
| 词 | 在 TUI 里的角色 |
|---|---|
| stdout | 画面输出通道(console.log也走它,易和 TUI 抢道) |
| stdin | 键盘进来的字节流(见下节 raw mode) |
| ANSI / CSI | 改颜色、移光标、清行等的转义序列;手写 TUI 就要自己拼,Ink 替你生成 |
Yoga、屏缓冲都是在服务这张字符表。
raw mode:为什么要开、Ink 怎么开
终端默认多半是cooked(熟)模式:内核先帮你做行编辑——你打字会回显,只有按回车才把整行交给进程;Ctrl+C 往往直接 SIGINT。这对readline友好,对「每按一键就改界面」不友好。
raw(生)模式关掉这层加工:按键字节尽快进stdin,不自动回显、不等整行;Ctrl+C 也变成普通字节(\x03),由程序自己决定退出还是取消当前轮。
Ink 的链路大致是:
useInput(..., { isActive }) → setRawMode(true) // 引用计数:多个 hook 共用一根 stdin → stdin.setRawMode(true) // Node TTY API(底层 termios) → 监听 stdin 'readable' → read() 取出 chunk → 解析成 key 事件(含 CSI 方向键、粘贴括号等) → emit('input') → 你的 useInput 回调卸载或isActive: false时setRawMode(false);引用计数归零才真正关掉 raw,并摘掉 listener。
因此:业务侧写useInput即可;不要自己再对process.stdin.setRawMode抢控制——Ink 通过StdinContext统一管,才能和 Ctrl+C、退出清理对齐。
非 TTY(管道喂入)通常不能raw mode;这也是-p/ pipe 不走 Ink 交互的原因之一。
2. 为什么 readline 不够?
CLI / REPL 篇 的模式:
> 一行输入 → runTurn → println 结果 → 再 >短问答够用。Agent 交互要的是上面说的TUI:一块持续存活、可分区刷新的界面。
| 需求 | 纯打印的麻烦 |
|---|---|
| 上滚动 transcript、下固定输入框 | 流式输出冲掉「底部」,光标要手算 |
权限面板y/n/a | 和输入提示抢同一行协议 |
| ctx%、running | 又一层特殊打印,和正文缠在一起 |
| 状态驱动换面 | 满地 flag +console.log |
不是日志管道,是多区域状态机——这才轮到 Ink。
3. 用法:Ink 相对 React DOM 换了什么?
心智仍是 React。差别在宿主原语和输入:
| Web | Ink |
|---|---|
div+ CSS flex | Box(flex 容器;官方类比display:flex的 div) |
span/ 文本节点 | Text(颜色、粗体等 → ANSI) |
onKeyDown/ input | useInput(stdin raw + 解析后的input/key) |
createRoot(...).render | Ink 的render/createRoot(接管 stdout/stdin) |
<Box flexDirection="column" width="100%"> <Text bold color="cyan">标题</Text> <Text dimColor>提示</Text> </Box>flexDirection="column":子节点从上往下排。颜色不必手写 escape。
useInput:终端键事件
useInput( (input, key) => { if (disabled) return if (key.return) { const v = value update('') onSubmit(v) return } if (key.backspace || key.delete) { update(value.slice(0, -1)) return } if (key.ctrl || key.meta) return if (input) update(value + input) }, { isActive: !disabled }, )input:可打印字符key.return/key.backspace/key.ctrl…:功能键isActive:是否接收键——权限框弹出时关掉输入框监听,避免抢键
useApp().exit()结束 Ink 会话。render选项里常见:是否自动处理 Ctrl+C、是否 patchconsole(防止日志打穿画面)。
条件渲染照旧,换的是「怎么落到终端」
return ( <Box flexDirection="column" width="100%"> <Text bold>react-agent-mini</Text> <Messages snapshot={snap} /> <StatusLine snapshot={snap} /> {snap.permission ? ( <PermissionDialog request={snap.permission} onAnswer={a => bridge.answerPermission(a)} /> ) : ( <Box flexDirection="column"> <SlashSuggestList ... /> <PromptInput ... /> </Box> )} </Box> )有权限 →PermissionDialog;否则 → slash 建议 +PromptInput。结构即产品分区;清行、挪光标、写 ANSI由 Ink 完成。
4. 内部链路:每个名词干什么?
写业务很少直接调这些 API;读 Ink / Claude Code 源码时会反复撞上。按「在管线里的位置」记。
4.1 自定义 Reconciler
React 负责组件树与更新调度;真正创建/更新「宿主节点」由 reconciler 对接的宿主实现完成。
- 浏览器:
react-dom→ DOM - 原生:React Native → 原生控件
- Ink:
react-reconciler+ 自研宿主 → 终端节点树(box/text 等)
所以「自定义 Reconciler」= Ink 把 React 的宿主从 DOM换成终端节点,不是让你在业务里再写一套 reconciler。
有人把这棵树叫 terminal DOM / Ink DOM——结构类比 DOM,不是网页 DOM。
4.2 Yoga 布局
Yoga(Meta)是实现Flexbox的布局引擎。Box上的flexDirection、width、margin等交给 Yoga,算出每个节点的矩形。
关键差别:浏览器单位常是像素;终端单位是列与行。
没有它就要手算「这段字从第 3 行第 0 列开始」;有它则声明 flex,引擎出坐标。
Yoga = 终端字符网格上的 Flex 排版器。
4.3 屏缓冲(Screen buffer)
布局之后,先填一张内存里的整屏草稿:每格字符 + 样式(+ 超链接等)。
这叫 screen buffer——先成帧,再决定怎么打到真终端。
4.4 Diff → ANSI
整屏清掉重画会闪、抖。常见路径:
- 算新屏缓冲
- 与上一帧 diff
- 只对变化发 ANSI(移光标、改若干格)
流式多几个字时,往往只动 transcript 相关行,底部输入区可以稳住。
4.5 整条管道
组件树(Box / Text + state) │ ▼ React + 自定义 Reconciler → 终端节点树 │ ▼ Yoga → 每节点行列矩形 │ ▼ 屏缓冲 → 字符表草稿 │ ▼ Diff → ANSI → stdout → 真终端| 名词 | 一句话 |
|---|---|
| 自定义 Reconciler | React 宿主改为终端节点,而非 DOM |
| 终端节点树 | Ink 内部的 box/text 树 |
| Yoga | Flex → 行列坐标 |
| 屏缓冲 | 一帧画面的内存草稿 |
| Diff + ANSI | 增量写回终端 |
5. 包的三层结构
/** * @anthropic/ink — Terminal React rendering framework * * Three-layer architecture: * core/ — Rendering engine (reconciler, layout, terminal I/O, screen buffer) * components/ — UI primitives (Box, Text, ScrollBox, App, hooks) * theme/ — Theme system (ThemeProvider, ThemedBox, ThemedText, design-system) */| 层 | 内容 | 业务侧 |
|---|---|---|
| core | reconciler、Yoga、屏缓冲、终端 I/O | 一般不直接依赖 |
| components | Box、Text、useInput… | 日常 API |
| theme | 主题与成套控件 | 按需;本仓 REPL 先用基础原语 |
6. 本仓 Agent REPL 怎么拼
6.1 分区
| 区域 | 职责 |
|---|---|
| Messages | transcript + 流式助手文本 |
| StatusLine | running、ctx % 等 |
| PermissionDialog | 挡住输入,收y/n/a |
| PromptInput(+ slash) | 编辑与提交 |
export function Messages({ snapshot }: MessagesProps) { return ( <Box flexDirection="column" marginBottom={1}> {snapshot.items.map(item => ( <ItemView key={item.id} item={item} /> ))} {snapshot.streamingText ? ( <Box flexDirection="column"> <Text color={'magenta' as any}>assistant:</Text> <Markdown>{snapshot.streamingText}</Markdown> </Box> ) : null} </Box> ) }- 旧:
runTurn里process.stdout.write(delta) - 新:更新
streamingText→Messages重渲 → Ink diff
用户 / 助手正文还会包一层<Markdown>,不是直接塞进<Text>。
6.2 Markdown 怎么展示到终端
网页里 Markdown → HTML → DOM。终端没有 DOM,本仓路径是:
Markdown 源码(模型吐出的 # / ** / ```…) │ ▼ marked.lexer → token 树(heading / paragraph / strong / code …) │ ▼ formatToken + chalk → 带 ANSI 的字符串(粗体、颜色、列表符号…) │ ▼ <Ansi>{ansi}</Ansi> → Ink 按转义序列填屏缓冲(不是当纯文本打印)组件本身很薄:
export function Markdown({ children, dimColor }: MarkdownProps): React.ReactNode { const ansi = useMemo(() => formatMarkdown(children), [children]) return ( <Box flexDirection="column"> <Ansi dimColor={dimColor}>{ansi}</Ansi> </Box> ) }formatMarkdown(src/ui/utils/markdownFormat.ts)做的事:
marked.lexer:只词法分析成 token,不渲染 HTML(终端用不上 HTML)。- 按 token 类型上色:例如标题
chalk.bold/ 下划线,加粗bold,行内代码cyan,链接蓝字 + dim 的 URL,列表用•/1.。 - 强制
chalk.level = 3:即便某些环境下 stdout 被判定非 TTY,也仍产出带色序列——因为真正画屏的是 Ink 的<Ansi>,不是直接console.log。 - 子集即可:删线等按需关掉;图片变成
[image: …]占位——终端画不了真图时至少不炸。
流式时:streamingText每变一截就重新formatMarkdown。未闭合的 ``` 可能暂时难看,完整段落地后会正常;这是「边收边渲」的取舍,不是另搞一套增量 Markdown 解析器。
和手写 ANSI 的差别:业务只写/存 Markdown 字符串;样式规则集中在formatToken,Ink 负责把已着色字符串嵌进布局。
6.3 键盘分层
PromptInput:编辑 / 提交REPL上Ctrl+C→ Interrupt 三段态
谁听键由组件树 +isActive决定。
6.4 组件只依赖一份「当前界面状态」
不好的接法:Messages/PromptInput里直接import QueryEngine,自己订阅runTurn的 yield、自己拼 tool 结果、自己调权限。引擎一改字段,整棵 UI 一起碎。
本仓的做法是中间放一层HostBridge:
QueryEngine(跑模型、调工具、问权限) │ 事件 / yield ▼ HostBridge ← 翻译成「界面现在该显示什么」 │ snapshot + subscribe ▼ Ink 组件(只读 snapshot,点按钮时调 bridge 的 submit / answer / abort)snapshot长这样(字段即画面):
| 字段 | 界面怎么用 |
|---|---|
items | 已显示的用户 / 助手 / 工具 / 系统行 |
streamingText | 正在往外吐的助手正文 |
turnInProgress | 为 true 时禁用输入 |
permission | 非空则画权限面板 |
statusLine/ctxPercent | 状态行 |
组件因此只做两件事:按 snapshot 渲染;把用户动作交给 bridge(提交一句、回答y/n/a、中断)。-p/ pipe 可以不启动 Ink,继续直接消费引擎流——同一套引擎,两套出口。
7. 和前几篇的关系
| 篇 | 内容 |
|---|---|
| REPL 会话 | 多轮 messages、slash、会话语义 |
| CLI / readline | argv、stdin、打印粘引擎 |
| 本篇 | TUI / Ink:原语、管线、REPL 拼装 |
会话规则可不变;变的是呈现宿主:打印循环 → 可刷新的组件树。
8. 跑一下
bun run dev# 或bun run dev:repl对比旧路径:REPL_UI=readline。
管道 / 单次问答用-p,避免 TUI 与脚本抢 stdout。
你可以从这里带走什么?
- Ink = 终端宿主上的 React;引擎与画屏分层。
- 日常 API:
Box、Text、useInput、render(其余 hooks 照旧)。 - 管线:自定义 Reconciler → Yoga(行列 Flex)→ 屏缓冲 → ANSI diff。
- Agent REPL:分区组件;Markdown → marked + chalk →
<Ansi>;经 Bridge 用 snapshot 驱动;headless 可不进 Ink。
仓库与相关文档
- GitHub:https://github.com/jimchou-h/react-agent-mini
- Ink 包:packages/@anthropic/ink
- 源码:PromptInput.tsx · Messages.tsx · Markdown.tsx · markdownFormat.ts · REPL.tsx
- 相关前作:REPL · CLI / readline · Interrupt
欢迎 Star、Issue 和 PR。
本文说明 react-agent-mini 为何用 Ink 做 REPL:相对 readline 的收益、Box/Text/useInput 用法、Markdown→ANSI 展示,以及自定义 Reconciler、Yoga、屏缓冲与差分刷新在管线中的位置。