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

📅 2026/8/9 2:16:55
为什么 Agent REPL 要上 Ink:好处、用法与内部设计
上一篇对照 Claude Code 修漂移相关REPL · CLI / readline · Interrupt示例仓库react-agent-mini默认交互已换成Ink。本文假定你熟悉 React组件、state、hooks重点讲Ink 相对 readline 解决什么问题、终端侧原语怎么用以及 Reconciler / Yoga / 屏缓冲在 Ink 里各干什么。先说结论对比readline 手写 stdoutInk模型命令式打印 / 清行 / 挪光标同一套 React 组件模型宿主换成终端布局字符串拼接FlexYoga→ 字符网格多区域流式正文、输入框、权限问句互相踩脚组件树分区共存刷新容易整段重打、闪屏屏缓冲diff后写 ANSIInk 把 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 让终端重画这类「用字符格子搭起来的交互界面」就叫TUIText User Interface文本用户界面——对照 GUI图形界面。htop、vim 的屏幕、Claude Code / 本仓的 Ink REPL都是 TUIconsole.log一行行往下滚一般不叫 TUI。词在 TUI 里的角色stdout画面输出通道console.log也走它易和 TUI 抢道stdin键盘进来的字节流见下节 raw modeANSI / CSI改颜色、移光标、清行等的转义序列手写 TUI 就要自己拼Ink 替你生成Yoga、屏缓冲都是在服务这张字符表。raw mode为什么要开、Ink 怎么开终端默认多半是cooked熟模式内核先帮你做行编辑——你打字会回显只有按回车才把整行交给进程CtrlC 往往直接 SIGINT。这对readline友好对「每按一键就改界面」不友好。raw生模式关掉这层加工按键字节尽快进stdin不自动回显、不等整行CtrlC 也变成普通字节\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统一管才能和 CtrlC、退出清理对齐。非 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。差别在宿主原语和输入WebInkdiv CSS flexBoxflex 容器官方类比display:flex的 divspan/ 文本节点Text颜色、粗体等 → ANSIonKeyDown/ inputuseInputstdin raw 解析后的input/keycreateRoot(...).renderInk 的render/createRoot接管 stdout/stdinBox flexDirectioncolumn width100% Text bold colorcyan标题/Text Text dimColor提示/Text /BoxflexDirectioncolumn子节点从上往下排。颜色不必手写 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选项里常见是否自动处理 CtrlC、是否 patchconsole防止日志打穿画面。条件渲染照旧换的是「怎么落到终端」return ( Box flexDirectioncolumn width100% Text boldreact-agent-mini/Text Messages snapshot{snap} / StatusLine snapshot{snap} / {snap.permission ? ( PermissionDialog request{snap.permission} onAnswer{a bridge.answerPermission(a)} / ) : ( Box flexDirectioncolumn SlashSuggestList ... / PromptInput ... / /Box )} /Box )有权限 →PermissionDialog否则 → slash 建议 PromptInput。结构即产品分区清行、挪光标、写 ANSI由 Ink 完成。4. 内部链路每个名词干什么写业务很少直接调这些 API读 Ink / Claude Code 源码时会反复撞上。按「在管线里的位置」记。4.1 自定义 ReconcilerReact 负责组件树与更新调度真正创建/更新「宿主节点」由 reconciler 对接的宿主实现完成。浏览器react-dom→ DOM原生React Native → 原生控件Inkreact-reconciler 自研宿主 → 终端节点树box/text 等所以「自定义 Reconciler」 Ink 把 React 的宿主从 DOM换成终端节点不是让你在业务里再写一套 reconciler。有人把这棵树叫 terminal DOM / Ink DOM——结构类比 DOM不是网页 DOM。4.2 Yoga 布局YogaMeta是实现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 → 真终端名词一句话自定义 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一般不直接依赖componentsBox、Text、useInput…日常 APItheme主题与成套控件按需本仓 REPL 先用基础原语6. 本仓 Agent REPL 怎么拼6.1 分区区域职责Messagestranscript 流式助手文本StatusLinerunning、ctx % 等PermissionDialog挡住输入收y/n/aPromptInput( slash)编辑与提交export function Messages({ snapshot }: MessagesProps) { return ( Box flexDirectioncolumn marginBottom{1} {snapshot.items.map(item ( ItemView key{item.id} item{item} / ))} {snapshot.streamingText ? ( Box flexDirectioncolumn 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 flexDirectioncolumn Ansi dimColor{dimColor}{ansi}/Ansi /Box ) }formatMarkdownsrc/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 字符串样式规则集中在formatTokenInk 负责把已着色字符串嵌进布局。6.3 键盘分层PromptInput编辑 / 提交REPL上CtrlC→ Interrupt 三段态谁听键由组件树 isActive决定。6.4 组件只依赖一份「当前界面状态」不好的接法Messages/PromptInput里直接import QueryEngine自己订阅runTurn的 yield、自己拼 tool 结果、自己调权限。引擎一改字段整棵 UI 一起碎。本仓的做法是中间放一层HostBridgeQueryEngine跑模型、调工具、问权限 │ 事件 / yield ▼ HostBridge ← 翻译成「界面现在该显示什么」 │ snapshot subscribe ▼ Ink 组件只读 snapshot点按钮时调 bridge 的 submit / answer / abortsnapshot长这样字段即画面字段界面怎么用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_UIreadline。管道 / 单次问答用-p避免 TUI 与脚本抢 stdout。你可以从这里带走什么Ink 终端宿主上的 React引擎与画屏分层。日常 APIBox、Text、useInput、render其余 hooks 照旧。管线自定义 Reconciler → Yoga行列 Flex→ 屏缓冲 → ANSI diff。Agent REPL分区组件Markdown → marked chalk →Ansi经 Bridge 用 snapshot 驱动headless 可不进 Ink。仓库与相关文档GitHubhttps://github.com/jimchou-h/react-agent-miniInk 包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、屏缓冲与差分刷新在管线中的位置。