@epoch-agent/tui
v0.22.0
Published
epoch-agent Ink TUI 界面
Readme
@epoch-agent/tui
Ink 7 + React 19 的终端界面。
- ✅ 做:组件、布局、键位、Markdown 渲染、斜杠命令
- ❌ 不做:不许 import core(
check:layers会红)、不 spawn 子进程、 不碰文件系统。折叠逻辑也不在这里,在 view(与 web 共用同一份) - 依赖: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.tsx | renderApp()——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,所以一行分支都不开(选项是必填的,照常答得动;判据在 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.ts | debug 日志 |
布局约束(改这里之前先读)
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 gpt-4o 同一家换个模型
/model anthropic/claude-opus-4-6 provider 和模型一起换
/model --reset 回到配置里那个换模型不清空上下文——那正是它的用处(对着同一段对话换个更强或更便宜的脑子)。 代价是新模型得接得住已经积累的东西,所以有三道前置检查,任何一道不过就一个字段都不动:
| 拦住的情况 | 为什么 |
| ------------------------ | ------------------------------------------------------- |
| 目标 provider 没凭据 | 请求根本发不出去,理由里点名该设哪个环境变量 |
| 会话里有图片,新模型不认 | 下一轮把带图历史发出去必然 400,而 400 是已经计费的 |
| 已用上下文超过新窗口 | 同上。先 /clear 或换个大窗口的模型 |
装得下但已经很挤、或者新模型不支持工具调用,则是换成 + 出声(warning),不拦。
判据全在 core 的 evaluateSelection,TUI 只负责把结果念出来 —— 拒绝的理由逐字透传,
不在 TUI 里重编一句。provider/model 的解析走 protocol 的 parseModelRef,
所以 meta-llama/Llama-3.3-70B-Instruct-Turbo 这种模型名里自带斜杠的能正确识别。
⚠️ 已知边界(PR-2 复核后仍在):自动压缩的预算仍是启动时那个模型的窗口, 换到小窗口模型后压缩不会跟着提前触发 —— 这正是上面第三道检查直接拒绝而不是 「换了再说」的原因。要松成「换了就立刻压一次」得改
AgentConfig.contextLength的形状并动loop.ts,而那两处是方案 26 明列的禁止触碰项,留给后续方案。 另外/model目前不带交互式选择器,得自己打模型名 —— 这两条都已结案, 判据见 VERIFY_RECORD-26-model-switch 第四节。
换过之后:这一轮用哪个、以及模型挂了怎么办
/model 换的是会话级选择,但有两种情况会临时或永久地偏离它:
- 命令 frontmatter 的
model:—— 一条自定义命令可以声明自己用哪个模型 (model: utility解析成配置里的工具模型),只在那一轮生效,收尾无条件还原。 Esc 中断走的是生成器的return(),还原照样发生 - 降级 —— 主模型报 404 / 模型不可用时,引擎按
「
--fallback-model(同 provider)→ 内置默认 → 换 provider」的顺序往下试。 下一轮又会从你选的那个模型重新起步:降级是这一次请求的事,不改你的选择
同一个模型连续三次把请求拖垮,本会话就不再自动用它了,并在那一轮结束时给一条
提示 —— toast 加一条历史项,两处一起给:toast 抓得住正盯着屏幕的人,历史项留得住
几分钟后才回来看的人。而这句话说的是「你的账单和能力已经不是你选的那个模型了」。
重新 /model 选回它就清账,那是明确的「我知道,再试一次」。
@ 补全:文件和会话两组
打 @ 弹补全面板,分两组(方案 53 §1.3):
┌ 文件 ────────────────────────────
│ packages/core/src/agent/loop.ts
├ 会话 ────────────────────────────
│ 接上后台任务的输出 12/03 · 42 条 · 本工作区
└──────────────────────────────────- 文件组:子序列模糊匹配(
@c/a/loop命中packages/core/src/agent/loop.ts), Tab / Enter 补全路径,提交时文件内容随消息一起发出去,省掉 「模型调file_read→ 再等一轮」。文件组排前面,因为它是高频的那个 - 会话组:
@:只显示这一组。选中补进去的是 sessionId 而不是标题 (标题里几乎一定有空白,而提及的抽取规则遇到空白就断),提交时那段会话的 当前面(模型当时还看得见的那些)作为一个独立部件发出去
⚠️ 会话组只按 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 修掉):| 读了什么 | 症状 | | ----------------- | ----------------------------------------------------------------- | |
pendingImages| Ctrl+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 重装)—— 不重放的话,
屏幕上留着几条模型已经看不见的消息,而你会照着它们追问,然后收到一句对不上的回答。
⚠️ 面板的
useInputisActive不跟着步骤走,分步是在回调里判的。 ink 的useInput在isActive变 true 的那次 effect 里才挂上监听,而 effect 跑在 渲染提交之后 —— 中间那一小段时间里屏幕上已经画出了列表、按下去的键却没人接。 症状是用例随机红一条「等不到下一步」,真机上是「面板刚跳出来那一下敲的回车丢了」。
快捷键
下面是默认值,不是硬编码:每一行左边那个键对应右边那个动作,而动作和键的
对应关系可以改(见下一节「改键」)。默认表的真源是
keybindings/defaults.ts,它就是方案 31 之前散在
app.tsx / input-box.tsx 里的那些 if。
| 键 | 动作 | 操作 |
| ------------------------------ | ------------------------ | -------------------------------------- |
| Enter | submit | 发送 |
| Option+Enter / Shift+Enter | newline | 换行 |
| Ctrl+J | newline | 换行 |
| 行尾 \ + Enter | — | 换行(吃掉那个 \,不发送) |
| / | — | 打开命令面板(打字自然触发,不是绑定) |
| Tab | complete | 补全选中的命令 |
| ↑ / ↓ | history-prev/next | 行间移动;到边界则翻输入历史 |
| ↑ / ↓(面板开着时) | complete-prev/next | 在补全面板里选 |
| ← / → | cursor-left/right | 光标移动(跨行) |
| Home / Ctrl+A | line-start | 行首 |
| End / Ctrl+E | line-end | 行尾 |
| Ctrl+W | kill-word | 删词 |
| Ctrl+U / Ctrl+K | kill-line-start/end | 删到行首 / 删到行尾 |
| Backspace / Delete | delete-char-left/right | 删字符 |
| Ctrl+V | paste-image | 粘贴剪贴板里的图片 |
| Ctrl+O | open-artifact | 用系统查看器打开最近的 artifact |
| Ctrl+R | transcript-search | 搜会话历史(FTS5,中文也能搜) |
| Ctrl+L | clear-screen | 清屏 |
| Esc | interrupt | 中断当前流式;空闲时清空输入与待发图片 |
| Esc Esc | rewind | 上面那件事照做,外加打开回退面板 |
| Ctrl+C ×2 | exit | 退出 |
| Ctrl+D | exit-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+<键>,大小写和修饰键顺序都无所谓;具名键有escapeentertabspacebackspacedeleteupdownleftrighthomeendpageuppagedown(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"] }三条硬规矩:
- 第一下立刻触发它自己那个动作,不等第二下。绑了
escape escape之后单击 Esc 照样立即中断——「先等半秒看是不是序列」的代价是模型在那半秒里又调了一个工具。 实现因此是「先触发前缀动作,再在窗口内等后续键」,判据写在 keybindings/sequences.ts 的文件头, 反向用例在 keybindings-sequences.test.tsx - 窗口是 500ms(
SEQUENCE_WINDOW_MS)。超了就是两次独立的按键 - 全局动作和输入框动作都接得住序列(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 |
| 进 INSERT | i I a A o O |
| 计数 | 3w 2dd d3w(operator 前后两个计数相乘) |
四条要知道的:
- 开着 vim 也从 INSERT 起步 —— 打开输入框就能打字。一进来是 NORMAL 的话,每次
启动的第一件事都是按
i,而绝大多数消息是一句话打完就发 - Esc 在 INSERT 里是回 NORMAL,在 NORMAL 里照旧中断本轮 / 清输入。后半句不是
疏漏,是硬约束:Esc 是卡住时的逃生通道,不许被 vim 吃掉(§4.3 #19)。这个二选一
在
app.tsx的interrupt分支里做,输入框自己压根不碰 Esc —— 理由(两个useInput谁先跑取决于 effect 注册顺序)写在 vim/use-vim.ts 的文件头 u撤销以「一段输入」为单位,不是一个字符:进 INSERT 那一下压一张 buffer 快照,和 vim 一样.只重复不进 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的vimModeprop(默认 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,于是日志功能等于
不存在,而且一声不响。
