@epoch-agent/tui

中文文档
6.2.0发布于 2026/11/13Apache-2.0TypeScriptES Module近 30 天下载 3889

epoch-agent Ink TUI 界面

原始使用文档
文档目录23 节
  1. 挂载
  2. translate 是必填的(方案 40 PR-2)
  3. 结构
  4. 布局约束(改这里之前先读)
  5. 斜杠命令
  6. 会动到模型上下文的四条
  7. 「不可用」的两种,别混
  8. /model —— 运行期换模型
  9. 换过之后:这一轮用哪个、以及模型挂了怎么办
  10. @ 补全:文件和会话两组
  11. ! 与 #
  12. /tasks 与状态栏的 ⚙ 计数
  13. 状态栏的自定义那一段
  14. 计划审批框
  15. 批准之后留一张卡片
  16. 回退面板( /rewind 和 Esc Esc )
  17. 快捷键
  18. 改键
  19. Shift+Enter 与 /terminal-setup
  20. 序列
  21. Vim 模式
  22. 两个验证脚本,别混用
  23. 开发

@epoch-agent/tui

Ink 7 + React 19 的终端界面。

  • ✅ 做:组件、布局、键位、Markdown 渲染、斜杠命令
  • ❌ 不做:不许 import core(check:layers 会红)、不 spawn 子进程、 不碰用户文件(唯一的写盘是 EPOCH_DEBUG 打开的调试日志,src/debug.ts 往系统临时目录追加 —— 2026-11-03 把上一版那句笼统的「不碰文件系统」改准)。
  • 依赖:protocol + view + chalk / lowlight / string-width 等纯渲染库。ink 和 react 是 peer

⚠️ string-width 的大版本必须和 ink 依赖的那个对齐(今天两边都是 8)。 一个进程里两张宽度表,在歧义宽度的字符上会给出不同答案(⚠ / ℹ / 🖼: 7 说两列、8 说一列),于是 ink 按一列排版、我们按两列算光标和填充——同一个框里 以 ⚠ 开头的行会比别的行宽出一列,右边线逐行错开。2026-08-25 实测到过, 门禁在 __tests__/screen-metrics.test.tsx 的 M1。

同一条判据的另一半:对齐一律走 text/utils.ts 的 padEndByWidth / truncateByWidth,不许再写 String.prototype.padEnd(它数的是码元, 中文一个字 1 个码元 2 列)。扫源码的那条在同一份用例的 M7。

引擎能力一律由宿主经 renderApp({ host }) 注入——切权限级别、列工具、读启动诊断、 打开 artifact、读剪贴板图片。这样每个斜杠命令都能对着一个假 context 单测,不用把整棵 Ink 树跑起来。

挂载

import { renderApp } from '@epoch-agent/tui';

const instance = renderApp({
  sessionId,
  app: { version, cwd },
  config: { model, workDir, permissionLevel, contextLimit, getUseBackgroundColor },
  usageScope: runtime.usageScope, // usage 事件里 cumulative 的口径
  welcomeMessage: '…',
  startupNotices: diagnostics, // 配置写错了必须看得见
  translate: (key, vars) => t(key, vars), // **必填** —— infra 那份 t(),见下
  onRun: (message, { signal }) => session.run(message, { signal }), // AsyncGenerator<AgentEvent>
  onExit: () => instance.unmount(),
  host: {/* HostActions,全部可选:缺哪个对应命令就报「不可用」而不是崩 */},
});

translate 是必填的(方案 40 PR-2)

界面文案住在仓库根的 locales/{zh,en}.yaml,而加载那一半(找目录、读 yaml、 setLang / resolveLang)住在 @epoch-agent/infra —— 这个包够不着它 (check-layers 里 tui 只有 protocol + view)。所以 catalog 由宿主读盘之后把 绑好的查表函数递进来,形状和 HostActions 是同一条判据: 「能力只能从宿主这一侧递进去」。

刻意是必填而不是可选:可选的话「宿主忘了传」退化成满屏 tui.help.available 这样的 key 路径,而那是运行时才看得见的事;必填让它在编译期就红。 (key 路径本身就是回落链的第三层,所以那个失败形态很响 —— 但它不该发生。)

查表本体在 protocol/src/i18n.ts(makeTranslate), web 也用同一份。⚠️ 别在模块级调 t():插槽是 renderApp() 装的, 而模块级常量在 import 那一刻就求值完了 —— 写法用函数或 getter, 门禁在 __tests__/i18n-no-key-leak.test.ts。

onRun 吐的是 protocol 的 AgentEvent——不是 tui 自己的类型。 入参是 EpochUserContent(string | 部件数组)而不是 string:Ctrl+V 粘进来的图片 必须跟着这一次提交走到引擎。

renderApp() 封装了 provider 树和 ink 的 render options。其中 exitOnCtrlC: false 是必须的——ink 默认会自己吞掉 \x03 直接结束进程,App 里的「Ctrl+C 按两次才退出」 就收不到按键。

结构

