@epoch-agent/tui 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +222 -47
  2. package/dist/index.d.ts +473 -16
  3. package/dist/index.js +2178 -1024
  4. package/package.json +6 -5
package/README.md CHANGED
@@ -8,6 +8,16 @@ Ink 7 + React 19 的终端界面。
8
8
  - **依赖**:[protocol](../protocol) + [view](../view) + `chalk` / `lowlight` /
9
9
  `string-width` 等纯渲染库。`ink` 和 `react` 是 **peer**
10
10
 
11
+ > ⚠️ **`string-width` 的大版本必须和 `ink` 依赖的那个对齐**(今天两边都是 8)。
12
+ > 一个进程里两张宽度表,在**歧义宽度**的字符上会给出不同答案(`⚠` / `ℹ` / `🖼`:
13
+ > 7 说两列、8 说一列),于是 ink 按一列排版、我们按两列算光标和填充——同一个框里
14
+ > 以 `⚠` 开头的行会比别的行宽出一列,右边线逐行错开。2026-08-25 实测到过,
15
+ > 门禁在 `__tests__/screen-metrics.test.tsx` 的 M1。
16
+ >
17
+ > 同一条判据的另一半:**对齐一律走 `text/utils.ts` 的 `padEndByWidth` /
18
+ > `truncateByWidth`,不许再写 `String.prototype.padEnd`**(它数的是码元,
19
+ > 中文一个字 1 个码元 2 列)。扫源码的那条在同一份用例的 M7。
20
+
11
21
  引擎能力一律由宿主经 `renderApp({ host })` 注入——切权限级别、列工具、读启动诊断、
12
22
  打开 artifact、读剪贴板图片。这样每个斜杠命令都能对着一个假 context 单测,不用把整棵
13
23
  Ink 树跑起来。
@@ -24,12 +34,30 @@ const instance = renderApp({
24
34
  usageScope: runtime.usageScope, // usage 事件里 cumulative 的口径
25
35
  welcomeMessage: '…',
26
36
  startupNotices: diagnostics, // 配置写错了必须看得见
37
+ translate: (key, vars) => t(key, vars), // **必填** —— infra 那份 t(),见下
27
38
  onRun: (message, { signal }) => session.run(message, { signal }), // AsyncGenerator<AgentEvent>
28
39
  onExit: () => instance.unmount(),
29
40
  host: {/* HostActions,全部可选:缺哪个对应命令就报「不可用」而不是崩 */},
30
41
  });
