# @epoch-agent/tui 中文文档

规范地址：https://npmcn.com/package/%40epoch-agent/tui/

版本：6\.2\.0
npm 元数据更新时间：2026-11-13T08:28:15.234Z
中文译文更新时间：2026-11-13T19:51:26.782Z
软件包许可：Apache-2\.0

本文由上游 README 翻译，可能存在偏差，请结合原文核对 API、命令与配置。版权与许可归原作者所有。

原始 npm 页面：https://www.npmjs.com/package/%40epoch-agent/tui
原文对照：https://npmcn.com/package/%40epoch-agent/tui/?lang=original
项目主页：https://github.com/Ddbor/epoch-agent#readme
源码仓库：https://github.com/Ddbor/epoch-agent

## 关键信息（来自 npm 元数据）

- 包体积（解压后）：401 kB，5 个文件
- 模块格式：ES Module
- TypeScript 类型：包内自带
- Node.js 要求：\>=22\.0\.0
- 运行依赖：9 个
- peer 依赖：ink ^7\.1\.1，react ^19\.2\.8
- 首次发布：2026-08-19T10:32:03.329Z
- 最新版本发布：2026-11-13T08:28:15.010Z
- 近 30 天下载量：3889（2026-10-14 至 2026-11-12，npm 官方下载统计）
- 已发布版本数：57（近 12 个月 57 个）

## 中文简介

时代特务墨水TUI

## 安装

```sh
npm install @epoch-agent/tui
```

也可以使用：`pnpm add @epoch-agent/tui`、`yarn add @epoch-agent/tui`、`bun add @epoch-agent/tui`

## README

# @epoch-agent/tui

Ink 7 + React 19 的终端界面。