目录 / 文件内容
app.tsx根组件:<Static> 历史 + 限高 pending + 底栏 + 全局键位
render.tsxrenderApp()——provider 树 + ink render options
contexts/app / config / session / streaming / ui-state 五个 context
components/banner、history-item、input-box、status-bar、suggestions、toast、thinking-indicator、shortcuts-help
components/dialogs/tool-confirmation(审批)、plan-confirmation、command-palette、file-palette、search-palette(Ctrl+R)、rewind-picker(Esc Esc)、picker(单选 / 多选 / 自由输入)、select-list
components/messages/按类型分发的消息渲染:user / model / diff / error / warning / info / hint
commands/斜杠命令:builtin.ts / session.ts / inspect.ts / workspace.ts / rewind.ts / plugins.ts + parse.ts + prefix.ts + types.ts(HostActions 契约)
hooks/use-agent-run.ts(跑一轮 + 中断)、use-slash-commands.ts、use-rewind.ts(面板 + Esc Esc 窗口)、use-question.ts(结构化提问逐个走完)。⚠️ 2026-09-29 起,某一问带 page(插件在本机起的一张页)时副标题末尾补一句地址 —— 终端画不出 iframe,所以不开新组件。2026-10-15 起带 page 的那一问可以没有选项:那时副标题换成「选项在这个页面里 …」,列表里只剩「其他…」,并且一律按单选走(判据在 use-question.ts 那一处)
keybindings/键位层(方案 31):actions.ts / parser.ts(ink 的 Key → chord)/ defaults.ts(默认表 = 原来的硬编码)/ resolver.ts / use-keybinding.ts / sequences.ts + use-key-sequences.ts(序列)
vim/Vim 模式(方案 31 PR-3):motions.ts(w / $ / f, 落在哪)+ machine.ts((state, key) → (state, edits) 纯状态机)+ use-vim.ts(React 外壳,只存 ref / 喂渲染)
streaming/工具调用与 artifact 的展示格式化
text/ops.ts 多行 buffer 的纯状态迁移 + use-text-buffer.ts React 外壳
markdown/Markdown 解析、行内样式、代码高亮(lowlight)
theme/语义色 + 颜色工具
types/消息 / 状态类型、checkpoint-types.ts(core 那几个回退类型的结构性镜像)、host-info.ts(宿主算好的展示形状)
debug.tsdebug 日志

布局约束(改这里之前先读)