31
42
  ```
32
43
 
44
+ ### `translate` 是必填的(方案 40 PR-2)
45
+
46
+ 界面文案住在仓库根的 `locales/{zh,en}.yaml`,而**加载**那一半(找目录、读 yaml、
47
+ `setLang` / `resolveLang`)住在 `@epoch-agent/infra` —— 这个包够不着它
48
+ (`check-layers` 里 tui 只有 protocol + view)。所以 catalog 由**宿主**读盘之后把
49
+ 绑好的查表函数递进来,形状和 `HostActions` 是同一条判据:
50
+ 「能力只能从宿主这一侧递进去」。
51
+
52
+ **刻意是必填而不是可选**:可选的话「宿主忘了传」退化成满屏 `tui.help.available`
53
+ 这样的 key 路径,而那是运行时才看得见的事;必填让它在编译期就红。
54
+ (key 路径本身**就是**回落链的第三层,所以那个失败形态很响 —— 但它不该发生。)
55
+
56
+ 查表本体在 [protocol/src/i18n.ts](../protocol/src/i18n.ts)(`makeTranslate`),
57
+ web 也用同一份。⚠️ **别在模块级调 `t()`**:插槽是 `renderApp()` 装的,
58
+ 而模块级常量在 import 那一刻就求值完了 —— 写法用函数或 getter,
59
+ 门禁在 `__tests__/i18n-no-key-leak.test.ts`。
60
+
33
61
  `onRun` 吐的是 [protocol](../protocol) 的 `AgentEvent`——**不是** tui 自己的类型。
34
62
  入参是 `EpochUserContent`(`string | 部件数组`)而不是 `string`:Ctrl+V 粘进来的图片
35
63
  必须跟着这一次提交走到引擎。
@@ -40,23 +68,24 @@ const instance = renderApp({
40
68
 
41
69
  ## 结构
42
70
 
43
- | 目录 / 文件 | 内容 |
44
- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
45
- | `app.tsx` | 根组件:`<Static>` 历史 + 限高 pending + 底栏 + 全局键位 |
46
- | `render.tsx` | `renderApp()`——provider 树 + ink render options |
47
- | `contexts/` | app / config / session / streaming / ui-state 五个 context |
48
- | `components/` | banner、history-item、input-box、status-bar、suggestions、toast、thinking-indicator、shortcuts-help |
49
- | `components/dialogs/` | tool-confirmation(审批)、plan-confirmation、command-palette、file-palette、search-palette(Ctrl+R)、rewind-picker(Esc Esc)、picker(单选 / 多选 / 自由输入)、select-list |
50
- | `components/messages/` | 按类型分发的消息渲染:user / model / diff / error / warning / info / hint |
51
- | `commands/` | 斜杠命令:`builtin.ts` / `session.ts` / `inspect.ts` / `workspace.ts` / `rewind.ts` / `plugins.ts` + `parse.ts` + `prefix.ts` + `types.ts`(`HostActions` 契约) |
52
- | `hooks/` | `use-agent-run.ts`(跑一轮 + 中断)、`use-slash-commands.ts`、`use-rewind.ts`(面板 + Esc Esc 窗口)、`use-question.ts`(结构化提问逐个走完) |
53
- | `keybindings/` | 键位层(方案 31):`actions.ts` / `parser.ts`(ink 的 Key → chord)/ `defaults.ts`(默认表 = 原来的硬编码)/ `resolver.ts` / `use-keybinding.ts` |
54
- | `streaming/` | 工具调用与 artifact 的展示格式化 |
55
- | `text/` | `ops.ts` 多行 buffer 的纯状态迁移 + `use-text-buffer.ts` React 外壳 |
56
- | `markdown/` | Markdown 解析、行内样式、代码高亮(lowlight) |
57
- | `theme/` | 语义色 + 颜色工具 |
58
- | `types/` | 消息 / 状态类型、`checkpoint-types.ts`(core 那几个回退类型的结构性镜像)、`host-info.ts`(宿主算好的展示形状) |
59
- | `debug.ts` | debug 日志 |
71
+ | 目录 / 文件 | 内容 |
72
+ | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
73
+ | `app.tsx` | 根组件:`<Static>` 历史 + 限高 pending + 底栏 + 全局键位 |
74
+ | `render.tsx` | `renderApp()`——provider 树 + ink render options |
75
+ | `contexts/` | app / config / session / streaming / ui-state 五个 context |
76
+ | `components/` | banner、history-item、input-box、status-bar、suggestions、toast、thinking-indicator、shortcuts-help |
77
+ | `components/dialogs/` | tool-confirmation(审批)、plan-confirmation、command-palette、file-palette、search-palette(Ctrl+R)、rewind-picker(Esc Esc)、picker(单选 / 多选 / 自由输入)、select-list |
78
+ | `components/messages/` | 按类型分发的消息渲染:user / model / diff / error / warning / info / hint |
79
+ | `commands/` | 斜杠命令:`builtin.ts` / `session.ts` / `inspect.ts` / `workspace.ts` / `rewind.ts` / `plugins.ts` + `parse.ts` + `prefix.ts` + `types.ts`(`HostActions` 契约) |
80
+ | `hooks/` | `use-agent-run.ts`(跑一轮 + 中断)、`use-slash-commands.ts`、`use-rewind.ts`(面板 + Esc Esc 窗口)、`use-question.ts`(结构化提问逐个走完) |
81
+ | `keybindings/` | 键位层(方案 31):`actions.ts` / `parser.ts`(ink 的 Key → chord)/ `defaults.ts`(默认表 = 原来的硬编码)/ `resolver.ts` / `use-keybinding.ts` / `sequences.ts` + `use-key-sequences.ts`(序列) |
82
+ | `vim/` | Vim 模式(方案 31 PR-3):`motions.ts`(`w` / `$` / `f,` 落在哪)+ `machine.ts`(`(state, key) → (state, edits)` 纯状态机)+ `use-vim.ts`(React 外壳,只存 ref / 喂渲染) |
83
+ | `streaming/` | 工具调用与 artifact 的展示格式化 |
84
+ | `text/` | `ops.ts` 多行 buffer 的纯状态迁移 + `use-text-buffer.ts` React 外壳 |
85
+ | `markdown/` | Markdown 解析、行内样式、代码高亮(lowlight) |
86
+ | `theme/` | 语义色 + 颜色工具 |
87
+ | `types/` | 消息 / 状态类型、`checkpoint-types.ts`(core 那几个回退类型的结构性镜像)、`host-info.ts`(宿主算好的展示形状) |
88
+ | `debug.ts` | debug 日志 |
60
89
 
61
90
  ## 布局约束(改这里之前先读)
62
91
 
@@ -89,16 +118,26 @@ Ink 的 `log-update` **只能擦除它上一帧写过的行数**。一旦动态
89
118
  | 引擎里有什么 | `/model` `/permission` `/permissions` `/plan` `/goal` `/tools` `/tasks` `/diagnostics` `/skills` `/agents` `/mcp` `/memory` `/plugin` |
90
119
  | 和仓库打交道 | `/diff` `/copy` |
91
120
  | 退回去 | `/rewind`(同 Esc Esc,见下面「回退面板」) |
92
-
93
- 实现分七个文件:`builtin.ts`(前十二条 + 注册表合并)、`session.ts`、`inspect.ts`、
94
- `workspace.ts`、`rewind.ts`、`plugins.ts`、`goal.ts`。拆开纯粹是因为 500 行硬线,
95
- 分组按用途而不是按「新旧」。
96
-
97
- `/goal`(方案 52)是这张表里唯一**措辞不在 TUI 里**的一条:它每个子命令回的都是
98
- 宿主已经渲染好的一句话(`HostActions.goals`),因为 tui 够不着 `t()`,
99
- 而这一片是新增的用户可见文案 —— 硬编码进来就是给 i18n 那条棘轮添债。
100
- 判据写在 `commands/types.ts` 的 `HostActions.goals` 上。用法见
101
- [docs/GOALS.md](../../docs/GOALS.md)。
121
+ | 键位 | `/keybindings`(别名 `/keys`,见下面「快捷键」) |
122
+ | 终端 | `/terminal-setup [--write|--revert]`(Shift+Enter 配置向导,宿主注入,见下面「快捷键」) |
123
+
124
+ 实现分九个文件:`builtin.ts`(前十二条 + 注册表合并)、`session.ts`、`inspect.ts`、
125
+ `workspace.ts`、`rewind.ts`、`plugins.ts`、`goal.ts`、`keybindings.ts`、
126
+ `terminal-setup.ts`。拆开纯粹是因为 500 行硬线,分组按用途而不是按「新旧」。
127
+
128
+ `/goal`(方案 52)、`/keybindings`(方案 31)和 `/terminal-setup`(方案 31)是这张
129
+ 表里**措辞不在 TUI 里**的三条:它们回的都是宿主已经渲染好的文本
130
+ (`HostActions.goals` / `HostActions.keybindings` / `HostActions.terminalSetup`)。
131
+
132
+ > ⚠️ **这三条原来的理由是「因为 tui 够不着 `t()`」,那句话 2026-08-22 起不成立了**
133
+ > (方案 40 PR-2 把查表函数从宿主递进来了,见上面「挂载」那一节)。理由换成现在这个:
134
+ > 那三片文本**本来就是宿主侧的事实**(目标是引擎算的、键位来源只有加载期知道、
135
+ > 终端识别读的是 `process.env`),在 TUI 里重新渲染一遍等于把同一份判断抄两处。
136
+ > `/keybindings` 那条第二理由照旧:「这条键是谁定的」只有加载期知道。
137
+ > `/terminal-setup` 还要**无条件注册**:识别只读 `process.env`、不依赖 runtime 任何
138
+ > 子系统,认不出终端时那条命令照样在,进去走「只给建议」那一支。判据写在
139
+ > `commands/types.ts` 的那两个字段上。用法见
140
+ > [docs/GOALS.md](../../docs/GOALS.md)。
102
141
 
103
142
  `/plugin`(别名 `/plugins`)只看三态和停用 / 启用,**装和卸不在这里** ——
104
143
  装一个插件要过一道「它会带来什么」的确认(插件能带 hook),那道闸门只该有一份,
@@ -211,19 +250,42 @@ Ink 的 `log-update` **只能擦除它上一帧写过的行数**。一旦动态
211
250
  几分钟后才回来看的人。而这句话说的是「你的账单和能力已经不是你选的那个模型了」。
212
251
  重新 `/model` 选回它就清账,那是明确的「我知道,再试一次」。
213
252
 
214
- ## `@` 文件补全
253
+ ## `@` 补全:文件和会话两组
215
254
 
216
- 打 `@` 弹文件面板(子序列模糊匹配,`@c/a/loop` 命中
217
- `packages/core/src/agent/loop.ts`),Tab / Enter 补全路径,提交时**文件内容随消息
218
- 一起发出去**,省掉「模型调 file_read → 再等一轮」。
255
+ 打 `@` 弹补全面板,**分两组**(方案 53 §1.3):
219
256
 
220
- 三件事不在这个包里,都是刻意的:
257
+ ```
258
+ ┌ 文件 ────────────────────────────
259
+ │ packages/core/src/agent/loop.ts
260
+ ├ 会话 ────────────────────────────
261
+ │ 接上后台任务的输出 12/03 · 42 条 · 本工作区
262
+ └──────────────────────────────────
263
+ ```
221
264
 
222
- | 事情 | 在哪 | 为什么 |
223
- | -------------- | ---------------------------------------- | ------------------------------------------------- |
224
- | 候选清单 | 宿主 `host.listWorkspaceFiles()` | spawn `git ls-files`,tui 只依赖 protocol |
225
- | `@` 的抽取规则 | `@epoch-agent/protocol` `mentions.ts` | 引擎那侧要用同一份,两边写岔了会**静默错位** |
226
- | 读文件 + 权限 | 宿主 `host.resolveMentions()` | 要过工作区边界和 `file_read` 的权限判定,都在引擎 |
265
+ - **文件组**:子序列模糊匹配(`@c/a/loop` 命中 `packages/core/src/agent/loop.ts`),
266
+ Tab / Enter 补全路径,提交时**文件内容随消息一起发出去**,省掉
267
+ 「模型调 `file_read` 再等一轮」。文件组排前面,因为它是高频的那个
268
+ - **会话组**:`@:` 只显示这一组。选中补进去的是 **sessionId** 而不是标题
269
+ (标题里几乎一定有空白,而提及的抽取规则遇到空白就断),提交时那段会话的
270
+ **当前面**(模型当时还看得见的那些)作为一个独立部件发出去
271
+
272
+ > ⚠️ **会话组只按 sessionId / cwd / 标题匹配,永远不搜转录文本**(方案 53 §5.2)。
273
+ > 这是**安全边界不是性能考虑**:面板边打边显示,能搜正文的话打 `@:密码` 就会在
274
+ > 屏幕上列出所有提到过密码的会话 —— 而这块屏幕可能正被别人看着(结对、录屏、演示)。
275
+ > 落地上它是**结构性**的:`SessionCandidateView` 里压根没有转录文本字段,
276
+ > 所以这个包里再怎么改过滤也搜不出正文。想搜正文的路是
277
+ > [方案 48](../../docs/verify/VERIFY_RECORD-48-session-query.md) 的 `session_search`
278
+ > —— 那是模型调的、结果进上下文而不是进屏幕。守卫见
279
+ > `completion-sessions.test.ts` 和 `core/__tests__/session-reference-sites.test.ts`。
280
+
281
+ 四件事不在这个包里,都是刻意的:
282
+
283
+ | 事情 | 在哪 | 为什么 |
284
+ | -------------- | ---------------------------------------- | ----------------------------------------------------- |
285
+ | 文件候选清单 | 宿主 `host.listWorkspaceFiles()` | 要 spawn `git ls-files`,tui 只依赖 protocol |
286
+ | 会话候选清单 | 宿主 `host.listSessionCandidates()` | 要过会话可读性判定、要查 SQLite,两样都在引擎 |
287
+ | `@` 的抽取规则 | `@epoch-agent/protocol` 的 `mentions.ts` | 引擎那侧要用同一份,两边写岔了会**静默错位** |
288
+ | 读内容 + 判定 | 宿主 `host.resolveMentions()` | 文件过工作区边界 + `file_read` 权限,会话过可读性判定 |
227
289
 
228
290
  > ⚠️ **`input-box.tsx` 的键盘回调只准读 ref。** ink 7 把子组件里注册的 `useInput`
229
291
  > 回调钉在首帧,于是组件**画得出来**(渲染是新鲜的)但按键读到的是首帧的值。
@@ -391,11 +453,15 @@ effect),没造第二套。窗口取 500ms 而不是 Ctrl+C 的 2000ms:那
391
453
  | `Ctrl+R` | `transcript-search` | 搜会话历史(FTS5,中文也能搜) |
392
454
  | `Ctrl+L` | `clear-screen` | 清屏 |
393
455
  | `Esc` | `interrupt` | 中断当前流式;空闲时清空输入与待发图片 |
394
- | `Esc` ×2 | (双击窗口) | 上面那件事照做,外加打开回退面板 |
456
+ | `Esc` `Esc` | `rewind` | 上面那件事照做,外加打开回退面板 |
395
457
  | `Ctrl+C` ×2 | `exit` | 退出 |
396
458
  | `Ctrl+D` | `exit-if-empty` | 输入为空时退出 |
397
459
  | `y` / `n` | — | 工具审批(弹窗自己的键,还没进键位层) |
398
460
 
461
+ `/keybindings`(别名 `/keys`)印的是**当前真正生效**的那张表 + 每条的来源(默认 /
462
+ 用户)+ 没生效的那几条各是什么理由。上面这张表是**默认值**,改过键之后它就不是你
463
+ 机器上的实况了 —— 那时候要看的是那条命令。
464
+
399
465
  `Esc` 不做退出:方向键和 IME 在部分终端会发 ESC 前缀,误退代价太大。弹窗开着时
400
466
  `Esc` 一律让路给弹窗——同一个 Esc 被两边消费会变成「拒绝审批 + 顺手中断整轮」,
401
467
  而中断会 break 掉生成器,正在等答复的引擎就永远挂着。
@@ -426,32 +492,121 @@ effect),没造第二套。窗口取 500ms 而不是 Ctrl+C 的 2000ms:那
426
492
  `escape` `enter` `tab` `space` `backspace` `delete` `up` `down` `left` `right`
427
493
  `home` `end` `pageup` `pagedown`(`esc` / `del` / `pgup` 这些短写法也认)
428
494
  - **空数组 = 解绑**,默认键不会偷偷回来
429
- - 序列(`"escape escape"`)**还没做**,写了会报一条「不是一个能识别的按键」
495
+ - **序列**用空格分隔(`"rewind": ["escape escape"]`),见下面「序列」一节
430
496
 
431
- 四条规矩,都会在启动诊断里出声(**只出声,不拦启动**):
497
+ 五条规矩,都会在启动诊断里出声(**只出声,不拦启动**),也都能在 `/keybindings`
498
+ 里逐条查到(诊断超过 8 条会被折起来,那一屏不会):
432
499
 
433
- | 情况 | 结果 |
434
- | ---------------------- | -------------------------------------------------------- |
435
- | 绑 `ctrl+c` / `ctrl+d` | **拒绝那一条**——它们是卡住时的逃生通道,不许被锁死 |
436
- | 两个动作抢同一个键 | 先到先得,后面那条忽略;**用户写的先到**,所以重绑总能赢 |
437
- | 键写错 / 动作名拼错 | 那一条忽略,其余照常生效 |
438
- | 整个文件坏了 | 回落到默认表,诊断里带文件路径,TUI 照常起 |
500
+ | 情况 | 结果 |
501
+ | ---------------------- | ------------------------------------------------------------------------------------------------ |
502
+ | 绑 `ctrl+c` / `ctrl+d` | **拒绝那一条**——它们是卡住时的逃生通道,不许被锁死 |
503
+ | 两个动作抢同一条绑定 | 先到先得,后面那条忽略;**用户写的先到**,所以重绑总能赢 |
504
+ | 键写错 / 动作名拼错 | 那一条忽略,其余照常生效 |
505
+ | 序列绑在补全面板的动作 | 那一条忽略(`complete` / `complete-prev` / `complete-next` / `submit` 那一侧还没接序列层,见下) |
506
+ | 整个文件坏了 | 回落到默认表,诊断里带文件路径,TUI 照常起 |
439
507
 
440
508
  「两个动作抢同一个键」按**同时活着的上下文**判:`global` 和输入框那一层是同时在收
441
509
  键的,所以把 `clear-screen` 绑到 `ctrl+k` 会把默认的 `kill-line-end` 挤掉;而
442
510
  `input` 和 `dialog` 互斥(面板要么开着要么没开),所以 ↑ 同时是 `history-prev` 和
443
511
  `complete-prev` 不算冲突——那正是「面板可见时 ↑↓ 选命令」的实现方式。
444
512
 
513
+ ### Shift+Enter 与 `/terminal-setup`
514
+
515
+ 上表里 `Shift+Enter` 是 `newline` 的默认绑定之一,但它**不是每个终端都发得出**:
516
+ 有的终端(Apple Terminal)在驱动层就把 Shift+Enter 和 Enter 看成一个键,有的
517
+ (iTerm2 / kitty / VS Code)发 `ESC[13;2u` 这种独立序列。两件事都有人管:
518
+
519
+ - **`epoch doctor` 只报告**:在真终端里让你按一下 Shift+Enter,读这台终端此刻
520
+ 发出来的字节(实测不是查表),报告发不发独立序列、替代键(`Ctrl+J` /
521
+ `Option+Enter`)是什么。**一个字都不写**
522
+ - **`/terminal-setup` 负责改**:`--write` 往**你认识的四种终端**(VS Code /
523
+ Windows Terminal / iTerm2 / Apple Terminal)的配置文件里写一条 Shift+Enter 映射。
524
+ 四条硬约束:改之前先备份(备份失败一个字节都不写)、裸命令只展示计划不动手、
525
+ `--revert` 逐字节还原、认不出的终端只给手动指南。Apple Terminal 无解
526
+ (驱动层合并两键),那条支路也只给建议。详见
527
+ [docs/verify/VERIFY_RECORD-31-keybindings.md](../../docs/verify/VERIFY_RECORD-31-keybindings.md)
528
+ 第七节
529
+
530
+ ### 序列
531
+
532
+ 用空格分隔,一条绑定可以是好几下按键:
533
+
534
+ ```json
535
+ { "rewind": ["escape escape"] }
536
+ ```
537
+
538
+ 三条硬规矩:
539
+
540
+ 1. **第一下立刻触发它自己那个动作**,不等第二下。绑了 `escape escape` 之后单击 Esc
541
+ **照样立即中断**——「先等半秒看是不是序列」的代价是模型在那半秒里又调了一个工具。
542
+ 实现因此是「先触发前缀动作,再在窗口内等后续键」,判据写在
543
+ [keybindings/sequences.ts](src/keybindings/sequences.ts) 的文件头,
544
+ 反向用例在 [keybindings-sequences.test.tsx](__tests__/keybindings-sequences.test.tsx)
545
+ 2. **窗口是 500ms**(`SEQUENCE_WINDOW_MS`)。超了就是两次独立的按键
546
+ 3. **全局动作和输入框动作都接得住序列**(PR-3 给 `input-box.tsx` 接了第二台状态机)。
547
+ 还没接的只剩**补全面板**那几个(`complete` / `complete-prev` / `complete-next`,
548
+ 以及横跨两侧的 `submit`)—— 绑上去的会被**拒绝** + 一条诊断,而不是静默不生效。
549
+ 面板开着的时候用户正在选东西,把那几个键做成「按一下等半秒看有没有第二下」收益是负的
550
+
551
+ > ⚠️ **两个消费方各是一台状态机,不是一台服务两边。** 一次按键会被 `app.tsx` 和
552
+ > `input-box.tsx` 的两个 `useInput` **各收一次**,共用一台等于把窗口推进两步 ——
553
+ > 具体的坏法是输入框那次 `resolve(…, 'input')` 上下文不匹配、把 global 刚武装好的
554
+ > pending 清掉,于是 `escape escape` 再也开不出回退面板。判据写在
555
+ > [use-key-sequences.ts](src/keybindings/use-key-sequences.ts) 的文件头第 2 条,
556
+ > 守卫用例是 `keybindings-sequences.test.tsx` 里那条「不许做成模块级单例」。
557
+
558
+ `escape` 和 `escape escape` **不冲突**:一个是单键绑定、一个是序列,冲突检测按整条
559
+ 绑定比。这正是「单击中断、双击开回退面板」的实现方式。
560
+
445
561
  读盘 / 校验 / 冲突消解**都不在这个包里**(tui 只依赖 protocol,跑不了文件系统也没有
446
562
  zod):在 [core/src/config/keybindings.ts](../core/src/config/keybindings.ts),
447
563
  由 [cli/src/tui-entry.ts](../cli/src/tui-entry.ts) 调,tui 收到的是一张**已经校验过
448
564
  的表**。默认表反过来是 cli 从这个包递给 core 的——那张表的真源只该有一份。
449
565
 
566
+ ### Vim 模式
567
+
568
+ **默认关。** 打开之后范围**只在输入框内** —— 不做 `:` 命令行、不做窗口分割、不做
569
+ VISUAL(方案 31 §2.6)。
570
+
571
+ | 类别 | 支持的 |
572
+ | --------- | ------------------------------------------------------- |
573
+ | 模式 | NORMAL / INSERT,状态行画在输入行下面 |
574
+ | 移动 | `h j k l w b e 0 ^ $ gg G f<char> t<char>` |
575
+ | 编辑 | `x` `d{motion}` `c{motion}` `y{motion}` `p` `P` `u` `.` |
576
+ | 整行 | `dd` `cc` `yy` |
577
+ | 进 INSERT | `i I a A o O` |
578
+ | 计数 | `3w` `2dd` `d3w`(operator 前后两个计数**相乘**) |
579
+
580
+ 四条要知道的:
581
+
582
+ 1. **开着 vim 也从 INSERT 起步** —— 打开输入框就能打字。一进来是 NORMAL 的话,每次
583
+ 启动的第一件事都是按 `i`,而绝大多数消息是一句话打完就发
584
+ 2. **Esc 在 INSERT 里是回 NORMAL,在 NORMAL 里照旧中断本轮 / 清输入**。后半句不是
585
+ 疏漏,是硬约束:Esc 是卡住时的逃生通道,不许被 vim 吃掉(§4.3 #19)。这个二选一
586
+ 在 `app.tsx` 的 `interrupt` 分支里做,输入框自己压根不碰 Esc —— 理由(两个
587
+ `useInput` 谁先跑取决于 effect 注册顺序)写在
588
+ [vim/use-vim.ts](src/vim/use-vim.ts) 的文件头
589
+ 3. **`u` 撤销以「一段输入」为单位**,不是一个字符:进 INSERT 那一下压一张 buffer
590
+ 快照,和 vim 一样
591
+ 4. **`.` 只重复不进 INSERT 的那些编辑**(`x` / `d{motion}` / `dd` / `p` / `P`)。
592
+ `cw` / `i` 那一类要连当时打的字一起重放,而那些字不经过状态机 —— 录了只会
593
+ 「进 INSERT 但什么也不插」,比不支持更让人困惑
594
+
595
+ `w` / `b` / `e` 把**标点当成自成一类**(`abc,def` 上按 `w` 会先停在逗号上),`cw`
596
+ 的行为是 `ce`(只改到词尾、不吃掉后面那个空格)—— 两条都是 vim 的语义,不是简化。
597
+ 偏移量一律走 code point,所以 emoji / CJK 上 `x` 删的是一个字符而不是半个代理对。
598
+
599
+ > 开关是 `App` 的 `vimMode` prop(默认 false),宿主接线在
600
+ > `core/src/config/keybindings.ts` + `cli/src/tui-entry.ts`。
601
+ > **怎么开**:`~/.epoch/keybindings.json` 里写 `"vimMode": true`
602
+ > (见 [CONFIGURATION.md](../../docs/CONFIGURATION.md) 的「改键」一节)。
603
+
450
604
  ## 两个验证脚本,别混用
451
605
 
452
606
  ```bash
453
- pnpm tui:probe # 布局门禁,默认 80x24
607
+ pnpm tui:probe # 布局门禁,四发全跑(zh / en × 带不带 vim)
454
608
  pnpm --filter @epoch-agent/tui probe 120 40 # 指定终端尺寸
609
+ pnpm --filter @epoch-agent/tui probe --lang=en # 只跑英文那一发
455
610
  PROBE_APPROVAL=1 pnpm tui:probe # 顺带压测审批弹窗撑高 footer
456
611
  PROBE_QUESTION=1 pnpm tui:probe # 同上,换成提问弹窗(更高:标题 + 副标题 + 5 行选项)
457
612
  ```
@@ -460,10 +615,24 @@ PROBE_QUESTION=1 pnpm tui:probe # 同上,换成提问弹窗(更
460
615
  `probe.tsx`——假 `onRun`、场景固定、不需要 API key——把输出重放成屏幕,判两个失败
461
616
  信号:出现 `ESC[2J` 整屏清除,或屏幕上有连续重复行。
462
617
 
618
+ 固定场景里那条**补丁消息**(2026-08-27 加的)多带一条判据:夹具造得比
619
+ `MAX_RENDERED_LINES` 高,于是被裁掉的那几行**一帧都不该被写出去**——`probe.mjs`
620
+ 的 `CLIPPED_MARKER` 就是去 Ink 的原始字节里查它有没有露头。这条既盯硬裁本身,
621
+ 也盯**夹具别被改矮**:门禁真红的时候把夹具调小到绿为止,等于让它从此假装盯过
622
+ 这条路径。夹具住在 `scripts/probe-diff-fixture.ts`(单独一个文件是为了能被用例
623
+ import——`pnpm probe` 不在 `pnpm check` 里,兜底那份在
624
+ `__tests__/probe-diff-fixture.test.ts`)。
625
+
626
+ > ⚠️ **它判不了「排得齐不齐」。** 整屏清除和重复行是两条**灾难性**判据:一列描述
627
+ > 整体右移三格、一句警告被 `wrap="truncate"` 切掉后半句、一个框的右边线逐行错开
628
+ > 一列——这三样在它眼里全是绿的(2026-08-25 逐条实测过)。那一格补在
629
+ > `__tests__/screen-metrics.test.tsx`(M1~M7,`pnpm check` 里跑,不要 pty)。
630
+
463
631
  ```bash
464
632
  pnpm tui:smoke # 端到端冒烟,默认 100x30
465
633
  SMOKE_ENTRY=epoch pnpm tui:smoke # 走 `pnpm epoch:dev`(用户的日常路径)
466
634
  SMOKE_PROMPT='读一下 package.json' SMOKE_WAIT=120000 pnpm tui:smoke
635
+ EPOCH_LANGUAGE=en pnpm tui:smoke # 英文界面那一档(human-eye §2.23 组 ② 要的就是它)
467
636
  ```
468
637
 
469
638
  `scripts/smoke.mjs` 是另一件事:**连真模型**(要 API key,会计费)验
@@ -476,6 +645,12 @@ SMOKE_PROMPT='读一下 package.json' SMOKE_WAIT=120000 pnpm tui:smoke
476
645
  假红;要判重复行去跑 probe。也**不能**断言模型回了什么:每次措辞都不一样,钉死一个
477
646
  字符串是在钉模型而不是钉我们的链路。
478
647
 
648
+ > ⚠️ **场景标记是「一格一组候选,命中任一即可」,不是按语言选一组。** 这个脚本跑的是
649
+ > **真入口**,语言由 `resolveLang()` 那条三级链定(`EPOCH_LANGUAGE` > `config.yaml` 的
650
+ > `display.language` > 系统 locale),脚本这一侧算不出来、也不该把那条链抄第二遍。
651
+ > 2026-08-25 之前那两串是写死的中文,于是 `EPOCH_LANGUAGE=en` 那一档**必然假红**
652
+ > ——而那正是 human-eye §2.23 组 ② 唯一用到它的档位。
653
+
479
654
  > ⚠️ **ConPTY 的 attach 前导里自带一个 `ESC[2J`**(`ESC[?9001h ESC[?1004h ESC[?25l
480
655
  ESC[2J …`,那时候 node 连起都没起)。probe 盯的是 Ink 的捕获文件,天然躲开;
481
656
  > smoke 盯 pty 字节,所以它先剥掉开头那一段「一个可见字符都没有的 CSI」再数。