-   ✅ **做**：组件、布局、键位、Markdown 渲染、斜杠命令
-   ❌ **不做**：**不许 import [core](https://npmcn.com/package/%40epoch-agent/core)**（`check:layers` 会红）、不 spawn 子进程、 **不碰用户文件**（唯一的写盘是 `EPOCH_DEBUG` 打开的调试日志，`src/debug.ts` 往系统临时目录追加 —— 2026-11-03 把上一版那句笼统的「不碰文件系统」改准）。
-   **依赖**：[protocol](https://npmcn.com/package/%40epoch-agent/protocol) + [view](https://npmcn.com/package/%40epoch-agent/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 树跑起来。

## 挂载

```ts
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](https://npmcn.com/package/%40epoch-agent/protocol/src/i18n.ts)（`makeTranslate`）， web 也用同一份。⚠️ **别在模块级调 `t()`**：插槽是 `renderApp()` 装的， 而模块级常量在 import 那一刻就求值完了 —— 写法用函数或 getter， 门禁在 `__tests__/i18n-no-key-leak.test.ts`。

`onRun` 吐的是 [protocol](https://npmcn.com/package/%40epoch-agent/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，所以不开新组件**。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.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](https://npmcn.com/package/docs/GOALS.md)。

`/plugin`（别名 `/plugins`）只看三态和停用 / 启用，**装和卸不在这里** —— 装一个插件要过一道「它会带来什么」的确认（插件能带 hook），那道闸门只该有一份， 在 `epoch plugin install` 上。见 [docs/PLUGINS.md](https://npmcn.com/package/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`](https://npmcn.com/package/%40epoch-agent/protocol/src/commands.ts)。 后者有一条**双向**用例守着（`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](https://npmcn.com/package/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](https://npmcn.com/package/docs/verify/VERIFY_RECORD-26-model-switch.md) 第四节。

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

`/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](https://npmcn.com/package/docs/verify/VERIFY_RECORD-48-session-query.md) 的 `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](https://npmcn.com/package/%40epoch-agent/cli/src/statusline.ts)）， 不在这里。tui 只依赖 protocol，跑不了子进程；而那三条是一组约束，拆到两个包里 维护迟早有一半失效
-   **没配时宿主压根不注入 `readStatusLine`**，于是这里连计时器都不装、那一段整块不画。 不是给一个永远返回 `null` 的读取器让 UI 去判空
-   这里 2 秒问一次是「多久去看一眼有没有新值」，**不是**「多久跑一次那条命令」—— 后者由用户的 `statusLine.intervalMs` 决定，宿主在 `read()` 里判。和上面 ⚙ 那段 同一个形状（轮询 + 值没变就不 setState），刻意没合并：刷新频率的归属不同

## 计划审批框

`ApprovalRequest` 带 `plan` 字段时（方案 35），底部弹的是 [`PlanConfirmation`](https://npmcn.com/package/%40epoch-agent/tui/src/components/dialogs/plan-confirmation.tsx) 而不是 `ToolConfirmation`。拆成两个组件是因为两者问的问题根本不同：工具审批的四个选项 讲的是**授权范围**（一次 / 本会话 / 永久 / 拒），计划审批的四个讲的是 **接下来怎么走**（批准并执行 / 批准但保持只读 / 让我改 / 拒绝）。

两条是安全性质，不是外观：

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

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

> ⚠️ 那一行输入的真值**住在 ref 里**，state 只用于渲染 —— ink 7 在子组件里注册的 `useInput` 回调被钉在首帧，普通 state 跨不过这个边界。这不是风格问题， 见上面「布局约束」和 [第四批收尾验收记录第三节](https://npmcn.com/package/docs/verify/VERIFY_RECORD-batch4-closeout.md)。

### 批准之后留一张卡片

审批框是一次性的：点完「批准并执行」，接下来十分钟的纲就从屏幕上没了。所以批准的 那一刻往历史里落一条 [`PlanMessage`](https://npmcn.com/package/%40epoch-agent/tui/src/components/messages/plan-message.tsx)， 写清楚是「批准并执行（权限回到 X）」还是「保持只读」—— 这两者的差别是 「agent 现在能不能动我的文件」，两分钟后用户就记不住自己点的是哪个了。 拒绝**不落卡片**：把一份作废的计划留在屏幕上是这里能犯的最坏的错。

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

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

## 回退面板（`/rewind` 和 `Esc Esc`）

[`RewindPicker`](https://npmcn.com/package/%40epoch-agent/tui/src/components/dialogs/rewind-picker.tsx)，语义全在 [docs/CHECKPOINTS.md](https://npmcn.com/package/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`](https://npmcn.com/package/%40epoch-agent/tui/src/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](https://npmcn.com/package/%40epoch-agent/tui/__tests__/input-box-first-frame.test.tsx)： 那个文件的规矩是「可变值必须在挂载之后才变」，否则用例修不修都是绿的。

### 改键

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

```json
{
  "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](https://npmcn.com/package/docs/verify/VERIFY_RECORD-31-keybindings.md) 第七节

### 序列

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

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

三条硬规矩：

1.  **第一下立刻触发它自己那个动作**，不等第二下。绑了 `escape escape` 之后单击 Esc **照样立即中断**——「先等半秒看是不是序列」的代价是模型在那半秒里又调了一个工具。 实现因此是「先触发前缀动作，再在窗口内等后续键」，判据写在 [keybindings/sequences.ts](https://npmcn.com/package/%40epoch-agent/tui/src/keybindings/sequences.ts) 的文件头， 反向用例在 [keybindings-sequences.test.tsx](https://npmcn.com/package/%40epoch-agent/tui/__tests__/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](https://npmcn.com/package/%40epoch-agent/tui/src/keybindings/use-key-sequences.ts) 的文件头第 2 条， 守卫用例是 `keybindings-sequences.test.tsx` 里那条「不许做成模块级单例」。

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

读盘 / 校验 / 冲突消解**都不在这个包里**（tui 只依赖 protocol，跑不了文件系统也没有 zod）：在 [core/src/config/keybindings.ts](https://npmcn.com/package/%40epoch-agent/core/src/config/keybindings.ts)， 由 [cli/src/tui-entry.ts](https://npmcn.com/package/%40epoch-agent/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](https://npmcn.com/package/%40epoch-agent/tui/src/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](https://npmcn.com/package/docs/CONFIGURATION.md) 的「改键」一节）。

## 两个验证脚本，别混用

```bash
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）。

```bash
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 上这条冒烟永远红在一个和我们无关的清屏上。

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

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

## 开发

```bash
pnpm --filter @epoch-agent/tui test
pnpm tui        # 起 TUI（tsx 直跑 cli 的 tui-entry）
```

> ⚠️ **`pnpm tui` 读的是本包的 `dist/`。** [cli](https://npmcn.com/package/%40epoch-agent/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 是什么？

时代特务墨水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 和提交记录判断。

