npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 重装)—— 不重放的话, 屏幕上留着几条模型已经看不见的消息,而你会照着它们追问,然后收到一句对不上的回答。

⚠️ 面板的 useInput isActive 不跟着步骤走,分步是在回调里判的。 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+<键>,大小写和修饰键顺序都无所谓;具名键有 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 | | 进 INSERT | i 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,于是日志功能等于 不存在,而且一声不响。