相关: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: false 时 setRawMode(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 上的 flexDirection、width、margin 等交给 Yoga,算出每个节点的矩形。
关键差别:浏览器单位常是像素;终端单位是列与行 。
没有它就要手算「这段字从第 3 行第 0 列开始」;有它则声明 flex,引擎出坐标。
Yoga = 终端字符网格上的 Flex 排版器。
4.3 屏缓冲(Screen buffer)
布局之后,先填一张内存里的整屏草稿 :每格字符 + 样式(+ 超链接等)。
这叫 screen buffer------先成帧,再决定怎么打到真终端。
4.4 Diff → ANSI
整屏清掉重画会闪、抖。常见路径:
- 算新屏缓冲
- 与上一帧 diff
- 只对变化发 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 | Box、Text、useInput... |
日常 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>
)
}
- 旧 :
runTurn里process.stdout.write(delta) - 新 :更新
streamingText→Messages重渲 → 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>
)
}
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:
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。
你可以从这里带走什么?
- 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、屏缓冲与差分刷新在管线中的位置。