为什么 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. 终端画布:字符格,不是像素

浏览器按像素排版;终端按 行 × 列的字符格子 排版,每格一个字 + 样式(颜色、粗体等)。

text 复制代码
列 →  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 的链路大致是:

text 复制代码
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 篇 的模式:

text 复制代码
> 一行输入 → 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)
tsx 复制代码
<Box flexDirection="column" width="100%">
  <Text bold color="cyan">标题</Text>
  <Text dimColor>提示</Text>
</Box>

flexDirection="column":子节点从上往下排。颜色不必手写 escape。

useInput:终端键事件

22:38:src/ui/components/PromptInput.tsx 复制代码
  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、是否 patch console(防止日志打穿画面)。

条件渲染照旧,换的是「怎么落到终端」

170:197:src/ui/screens/REPL.tsx 复制代码
  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 整条管道

text 复制代码
组件树(Box / Text + state)
        │
        ▼
 React + 自定义 Reconciler  →  终端节点树
        │
        ▼
 Yoga                      →  每节点行列矩形
        │
        ▼
 屏缓冲                    →  字符表草稿
        │
        ▼
 Diff → ANSI → stdout      →  真终端
名词 一句话
自定义 Reconciler React 宿主改为终端节点,而非 DOM
终端节点树 Ink 内部的 box/text 树
Yoga Flex → 行列坐标
屏缓冲 一帧画面的内存草稿
Diff + ANSI 增量写回终端

5. 包的三层结构

1:8:packages/@anthropic/ink/src/index.ts 复制代码
/**
 * @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 BoxTextuseInput... 日常 API
theme 主题与成套控件 按需;本仓 REPL 先用基础原语

6. 本仓 Agent REPL 怎么拼

6.1 分区

区域 职责
Messages transcript + 流式助手文本
StatusLine running、ctx % 等
PermissionDialog 挡住输入,收 y/n/a
PromptInput (+ slash) 编辑与提交
45:58:src/ui/components/Messages.tsx 复制代码
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,本仓路径是:

text 复制代码
Markdown 源码(模型吐出的 # / ** / ```...)
        │
        ▼
 marked.lexer          →  token 树(heading / paragraph / strong / code ...)
        │
        ▼
 formatToken + chalk   →  带 ANSI 的字符串(粗体、颜色、列表符号...)
        │
        ▼
 <Ansi>{ansi}</Ansi>   →  Ink 按转义序列填屏缓冲(不是当纯文本打印)

组件本身很薄:

16:22:src/ui/components/Markdown.tsx 复制代码
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+CInterrupt 三段态

谁听键由组件树 + isActive 决定。

6.4 组件只依赖一份「当前界面状态」

不好的接法:Messages / PromptInput 里直接 import QueryEngine,自己订阅 runTurn 的 yield、自己拼 tool 结果、自己调权限。引擎一改字段,整棵 UI 一起碎。

本仓的做法是中间放一层 HostBridge

text 复制代码
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. 跑一下

bash 复制代码
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。

仓库与相关文档

欢迎 Star、Issue 和 PR。


本文说明 react-agent-mini 为何用 Ink 做 REPL:相对 readline 的收益、Box/Text/useInput 用法、Markdown→ANSI 展示,以及自定义 Reconciler、Yoga、屏缓冲与差分刷新在管线中的位置。

相关推荐
lllsure1 小时前
Vue&React Router
前端·vue.js·react.js
W_chuanqi1 小时前
VSCode + Claude Code + DeepSeek:打造 AI 编程神器
ide·vscode·编辑器·ai编程
VIP_CQCRE1 小时前
用 Ace Data Cloud 自动发布 CSDN 技术博客:把 AI 内容变成可持续获客入口
ai·自动化·csdn·ace data cloud·技术营销
IT_陈寒1 小时前
Java 8的stream让我debug了一整天,气笑了
前端·人工智能·后端
小当家.1051 小时前
工具并行调用原理与实现:CompletableFuture 实战
java·agent·线程池·工具·并行
蒲公英eric1 小时前
从直接拼接到参数化查询:DVWA SQL 注入模块完整漏洞分析教程
ai·dvwa·ai安全·sql 注入模块·sql injection
蒸蒸yyyyzwd2 小时前
cpp 选手 转 ai 后端学习笔记
c++·transformer·ai编程
小黑技术栈2 小时前
web前端基础到入门——14day
前端·数据库·oracle
苏灿烤鱼2 小时前
AI 论文档案库|大模型与 Agent 周报
人工智能·agent