Ink 的 log-update 只能擦除它上一帧写过的行数。一旦动态帧比终端还高、内容滚出 屏幕,擦除范围就对不上:轻则残留重复行,重则 Ink 退化成 ESC[2J 整屏清除,表现为 剧烈闪烁。所以:

  • Banner 和已完成的历史必须放进 <Static>——只打印一次、向上滚走、不参与重绘;
  • 只有 pending(流式中)的内容留在动态帧里,且必须按 availableHeight 限高 (clampTailLines,保留尾部);
  • 根 Box 不要设 height。

正在执行的工具会在 pending 区画一小段活尾巴(tool-output-delta 的尾部若干行, 跑完被完整结果顶掉)。它是整个 TUI 里重绘最频繁的东西——每来一段增量就重画一帧, 所以行数卡得比结果更死(5 行 vs 8 行)。probe.tsx 里有专门压它的场景。

availableHeight 由 useWindowSize()(终端尺寸)减去 useBoxMetrics() 量出的底部 控制区高度得到,两个 hook 都是 ink 7 内置的。

斜杠命令

按用途分五组。要引擎能力的一律靠 host 注入,宿主没给对应回调时命令报 「不可用」,不崩。唯一的真源是 BUILTIN_COMMANDS 那张表,这里不写条数 —— 写了迟早对不上。

组命令
纯 UI(不需要任何宿主能力)/help /clear /exit /thinking /init
这次会话/status /cost /context /export /compact /resume
引擎里有什么/model /permission /permissions /plan /goal /tools /tasks /diagnostics /skills /agents /mcp /memory /plugin
和仓库打交道/diff /copy
退回去/rewind(同 Esc Esc,见下面「回退面板」)
键位/keybindings(别名 /keys,见下面「快捷键」)
终端/terminal-setup [--write|--revert](Shift+Enter 配置向导,宿主注入,见下面「快捷键」)

实现分九个文件:builtin.ts(前十二条 + 注册表合并)、session.ts、inspect.ts、 workspace.ts、rewind.ts、plugins.ts、goal.ts、keybindings.ts、 terminal-setup.ts。拆开纯粹是因为 500 行硬线,分组按用途而不是按「新旧」。

/goal(方案 52)、/keybindings(方案 31)和 /terminal-setup(方案 31)是这张 表里措辞不在 TUI 里的三条:它们回的都是宿主已经渲染好的文本 (HostActions.goals / HostActions.keybindings / HostActions.terminalSetup)。

⚠️ 这三条原来的理由是「因为 tui 够不着 t()」,那句话 2026-08-22 起不成立了 (方案 40 PR-2 把查表函数从宿主递进来了,见上面「挂载」那一节)。理由换成现在这个: 那三片文本本来就是宿主侧的事实(目标是引擎算的、键位来源只有加载期知道、 终端识别读的是 process.env),在 TUI 里重新渲染一遍等于把同一份判断抄两处。 /keybindings 那条第二理由照旧:「这条键是谁定的」只有加载期知道。 /terminal-setup 还要无条件注册:识别只读 process.env、不依赖 runtime 任何 子系统,认不出终端时那条命令照样在,进去走「只给建议」那一支。判据写在 commands/types.ts 的那两个字段上。用法见 docs/GOALS.md。

/plugin(别名 /plugins)只看三态和停用 / 启用,装和卸不在这里 —— 装一个插件要过一道「它会带来什么」的确认(插件能带 hook),那道闸门只该有一份, 在 epoch plugin install 上。见 docs/PLUGINS.md。

/plan 和 /permission plan 不是同一件事,这条区别决定了用户该敲哪个: 前者进入一段临时只读区间(模型交计划、用户批一次就自动出来), 后者把权限级别改成只读、一直到用户自己改回来。见下面「计划审批框」一节。 /plan show 是第三支:把当前生效的那份已批准计划再打一遍。它存在是因为那份 计划活得比屏幕长(跟着会话落盘),而 --resume 之后屏幕上是空的。

/init 是这里唯一走 submitPrompt 的内置命令 —— 它要干的活本来就是 「让模型去干活」(扫仓库写 EPOCH.md),形状和自定义命令一样。

会动到模型上下文的四条

/compact /resume /rewind,加上 Ctrl+R。前三条和其余那些不是一回事: 它们改的是模型下一轮真正看到的东西,错了的症状不是「少个功能」而是 「模型记错了」。所以三者的实现都在 AgentSession 上(compact() / resume() / rewindConversation()),TUI 这一侧只负责问和显示。

  • /compact [要保留什么] —— 手动压一次,不看阈值。那句「保留什么」会追加到 摘要提示词的最后,不插进模板中间:模板是我们的契约(摘要结构固定, 下一轮增量更新靠它对齐)。压缩是一次真实的 LLM 请求,所以这条命令会花钱, 压完报出前后条数让你看得见换来了什么;摘要没生成出来时说「没压动」而不是 报一个假的「已压缩」
  • /resume —— 列会话、选一个,模型真的接上那段上下文(历史和 sessionId 一起换)。只把历史打印到屏幕上是不够的:那样你会照着屏幕去问「你刚才说的那个 方案」,然后收到一句完全对不上的回答
  • Ctrl+R —— 搜会话历史(SQLite FTS5,中文靠 trigram 分词器)。 第一版只把命中的那条重新打印一遍,不做滚动定位 —— 那要碰 <Static>, 见上面的布局约束。面板最多显示 5 条且每行 wrap="truncate":这是高度保证 不是好看,一条结果折成两行整帧就会超过终端高度,pnpm tui:probe 抓到过
  • /rewind(同 Esc Esc)—— 退回某个检查点之前。对话那半是真删、 不可撤销,所以面板上多一步确认;退完还会清屏重放剩下的历史, 否则屏幕上留着几条模型已经看不见的消息,而你会照着它们追问

两处清单必须和实现一致:这段散文,以及 protocol 的 RESERVED_COMMAND_NAMES。 后者有一条双向用例守着(custom-commands.test.ts):内置有而名单没有 → 红; 名单有而内置已经删了 → 也红。前者没有守卫,所以加命令时别忘了连它一起改。

保留名单登记的是全集,不是「这次注册了的」—— 一条命令因为宿主缺能力 而没注册(SlashCommand.available,见下),不代表自定义命令就能占它的名字。

「不可用」的两种,别混

形态用在哪
注册了,敲了报一行「不可用」本该有、这次没起来(provider 挂了时的 /model)
available 返回 false,不注册这个宿主就没有这个功能(比如没接权限出口时的 /permissions)

区别是前者要说话、后者要消失:一条注册了的命令会出现在 /help 和补全面板里, 那是在承诺一个不存在的功能。

自定义命令(~/.epoch/commands/*.md 和项目里那份)由宿主加载好之后经 renderApp({ customCommands }) 传进来,和内置命令进同一张注册表、同一个补全面板, /help 里分两段列。它们的出口不一样:内置命令是「在 TUI 里做一件事」, 自定义命令是「替用户敲一段话」,走 ctx.submitPrompt() 发一轮对话。

插值($ARGUMENTS / $1)不在这里做 —— 它要引号感知的分词器,而那住在 @epoch-agent/infra,tui 只依赖 protocol。所以 TUI 调 host.expandCommand() 拿结果。在这儿手写第二个「差不多的」切词器,就会出现同一条命令在 TUI 和别的宿主里 断句不一样。格式与规则见 docs/EXTENSIONS.md。

/model —— 运行期换模型

/model                              看当前模型 / provider / 凭据 / 窗口
/model @my-relay                    换到「我的模型」里那一条(端点 / key 跟着它走)
/model --reset                      回到配置里那个

换模型不清空上下文——那正是它的用处(对着同一段对话换个更强或更便宜的脑子)。 代价是新模型得接得住已经积累的东西,所以有三道前置检查,任何一道不过就一个字段都不动:

拦住的情况为什么
目标那一条没有 key请求根本发不出去,理由里点名是哪一条(去设置页给它填一把)
会话里有图片,新模型不认下一轮把带图历史发出去必然 400,而 400 是已经计费的
已用上下文超过新窗口同上。先 /clear 或换个大窗口的模型

装得下但已经很挤、或者新模型不支持工具调用,则是换成 + 出声(warning),不拦。

判据全在 core 的 evaluateSelection,TUI 只负责把结果念出来 —— 拒绝的理由逐字透传, 不在 TUI 里重编一句。取值只认 @<id>(「我的模型」里的一条),解析走 protocol 的 parseModelRef —— 2.0.0 起裸模型名 / <provider>/<model> 一律报「看不懂」, 那一轮仍跑在当前模型上。加条目用设置页或 epoch model。

⚠️ 已知边界(PR-2 复核后仍在):自动压缩的预算仍是启动时那个模型的窗口, 换到小窗口模型后压缩不会跟着提前触发 —— 这正是上面第三道检查直接拒绝而不是 「换了再说」的原因。要松成「换了就立刻压一次」得改 AgentConfig.contextLength 的形状并动 loop.ts,而那两处是方案 26 明列的禁止触碰项,留给后续方案。 另外 /model 目前不带交互式选择器,得自己打模型名 —— 这两条都已结案, 判据见 VERIFY_RECORD-26-model-switch 第四节。

换过之后:这一轮用哪个、以及模型挂了怎么办

/model 换的是会话级选择,但有两种情况会临时或永久地偏离它:

  • 命令 frontmatter 的 model: —— 一条自定义命令可以声明自己用哪个模型 (model: utility 解析成配置里的工具模型),只在那一轮生效,收尾无条件还原。 Esc 中断走的是生成器的 return(),还原照样发生
  • 降级 —— 主模型这个落点没救了就换 fallback.model 指向的那一条(2.0.0 起 只有这一级,条目自带端点,可以跨家)。404 / 模型不存在、401/403 / key 被拒、 402 / 额度耗尽当场换;429 / 5xx / 超时那一类先在原地把重试预算跑完,耗尽 再换(2026-10-16 起 —— 在那之前这一族一次都降不到)。 下一轮又会从你选的那个模型重新起步:降级是这一次请求的事,不改你的选择

同一个模型连续三次把请求拖垮,本会话就不再自动用它了,并在那一轮结束时给一条 提示 —— toast 加一条历史项,两处一起给:toast 抓得住正盯着屏幕的人,历史项留得住 几分钟后才回来看的人。而这句话说的是「你的账单和能力已经不是你选的那个模型了」。 重新 /model 选回它就清账,那是明确的「我知道,再试一次」。

@ 补全:文件和会话两组

打 @ 弹补全面板,分两组(方案 53 §1.3;2026-11-05 起会话在上):

┌ 会话 ────────────────────────────
│ 接上后台任务的输出   12/03 · 42 条 · 本工作区
├ 文件 ────────────────────────────
│ packages/core/src/agent/loop.ts
└──────────────────────────────────
  • 会话组:@ 一打就列出来,不需要先知道 @: 这个前缀(2026-11-05 之前 只有 @: 才出会话,而那件事只写在文档里)。选中补进去的是 sessionId (落成 @:id)而不是标题 —— 标题里几乎一定有空白,而提及的抽取规则遇到空白 就断 —— 提交时那段会话的 当前面(模型当时还看得见的那些)作为一个独立 部件发出去
  • 文件组:子序列模糊匹配(@c/a/loop 命中 packages/core/src/agent/loop.ts), Tab / Enter 补全路径,提交时文件内容随消息一起发出去,省掉 「模型调 file_read → 再等一轮」
  • @: 是缩窄:打了它就只要会话,文件组整个不列。这是「我知道自己要哪段 对话」时省掉下半屏的写法,不是另一种语法 —— 落进文本的仍然是 @:id
  • 会话组排上面的判据是影响范围:引用一段会话改变的是整场对话的前提, 一个文件是局部素材。↑↓ 的高亮下标跨两组连着走(会话最后一条 → 文件第一条)

⚠️ 会话组只按 sessionId / cwd / 标题匹配,永远不搜转录文本(方案 53 §5.2)。 这是安全边界不是性能考虑:面板边打边显示,能搜正文的话打 @:密码 就会在 屏幕上列出所有提到过密码的会话 —— 而这块屏幕可能正被别人看着(结对、录屏、演示)。 落地上它是结构性的:SessionCandidateView 里压根没有转录文本字段, 所以这个包里再怎么改过滤也搜不出正文。想搜正文的路是 方案 48 的 session_search —— 那是模型调的、结果进上下文而不是进屏幕。守卫见 completion-sessions.test.ts 和 core/__tests__/session-reference-sites.test.ts。

四件事不在这个包里,都是刻意的:

事情在哪为什么
文件候选清单宿主 host.listWorkspaceFiles()要 spawn git ls-files,tui 只依赖 protocol
会话候选清单宿主 host.listSessionCandidates()要过会话可读性判定、要查 SQLite,两样都在引擎
@ 的抽取规则@epoch-agent/protocol 的 mentions.ts引擎那侧要用同一份,两边写岔了会静默错位
读内容 + 判定宿主 host.resolveMentions()文件过工作区边界 + file_read 权限,会话过可读性判定

⚠️ input-box.tsx 的键盘回调只准读 ref。 ink 7 把子组件里注册的 useInput 回调钉在首帧,于是组件画得出来(渲染是新鲜的)但按键读到的是首帧的值。 这个坑一共有七个实例,六个是存量 bug(最后三个 2026-08-13 修掉):

读了什么症状
pendingImagesCtrl+V 粘的图被静默丢掉(方案 12 时发现)
matched/cl + Tab 什么也不做、Enter 把 /cl 当普通消息发给模型
mention@ 面板弹得出来但 Tab 没反应
disabled弹窗开着时按的键同时打进输入框 —— 答复审批按的 1 会留在里面
history(prop)↑ 永远翻不出上一条 —— 回调里那个数组恒为首帧的空数组
buf.isMultiline恒 false,多行时 ↑↓ 不做行间移动,直接去翻历史
buf.lines / col行尾 \ + Enter 的续行从来没生效(首帧那行是空的)

往这个文件加任何「键盘回调要读的 state 或 prop」都会再踩一次。buffer 状态一律 走 bufRef(整个 buf 的镜像,所以往 TextBufferAPI 加派生值不会漏)。 七条各有一条会红的回归用例(app.test.tsx / input-box-first-frame.test.tsx)。

! 与 #

!git status          直接跑一条命令,不过模型
#测试要用 pnpm test:ci  记一条,不过模型

两条都不产生一轮模型调用,但也都不是免检通道:! 走的是和模型调 terminal 完全相同的那条路(危险命令表 → 权限判定 → 审批 → 执行), 判定在引擎侧,TUI 只负责把确认框接上去。用户手打的命令和模型生成的命令在 危险性上没有区别。

! 的输出进两处:TUI 历史(给人看)和会话上下文(给模型看,host.noteToSession)。 只进前者的话,「你看一下这个命令的输出」这句话对模型是空的。被权限拦下的那句 不进上下文 —— 那不是命令的输出,是我们的一句拒绝。

# 第一次用时弹一次「记到哪一层」(项目知识 / 用户偏好),之后记住不再问: 每次都问的话这个快捷方式就不快了,永远不问又会让项目知识默默写进全局记忆。

判定本身(含 !!! / ## / 多行三条反例)在 commands/prefix.ts,纯函数, 用例在 completion.test.ts。

/tasks 与状态栏的 ⚙ 计数

后台任务(方案 36)活在 plugin-terminal 的任务表里,而 tui 不许 import 它 —— 所以两处都走宿主:host.listBackgroundTasks()。计数是每两秒轮询一次的, 且只在数字真的变了时 setState —— 无条件重渲染会让 <Static> 之外的一切 白重绘,实测就是光标闪。

一个都不在跑时那一块整块不画:绝大多数会话没有后台任务,一个常驻的 ⚙ 0 是纯噪音。

状态栏的自定义那一段

用户在 ~/.epoch/config.yaml 里配 statusLine.command 之后,状态栏右侧那一组的最左多一段 他自己的内容(通常是分支名)。整条链只有一个字符串过 tui 的边界: host.readStatusLine() 同步返回已经剥过 ANSI、取过第一行、截过 60 字符的那一行。

  • 超时、截断、失败保留旧值三条都在宿主那侧(cli/src/statusline.ts), 不在这里。tui 只依赖 protocol,跑不了子进程;而那三条是一组约束,拆到两个包里 维护迟早有一半失效
  • 没配时宿主压根不注入 readStatusLine,于是这里连计时器都不装、那一段整块不画。 不是给一个永远返回 null 的读取器让 UI 去判空
  • 这里 2 秒问一次是「多久去看一眼有没有新值」,不是「多久跑一次那条命令」—— 后者由用户的 statusLine.intervalMs 决定,宿主在 read() 里判。和上面 ⚙ 那段 同一个形状(轮询 + 值没变就不 setState),刻意没合并:刷新频率的归属不同

计划审批框

ApprovalRequest 带 plan 字段时(方案 35),底部弹的是 PlanConfirmation 而不是 ToolConfirmation。拆成两个组件是因为两者问的问题根本不同:工具审批的四个选项 讲的是授权范围(一次 / 本会话 / 永久 / 拒),计划审批的四个讲的是 接下来怎么走(批准并执行 / 批准但保持只读 / 让我改 / 拒绝)。

两条是安全性质,不是外观:

  • canExecute: false 时「批准并执行」根本不渲染。 那意味着用户本来就把权限 级别设成了 plan(他显式要求全程只读),一份计划被批准不能把他升上去。 画出来再由引擎拒绝更糟 —— 那是在给一个不存在的出口,用户点了才知道点不动
  • Esc 等于拒绝,和工具审批同一条口径:想不清楚就走开,必须落在安全的一侧

选「让我改一下」时不立刻答复,先在框里弹一行输入收用户那句意见, Enter 提交、Esc 退回选项。不收的话这个出口是个死循环:模型不知道要改什么, 只会把同一份计划原样再交一次。

⚠️ 那一行输入的真值住在 ref 里,state 只用于渲染 —— ink 7 在子组件里注册的 useInput 回调被钉在首帧,普通 state 跨不过这个边界。这不是风格问题, 见上面「布局约束」和 第四批收尾验收记录第三节。

批准之后留一张卡片

审批框是一次性的:点完「批准并执行」,接下来十分钟的纲就从屏幕上没了。所以批准的 那一刻往历史里落一条 PlanMessage, 写清楚是「批准并执行(权限回到 X)」还是「保持只读」—— 这两者的差别是 「agent 现在能不能动我的文件」,两分钟后用户就记不住自己点的是哪个了。 拒绝不落卡片:把一份作废的计划留在屏幕上是这里能犯的最坏的错。

卡片走 <Static> 里的历史项,不常驻底部:常驻意味着每一帧都要重绘几十行, 那正是上面「布局约束」骂的花屏成因。落进历史反而对 —— 它随对话往上滚, 终端自己的 scrollback 就是回看入口,回看不到了还有 /plan show。

⚠️ plan 这一种展示项不在 @epoch-agent/view 里,是 TUI 独有的 (见 types/message-types.ts)。往那个共用联合类型里加 就等于要求 Web 也在时间线上画它,而 Web 的持久形态是检视面板的一个 tab —— 设计稿明确否掉了「在时间线里保留一份长正文」。两个宿主对同一件事的形状本来就不同。

回退面板(/rewind 和 Esc Esc)

RewindPicker,语义全在 docs/CHECKPOINTS.md,这里只记 TUI 这一侧的决定。

Esc 单击的语义一点没变,也没有变慢。 双击判定的做法是「中断 + 回退」而不是 「等一等看是不是双击」:第一下 Esc 立刻把该干的干完(中断本轮 / 清空输入), 顺手武装 500ms 的窗口;第二下在窗口里到达时才开面板。中断慢半秒的代价是模型 在那半秒里又调了一个工具,所以 rewind.test.tsx 里那条断的是「让出一拍就已经 aborted」—— 比双击窗口小一个量级,挂在 setTimeout 上的实现必红。 窗口这套模式和 Ctrl+C 两段式退出共用一份写法(一个 state + 一个到点撤销的 effect),没造第二套。窗口取 500ms 而不是 Ctrl+C 的 2000ms:那个窗口是「别急, 再按一次才真退」,长一点更安全;这个是「刚才那下是不是双击的前半」,长了会把两次 无关的 Esc 误判成双击。

三条必须写在面板上,不能含糊过去:terminal 里跑的命令改的文件不在范围内、 这条检查点可能不完整、工作区有未提交改动时建议先 git stash。最后一条走的是 HostActions.gitDirty(git status --porcelain,未跟踪的新文件也算), 和 /diff 的 gitDiff 分开 —— 为一行提示读几 MB 补丁不划算,而口径也不一样。 问不出来(不是 git 仓库 / git 不在 PATH)时什么都不提示,不说「工作区是干净的」。

冲突文件默认一个都不覆盖,且没有「全部覆盖」这个按钮。 要覆盖得用空格逐个点过头, 勾中的路径才会进 rewind() 的 overwrite。引擎那侧同样刻意没有 force: true。

对话回退多一步确认,因为它是真删、不可撤销。退完之后 TUI 会清屏并把剩下的历史 重放一遍(拿当前 sessionId 再 resume 一次,也就是从 DB 重装)—— 不重放的话, 屏幕上留着几条模型已经看不见的消息,而你会照着它们追问,然后收到一句对不上的回答。

⚠️ 面板的 useInput isActive 不跟着步骤走,分步是在回调里判的。 ink 的 useInput 在 isActive 变 true 的那次 effect 里才挂上监听,而 effect 跑在 渲染提交之后 —— 中间那一小段时间里屏幕上已经画出了列表、按下去的键却没人接。 症状是用例随机红一条「等不到下一步」,真机上是「面板刚跳出来那一下敲的回车丢了」。

快捷键

下面是默认值,不是硬编码:每一行左边那个键对应右边那个动作,而动作和键的 对应关系可以改(见下一节「改键」)。默认表的真源是 keybindings/defaults.ts,它就是方案 31 之前散在 app.tsx / input-box.tsx 里的那些 if。

键动作操作
Entersubmit发送
Option+Enter / Shift+Enternewline换行
Ctrl+Jnewline换行
行尾 \ + Enter—换行(吃掉那个 \,不发送)
/—打开命令面板(打字自然触发,不是绑定)
Tabcomplete补全选中的命令
↑ / ↓history-prev/next行间移动;到边界则翻输入历史
↑ / ↓(面板开着时)complete-prev/next在补全面板里选
← / →cursor-left/right光标移动(跨行)
Home / Ctrl+Aline-start行首
End / Ctrl+Eline-end行尾
Ctrl+Wkill-word删词
Ctrl+U / Ctrl+Kkill-line-start/end删到行首 / 删到行尾
Backspace / Deletedelete-char-left/right删字符
Ctrl+Vpaste-image粘贴剪贴板里的图片
Ctrl+Oopen-artifact用系统查看器打开最近的 artifact
Ctrl+Rtranscript-search搜会话历史(FTS5,中文也能搜)
Ctrl+Lclear-screen清屏
Escinterrupt中断当前流式;空闲时清空输入与待发图片
Esc Escrewind上面那件事照做,外加打开回退面板
Ctrl+C ×2exit退出
Ctrl+Dexit-if-empty输入为空时退出
y / n—工具审批(弹窗自己的键,还没进键位层)

/keybindings(别名 /keys)印的是当前真正生效的那张表 + 每条的来源(默认 / 用户)+ 没生效的那几条各是什么理由。上面这张表是默认值,改过键之后它就不是你 机器上的实况了 —— 那时候要看的是那条命令。

Esc 不做退出:方向键和 IME 在部分终端会发 ESC 前缀,误退代价太大。弹窗开着时 Esc 一律让路给弹窗——同一个 Esc 被两边消费会变成「拒绝审批 + 顺手中断整轮」, 而中断会 break 掉生成器,正在等答复的引擎就永远挂着。

✅ 上表里有三个键曾经是失效的(方案 31 PR-1 查出来、那一轮刻意没修, 因为它要证明的恰好是「抽了一层但行为一个键都没变」):行尾 \ + Enter 的续行、 多行时 ↑↓ 的行间移动、以及翻输入历史。三者同一个根因:ink 7 把子组件里的 useInput 回调钉在首帧,而它们读的值每次渲染都重取。2026-08-13 一起修了 ——镜像进渲染期赋值的 ref(bufRef / historyRef,见上面「只准读 ref」那段), 回归用例在 input-box-first-frame.test.tsx: 那个文件的规矩是「可变值必须在挂载之后才变」,否则用例修不修都是绿的。

改键

~/.epoch/keybindings.json(只有用户级,没有项目级——键位是个人偏好, 而「项目能改键位」等于让一个 clone 下来的仓库改掉你的 Esc):

{
  "clear-screen": ["ctrl+k"],
  "newline": ["shift+enter", "alt+enter", "ctrl+j"],
  "history-prev": []
}
  • 键名逐字用上表里那些动作名;一个动作可以绑多个键
  • 写法是 ctrl+alt+shift+<键>,大小写和修饰键顺序都无所谓;具名键有 escape enter tab space backspace delete up down left right home end pageup pagedown(esc / del / pgup 这些短写法也认)
  • 空数组 = 解绑,默认键不会偷偷回来
  • 序列用空格分隔("rewind": ["escape escape"]),见下面「序列」一节

五条规矩,都会在启动诊断里出声(只出声,不拦启动),也都能在 /keybindings 里逐条查到(诊断超过 8 条会被折起来,那一屏不会):

情况结果
绑 ctrl+c / ctrl+d拒绝那一条——它们是卡住时的逃生通道,不许被锁死
两个动作抢同一条绑定先到先得,后面那条忽略;用户写的先到,所以重绑总能赢
键写错 / 动作名拼错那一条忽略,其余照常生效
序列绑在补全面板的动作那一条忽略(complete / complete-prev / complete-next / submit 那一侧还没接序列层,见下)
整个文件坏了回落到默认表,诊断里带文件路径,TUI 照常起

「两个动作抢同一个键」按同时活着的上下文判:global 和输入框那一层是同时在收 键的,所以把 clear-screen 绑到 ctrl+k 会把默认的 kill-line-end 挤掉;而 input 和 dialog 互斥(面板要么开着要么没开),所以 ↑ 同时是 history-prev 和 complete-prev 不算冲突——那正是「面板可见时 ↑↓ 选命令」的实现方式。

Shift+Enter 与 /terminal-setup

上表里 Shift+Enter 是 newline 的默认绑定之一,但它不是每个终端都发得出: 有的终端(Apple Terminal)在驱动层就把 Shift+Enter 和 Enter 看成一个键,有的 (iTerm2 / kitty / VS Code)发 ESC[13;2u 这种独立序列。两件事都有人管:

  • epoch doctor 只报告:在真终端里让你按一下 Shift+Enter,读这台终端此刻 发出来的字节(实测不是查表),报告发不发独立序列、替代键(Ctrl+J / Option+Enter)是什么。一个字都不写
  • /terminal-setup 负责改:--write 往你认识的四种终端(VS Code / Windows Terminal / iTerm2 / Apple Terminal)的配置文件里写一条 Shift+Enter 映射。 四条硬约束:改之前先备份(备份失败一个字节都不写)、裸命令只展示计划不动手、 --revert 逐字节还原、认不出的终端只给手动指南。Apple Terminal 无解 (驱动层合并两键),那条支路也只给建议。详见 docs/verify/VERIFY_RECORD-31-keybindings.md 第七节

序列

用空格分隔,一条绑定可以是好几下按键:

{ "rewind": ["escape escape"] }

三条硬规矩:

  1. 第一下立刻触发它自己那个动作,不等第二下。绑了 escape escape 之后单击 Esc 照样立即中断——「先等半秒看是不是序列」的代价是模型在那半秒里又调了一个工具。 实现因此是「先触发前缀动作,再在窗口内等后续键」,判据写在 keybindings/sequences.ts 的文件头, 反向用例在 keybindings-sequences.test.tsx
  2. 窗口是 500ms(SEQUENCE_WINDOW_MS)。超了就是两次独立的按键
  3. 全局动作和输入框动作都接得住序列(PR-3 给 input-box.tsx 接了第二台状态机)。 还没接的只剩补全面板那几个(complete / complete-prev / complete-next, 以及横跨两侧的 submit)—— 绑上去的会被拒绝 + 一条诊断,而不是静默不生效。 面板开着的时候用户正在选东西,把那几个键做成「按一下等半秒看有没有第二下」收益是负的

⚠️ 两个消费方各是一台状态机,不是一台服务两边。 一次按键会被 app.tsx 和 input-box.tsx 的两个 useInput 各收一次,共用一台等于把窗口推进两步 —— 具体的坏法是输入框那次 resolve(…, 'input') 上下文不匹配、把 global 刚武装好的 pending 清掉,于是 escape escape 再也开不出回退面板。判据写在 use-key-sequences.ts 的文件头第 2 条, 守卫用例是 keybindings-sequences.test.tsx 里那条「不许做成模块级单例」。

escape 和 escape escape 不冲突:一个是单键绑定、一个是序列,冲突检测按整条 绑定比。这正是「单击中断、双击开回退面板」的实现方式。

读盘 / 校验 / 冲突消解都不在这个包里(tui 只依赖 protocol,跑不了文件系统也没有 zod):在 core/src/config/keybindings.ts, 由 cli/src/tui-entry.ts 调,tui 收到的是一张已经校验过 的表。默认表反过来是 cli 从这个包递给 core 的——那张表的真源只该有一份。

Vim 模式

默认关。 打开之后范围只在输入框内 —— 不做 : 命令行、不做窗口分割、不做 VISUAL(方案 31 §2.6)。

类别支持的
模式NORMAL / INSERT,状态行画在输入行下面
移动h j k l w b e 0 ^ $ gg G f<char> t<char>
编辑x d{motion} c{motion} y{motion} p P u .
整行dd cc yy
进 INSERTi I a A o O
计数3w 2dd d3w(operator 前后两个计数相乘)

四条要知道的:

  1. 开着 vim 也从 INSERT 起步 —— 打开输入框就能打字。一进来是 NORMAL 的话,每次 启动的第一件事都是按 i,而绝大多数消息是一句话打完就发
  2. Esc 在 INSERT 里是回 NORMAL,在 NORMAL 里照旧中断本轮 / 清输入。后半句不是 疏漏,是硬约束:Esc 是卡住时的逃生通道,不许被 vim 吃掉(§4.3 #19)。这个二选一 在 app.tsx 的 interrupt 分支里做,输入框自己压根不碰 Esc —— 理由(两个 useInput 谁先跑取决于 effect 注册顺序)写在 vim/use-vim.ts 的文件头
  3. u 撤销以「一段输入」为单位,不是一个字符:进 INSERT 那一下压一张 buffer 快照,和 vim 一样
  4. . 只重复不进 INSERT 的那些编辑(x / d{motion} / dd / p / P)。 cw / i 那一类要连当时打的字一起重放,而那些字不经过状态机 —— 录了只会 「进 INSERT 但什么也不插」,比不支持更让人困惑

w / b / e 把标点当成自成一类(abc,def 上按 w 会先停在逗号上),cw 的行为是 ce(只改到词尾、不吃掉后面那个空格)—— 两条都是 vim 的语义,不是简化。 偏移量一律走 code point,所以 emoji / CJK 上 x 删的是一个字符而不是半个代理对。

开关是 App 的 vimMode prop(默认 false),宿主接线在 core/src/config/keybindings.ts + cli/src/tui-entry.ts。 怎么开:~/.epoch/keybindings.json 里写 "vimMode": true (见 CONFIGURATION.md 的「改键」一节)。

两个验证脚本,别混用

pnpm tui:probe                              # 布局门禁,四发全跑(zh / en × 带不带 vim)
pnpm --filter @epoch-agent/tui probe 120 40 # 指定终端尺寸
pnpm --filter @epoch-agent/tui probe --lang=en  # 只跑英文那一发
PROBE_APPROVAL=1 pnpm tui:probe             # 顺带压测审批弹窗撑高 footer
PROBE_QUESTION=1 pnpm tui:probe             # 同上,换成提问弹窗(更高:标题 + 副标题 + 5 行选项)

scripts/probe.mjs 是布局门禁:在真 pty(Windows ConPTY / macOS forkpty)里跑 probe.tsx——假 onRun、场景固定、不需要 API key——把输出重放成屏幕,判两个失败 信号:出现 ESC[2J 整屏清除,或屏幕上有连续重复行。

固定场景里那条补丁消息(2026-08-27 加的)多带一条判据:夹具造得比 MAX_RENDERED_LINES 高,于是被裁掉的那几行一帧都不该被写出去——probe.mjs 的 CLIPPED_MARKER 就是去 Ink 的原始字节里查它有没有露头。这条既盯硬裁本身, 也盯夹具别被改矮:门禁真红的时候把夹具调小到绿为止,等于让它从此假装盯过 这条路径。夹具住在 scripts/probe-diff-fixture.ts(单独一个文件是为了能被用例 import——pnpm probe 不在 pnpm check 里,兜底那份在 __tests__/probe-diff-fixture.test.ts)。

⚠️ 它判不了「排得齐不齐」。 整屏清除和重复行是两条灾难性判据:一列描述 整体右移三格、一句警告被 wrap="truncate" 切掉后半句、一个框的右边线逐行错开 一列——这三样在它眼里全是绿的(2026-08-25 逐条实测过)。那一格补在 __tests__/screen-metrics.test.tsx(M1~M7,pnpm check 里跑,不要 pty)。

pnpm tui:smoke                              # 端到端冒烟,默认 100x30
SMOKE_ENTRY=epoch pnpm tui:smoke            # 走 `pnpm epoch:dev`(用户的日常路径)
SMOKE_PROMPT='读一下 package.json' SMOKE_WAIT=120000 pnpm tui:smoke
EPOCH_LANGUAGE=en pnpm tui:smoke            # 英文界面那一档(human-eye §2.23 组 ② 要的就是它)

scripts/smoke.mjs 是另一件事:连真模型(要 API key,会计费)验 「装配 → 事件流 → usage 回填 → Ctrl+C 两段式退出」这条链没断。判据四条:场景标记、 状态栏的 ctx 真的非零(usage 回填了)、整屏清除次数、以及第一下 Ctrl+C 还活着、 第二下才退。中间那句「还活着」才是价值所在——只验「按了会退」的话,二次确认整个 失效(一按就退)也照样绿。

它不能用屏幕模型判重复行——真实会话超过一屏之后绝对行号就和终端对不上,必然报 假红;要判重复行去跑 probe。也不能断言模型回了什么:每次措辞都不一样,钉死一个 字符串是在钉模型而不是钉我们的链路。

⚠️ 场景标记是「一格一组候选,命中任一即可」,不是按语言选一组。 这个脚本跑的是 真入口,语言由 resolveLang() 那条三级链定(EPOCH_LANGUAGE > config.yaml 的 display.language > 系统 locale),脚本这一侧算不出来、也不该把那条链抄第二遍。 2026-08-25 之前那两串是写死的中文,于是 EPOCH_LANGUAGE=en 那一档必然假红 ——而那正是 human-eye §2.23 组 ② 唯一用到它的档位。

⚠️ ConPTY 的 attach 前导里自带一个 ESC[2J(ESC[?9001h ESC[?1004h ESC[?25l ESC[2J …,那时候 node 连起都没起)。probe 盯的是 Ink 的捕获文件,天然躲开; smoke 盯 pty 字节,所以它先剥掉开头那一段「一个可见字符都没有的 CSI」再数。 照单全收的话,Windows 上这条冒烟永远红在一个和我们无关的清屏上。

pnpm --filter @epoch-agent/tui screen <capture-file> [cols] [rows]

scripts/screen.mjs 是 probe 用的 ANSI 屏幕模型(带滚动区,这是它取代 replay.mjs 的唯一理由),也可以手工拿来看一份 capture。开 pty 和等一帧渲染完这 两件事由 scripts/pty-harness.mjs 共用。

开发

pnpm --filter @epoch-agent/tui test
pnpm tui        # 起 TUI(tsx 直跑 cli 的 tui-entry)

⚠️ pnpm tui 读的是本包的 dist/。 cli 是按 exports 解析 @epoch-agent/tui 的(没有 tsconfig paths 映射),改了这里的源码不 pnpm build 就去测,测的是上一次的产物——typecheck 也读 dist 的 .d.ts,两边一起骗你。 只调布局的话直接 pnpm tui:probe(它 import 的是 ../src,改完即刻生效)。

调试日志:设 EPOCH_DEBUG=1 后 debug.ts 会写 <系统临时目录>/epoch-debug.log。 默认关闭——它是同步写盘,挂在按键处理里会拖慢输入。用 tmpdir() 而不是字面量 /tmp:Windows 上 /tmp 会被 resolve 成通常不存在的 C:\tmp,于是日志功能等于 不存在,而且一声不响。

版本与发布记录

@epoch-agent/tui 共发布 57 个版本,首次发布于 2026/08/19;过去 12 个月发布了 57 个版本。

版本发布日期距今
6.2.0latest2026/11/13今天
6.1.02026/11/112 天前
6.0.02026/11/112 天前
5.0.02026/11/103 天前
4.1.12026/11/067 天前
4.1.02026/11/067 天前

关于 @epoch-agent/tui 的常见问题

@epoch-agent/tui 是什么?

epoch-agent Ink TUI 界面

如何安装 @epoch-agent/tui?

在项目目录运行 npm install @epoch-agent/tui,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:ink(^7.1.1)、react(^19.2.8)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@epoch-agent/tui 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@epoch-agent/tui 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@epoch-agent/tui 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@epoch-agent/tui 可以商用吗?

@epoch-agent/tui 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@epoch-agent/tui 的下载量有多少?

根据 npm 官方下载统计,@epoch-agent/tui 在 2026/10/14 – 2026/11/12 这 30 天内被下载 3,889 次,日均约 130 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@epoch-agent/tui 还在维护吗?

最新版本 6.2.0 发布于 2026/11/13(今天);至今共发布 57 个版本,其中过去 12 个月发布了 57 个;首次发布于 2026/08/19。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。