ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

为什么 Agent REPL 要上 Ink:好处、用法与内部设计

2026/8/9 2:25:15 拓冰建站 浏览量
为什么 Agent REPL 要上 Ink:好处、用法与内部设计

上一篇:对照 Claude Code 修漂移
相关:REPL · CLI / readline · Interrupt

示例仓库:react-agent-mini
默认交互已换成Ink。本文假定你熟悉 React(组件、state、hooks);重点讲:Ink 相对 readline 解决什么问题、终端侧原语怎么用,以及 Reconciler / Yoga / 屏缓冲在 Ink 里各干什么。


先说结论

对比readline+ 手写 stdoutInk
模型命令式打印 / 清行 / 挪光标同一套 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: falsesetRawMode(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。差别在宿主原语输入

WebInk
div+ CSS flexBox(flex 容器;官方类比display:flex的 div)
span/ 文本节点Text(颜色、粗体等 → ANSI)
onKeyDown/ inputuseInput(stdin raw + 解析后的input/key
createRoot(...).renderInk 的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上的flexDirectionwidthmargin等交给 Yoga,算出每个节点的矩形。

关键差别:浏览器单位常是像素;终端单位是列与行
没有它就要手算「这段字从第 3 行第 0 列开始」;有它则声明 flex,引擎出坐标。

Yoga = 终端字符网格上的 Flex 排版器。

4.3 屏缓冲(Screen buffer)

布局之后,先填一张内存里的整屏草稿:每格字符 + 样式(+ 超链接等)。
这叫 screen buffer——先成帧,再决定怎么打到真终端。

4.4 Diff → ANSI

整屏清掉重画会闪、抖。常见路径:

  1. 算新屏缓冲
  2. 与上一帧 diff
  3. 只对变化发 ANSI(移光标、改若干格)

流式多几个字时,往往只动 transcript 相关行,底部输入区可以稳住。

4.5 整条管道

组件树(Box / Text + state) │ ▼ React + 自定义 Reconciler → 终端节点树 │ ▼ Yoga → 每节点行列矩形 │ ▼ 屏缓冲 → 字符表草稿 │ ▼ Diff → ANSI → stdout → 真终端
名词一句话
自定义 ReconcilerReact 宿主改为终端节点,而非 DOM
终端节点树Ink 内部的 box/text 树
YogaFlex → 行列坐标
屏缓冲一帧画面的内存草稿
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) */
内容业务侧
corereconciler、Yoga、屏缓冲、终端 I/O一般不直接依赖
componentsBoxTextuseInput日常 API
theme主题与成套控件按需;本仓 REPL 先用基础原语

6. 本仓 Agent REPL 怎么拼

6.1 分区

区域职责
Messagestranscript + 流式助手文本
StatusLinerunning、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> ) }
  • runTurnprocess.stdout.write(delta)
  • :更新streamingTextMessages重渲 → 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> ) }

formatMarkdownsrc/ui/utils/markdownFormat.ts)做的事:

  1. marked.lexer:只词法分析成 token,不渲染 HTML(终端用不上 HTML)。
  2. 按 token 类型上色:例如标题chalk.bold/ 下划线,加粗bold,行内代码cyan,链接蓝字 + dim 的 URL,列表用/1.
  3. 强制chalk.level = 3:即便某些环境下 stdout 被判定非 TTY,也仍产出带色序列——因为真正画屏的是 Ink 的<Ansi>,不是直接console.log
  4. 子集即可:删线等按需关掉;图片变成[image: …]占位——终端画不了真图时至少不炸。

流式时:streamingText每变一截就重新formatMarkdown。未闭合的 ``` 可能暂时难看,完整段落地后会正常;这是「边收边渲」的取舍,不是另搞一套增量 Markdown 解析器。

和手写 ANSI 的差别:业务只写/存 Markdown 字符串;样式规则集中在formatToken,Ink 负责把已着色字符串嵌进布局。

6.3 键盘分层

  • PromptInput:编辑 / 提交
  • REPLCtrl+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 / readlineargv、stdin、打印粘引擎
本篇TUI / Ink:原语、管线、REPL 拼装

会话规则可不变;变的是呈现宿主:打印循环 → 可刷新的组件树。


8. 跑一下

bun run dev# 或bun run dev:repl

对比旧路径:REPL_UI=readline
管道 / 单次问答用-p,避免 TUI 与脚本抢 stdout。


你可以从这里带走什么?

  1. Ink = 终端宿主上的 React;引擎与画屏分层。
  2. 日常 APIBoxTextuseInputrender(其余 hooks 照旧)。
  3. 管线:自定义 Reconciler → Yoga(行列 Flex)→ 屏缓冲 → ANSI diff。
  4. 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、屏缓冲与差分刷新在管线中的位置。