@epoch-agent/tui 0.1.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.
- package/LICENSE +219 -0
- package/README.md +507 -0
- package/dist/index.d.ts +1364 -0
- package/dist/index.js +5562 -0
- package/package.json +61 -0
package/README.md
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
# @epoch-agent/tui
|
|
2
|
+
|
|
3
|
+
Ink 7 + React 19 的终端界面。
|
|
4
|
+
|
|
5
|
+
- ✅ **做**:组件、布局、键位、Markdown 渲染、斜杠命令
|
|
6
|
+
- ❌ **不做**:**不许 import [core](../core)**(`check:layers` 会红)、不 spawn 子进程、
|
|
7
|
+
不碰文件系统。折叠逻辑也不在这里,在 [view](../view)(与 [web](../web) 共用同一份)
|
|
8
|
+
- **依赖**:[protocol](../protocol) + [view](../view) + `chalk` / `lowlight` /
|
|
9
|
+
`string-width` 等纯渲染库。`ink` 和 `react` 是 **peer**
|
|
10
|
+
|
|
11
|
+
引擎能力一律由宿主经 `renderApp({ host })` 注入——切权限级别、列工具、读启动诊断、
|
|
12
|
+
打开 artifact、读剪贴板图片。这样每个斜杠命令都能对着一个假 context 单测,不用把整棵
|
|
13
|
+
Ink 树跑起来。
|
|
14
|
+
|
|
15
|
+
## 挂载
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { renderApp } from '@epoch-agent/tui';
|
|
19
|
+
|
|
20
|
+
const instance = renderApp({
|
|
21
|
+
sessionId,
|
|
22
|
+
app: { version, cwd },
|
|
23
|
+
config: { model, workDir, permissionLevel, contextLimit, getUseBackgroundColor },
|
|
24
|
+
usageScope: runtime.usageScope, // usage 事件里 cumulative 的口径
|
|
25
|
+
welcomeMessage: '…',
|
|
26
|
+
startupNotices: diagnostics, // 配置写错了必须看得见
|
|
27
|
+
onRun: (message, { signal }) => session.run(message, { signal }), // AsyncGenerator<AgentEvent>
|
|
28
|
+
onExit: () => instance.unmount(),
|
|
29
|
+
host: {/* HostActions,全部可选:缺哪个对应命令就报「不可用」而不是崩 */},
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`onRun` 吐的是 [protocol](../protocol) 的 `AgentEvent`——**不是** tui 自己的类型。
|
|
34
|
+
入参是 `EpochUserContent`(`string | 部件数组`)而不是 `string`:Ctrl+V 粘进来的图片
|
|
35
|
+
必须跟着这一次提交走到引擎。
|
|
36
|
+
|
|
37
|
+
`renderApp()` 封装了 provider 树和 ink 的 render options。其中 `exitOnCtrlC: false`
|
|
38
|
+
是必须的——ink 默认会自己吞掉 `\x03` 直接结束进程,App 里的「Ctrl+C 按两次才退出」
|
|
39
|
+
就收不到按键。
|
|
40
|
+
|
|
41
|
+
## 结构
|
|
42
|
+
|
|
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 日志 |
|
|
60
|
+
|
|
61
|
+
## 布局约束(改这里之前先读)
|
|
62
|
+
|
|
63
|
+
Ink 的 `log-update` **只能擦除它上一帧写过的行数**。一旦动态帧比终端还高、内容滚出
|
|
64
|
+
屏幕,擦除范围就对不上:轻则残留重复行,重则 Ink 退化成 `ESC[2J` 整屏清除,表现为
|
|
65
|
+
剧烈闪烁。所以:
|
|
66
|
+
|
|
67
|
+
- Banner 和已完成的历史必须放进 `<Static>`——只打印一次、向上滚走、不参与重绘;
|
|
68
|
+
- 只有 pending(流式中)的内容留在动态帧里,且必须按 `availableHeight` 限高
|
|
69
|
+
(`clampTailLines`,保留尾部);
|
|
70
|
+
- 根 Box 不要设 `height`。
|
|
71
|
+
|
|
72
|
+
正在执行的工具会在 pending 区画一小段**活尾巴**(`tool-output-delta` 的尾部若干行,
|
|
73
|
+
跑完被完整结果顶掉)。它是整个 TUI 里重绘最频繁的东西——每来一段增量就重画一帧,
|
|
74
|
+
所以行数卡得比结果更死(5 行 vs 8 行)。`probe.tsx` 里有专门压它的场景。
|
|
75
|
+
|
|
76
|
+
`availableHeight` 由 `useWindowSize()`(终端尺寸)减去 `useBoxMetrics()` 量出的底部
|
|
77
|
+
控制区高度得到,两个 hook 都是 ink 7 内置的。
|
|
78
|
+
|
|
79
|
+
## 斜杠命令
|
|
80
|
+
|
|
81
|
+
按用途分五组。**要引擎能力的一律靠 `host` 注入**,宿主没给对应回调时命令报
|
|
82
|
+
「不可用」,不崩。唯一的真源是 `BUILTIN_COMMANDS` 那张表,这里不写条数 ——
|
|
83
|
+
写了迟早对不上。
|
|
84
|
+
|
|
85
|
+
| 组 | 命令 |
|
|
86
|
+
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
|
87
|
+
| 纯 UI(不需要任何宿主能力) | `/help` `/clear` `/exit` `/thinking` `/init` |
|
|
88
|
+
| 这次会话 | `/status` `/cost` `/context` `/export` `/compact` `/resume` |
|
|
89
|
+
| 引擎里有什么 | `/model` `/permission` `/permissions` `/plan` `/goal` `/tools` `/tasks` `/diagnostics` `/skills` `/agents` `/mcp` `/memory` `/plugin` |
|
|
90
|
+
| 和仓库打交道 | `/diff` `/copy` |
|
|
91
|
+
| 退回去 | `/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)。
|
|
102
|
+
|
|
103
|
+
`/plugin`(别名 `/plugins`)只看三态和停用 / 启用,**装和卸不在这里** ——
|
|
104
|
+
装一个插件要过一道「它会带来什么」的确认(插件能带 hook),那道闸门只该有一份,
|
|
105
|
+
在 `epoch plugin install` 上。见 [docs/PLUGINS.md](../../docs/PLUGINS.md)。
|
|
106
|
+
|
|
107
|
+
`/plan` 和 `/permission plan` **不是同一件事**,这条区别决定了用户该敲哪个:
|
|
108
|
+
前者进入一段**临时**只读区间(模型交计划、用户批一次就自动出来),
|
|
109
|
+
后者把权限**级别**改成只读、一直到用户自己改回来。见下面「计划审批框」一节。
|
|
110
|
+
`/plan show` 是第三支:把**当前生效的那份已批准计划**再打一遍。它存在是因为那份
|
|
111
|
+
计划活得比屏幕长(跟着会话落盘),而 `--resume` 之后屏幕上是空的。
|
|
112
|
+
|
|
113
|
+
`/init` 是这里唯一**走 `submitPrompt` 的内置命令** —— 它要干的活本来就是
|
|
114
|
+
「让模型去干活」(扫仓库写 EPOCH.md),形状和自定义命令一样。
|
|
115
|
+
|
|
116
|
+
### 会动到模型上下文的四条
|
|
117
|
+
|
|
118
|
+
`/compact` `/resume` `/rewind`,加上 Ctrl+R。前三条和其余那些**不是一回事**:
|
|
119
|
+
它们改的是模型下一轮真正看到的东西,错了的症状不是「少个功能」而是
|
|
120
|
+
「模型记错了」。所以三者的实现都在 `AgentSession` 上(`compact()` / `resume()` /
|
|
121
|
+
`rewindConversation()`),TUI 这一侧只负责问和显示。
|
|
122
|
+
|
|
123
|
+
- **`/compact [要保留什么]`** —— 手动压一次,不看阈值。那句「保留什么」会追加到
|
|
124
|
+
摘要提示词的**最后**,不插进模板中间:模板是我们的契约(摘要结构固定,
|
|
125
|
+
下一轮增量更新靠它对齐)。压缩是一次真实的 LLM 请求,所以这条命令**会花钱**,
|
|
126
|
+
压完报出前后条数让你看得见换来了什么;摘要没生成出来时说「没压动」而不是
|
|
127
|
+
报一个假的「已压缩」
|
|
128
|
+
- **`/resume`** —— 列会话、选一个,**模型真的接上那段上下文**(历史和 sessionId
|
|
129
|
+
一起换)。只把历史打印到屏幕上是不够的:那样你会照着屏幕去问「你刚才说的那个
|
|
130
|
+
方案」,然后收到一句完全对不上的回答
|
|
131
|
+
- **`Ctrl+R`** —— 搜会话历史(SQLite FTS5,中文靠 trigram 分词器)。
|
|
132
|
+
第一版**只把命中的那条重新打印一遍**,不做滚动定位 —— 那要碰 `<Static>`,
|
|
133
|
+
见上面的布局约束。面板最多显示 5 条且每行 `wrap="truncate"`:这是**高度保证**
|
|
134
|
+
不是好看,一条结果折成两行整帧就会超过终端高度,`pnpm tui:probe` 抓到过
|
|
135
|
+
- **`/rewind`**(同 `Esc Esc`)—— 退回某个检查点之前。对话那半是**真删**、
|
|
136
|
+
不可撤销,所以面板上多一步确认;退完还会清屏重放剩下的历史,
|
|
137
|
+
否则屏幕上留着几条模型已经看不见的消息,而你会照着它们追问
|
|
138
|
+
|
|
139
|
+
> **两处清单必须和实现一致**:这段散文,以及
|
|
140
|
+
> [protocol 的 `RESERVED_COMMAND_NAMES`](../protocol/src/commands.ts)。
|
|
141
|
+
> 后者有一条**双向**用例守着(`custom-commands.test.ts`):内置有而名单没有 → 红;
|
|
142
|
+
> 名单有而内置已经删了 → 也红。**前者没有守卫**,所以加命令时别忘了连它一起改。
|
|
143
|
+
>
|
|
144
|
+
> 保留名单登记的是**全集**,不是「这次注册了的」—— 一条命令因为宿主缺能力
|
|
145
|
+
> 而没注册(`SlashCommand.available`,见下),不代表自定义命令就能占它的名字。
|
|
146
|
+
|
|
147
|
+
### 「不可用」的两种,别混
|
|
148
|
+
|
|
149
|
+
| 形态 | 用在哪 |
|
|
150
|
+
| ------------------------------ | ----------------------------------------------------------------- |
|
|
151
|
+
| 注册了,敲了报一行「不可用」 | **本该有、这次没起来**(provider 挂了时的 `/model`) |
|
|
152
|
+
| `available` 返回 false,不注册 | **这个宿主就没有这个功能**(比如没接权限出口时的 `/permissions`) |
|
|
153
|
+
|
|
154
|
+
区别是前者要说话、后者要消失:一条注册了的命令会出现在 `/help` 和补全面板里,
|
|
155
|
+
那是在**承诺一个不存在的功能**。
|
|
156
|
+
|
|
157
|
+
**自定义命令**(`~/.epoch/commands/*.md` 和项目里那份)由宿主加载好之后经
|
|
158
|
+
`renderApp({ customCommands })` 传进来,和内置命令进同一张注册表、同一个补全面板,
|
|
159
|
+
`/help` 里分两段列。它们的出口不一样:内置命令是「在 TUI 里做一件事」,
|
|
160
|
+
自定义命令是「替用户敲一段话」,走 `ctx.submitPrompt()` 发一轮对话。
|
|
161
|
+
|
|
162
|
+
插值(`$ARGUMENTS` / `$1`)**不在这里做** —— 它要引号感知的分词器,而那住在
|
|
163
|
+
`@epoch-agent/infra`,tui 只依赖 protocol。所以 TUI 调 `host.expandCommand()`
|
|
164
|
+
拿结果。在这儿手写第二个「差不多的」切词器,就会出现同一条命令在 TUI 和别的宿主里
|
|
165
|
+
断句不一样。格式与规则见 [docs/EXTENSIONS.md](../../docs/EXTENSIONS.md)。
|
|
166
|
+
|
|
167
|
+
### `/model` —— 运行期换模型
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
/model 看当前模型 / provider / 凭据 / 窗口
|
|
171
|
+
/model gpt-4o 同一家换个模型
|
|
172
|
+
/model anthropic/claude-opus-4-6 provider 和模型一起换
|
|
173
|
+
/model --reset 回到配置里那个
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**换模型不清空上下文**——那正是它的用处(对着同一段对话换个更强或更便宜的脑子)。
|
|
177
|
+
代价是新模型得接得住已经积累的东西,所以有三道前置检查,任何一道不过就**一个字段都不动**:
|
|
178
|
+
|
|
179
|
+
| 拦住的情况 | 为什么 |
|
|
180
|
+
| ------------------------ | ------------------------------------------------------- |
|
|
181
|
+
| 目标 provider 没凭据 | 请求根本发不出去,理由里点名该设哪个环境变量 |
|
|
182
|
+
| 会话里有图片,新模型不认 | 下一轮把带图历史发出去必然 400,而 400 是**已经计费**的 |
|
|
183
|
+
| 已用上下文超过新窗口 | 同上。先 `/clear` 或换个大窗口的模型 |
|
|
184
|
+
|
|
185
|
+
装得下但已经很挤、或者新模型不支持工具调用,则是**换成 + 出声**(warning),不拦。
|
|
186
|
+
|
|
187
|
+
判据全在 core 的 `evaluateSelection`,TUI 只负责把结果念出来 —— 拒绝的理由逐字透传,
|
|
188
|
+
不在 TUI 里重编一句。`provider/model` 的解析走 protocol 的 `parseModelRef`,
|
|
189
|
+
所以 `meta-llama/Llama-3.3-70B-Instruct-Turbo` 这种**模型名里自带斜杠**的能正确识别。
|
|
190
|
+
|
|
191
|
+
> ⚠️ 已知边界(PR-2 复核后仍在):自动压缩的预算仍是**启动时**那个模型的窗口,
|
|
192
|
+
> 换到小窗口模型后压缩不会跟着提前触发 —— 这正是上面第三道检查直接拒绝而不是
|
|
193
|
+
> 「换了再说」的原因。要松成「换了就立刻压一次」得改 `AgentConfig.contextLength`
|
|
194
|
+
> 的形状并动 `loop.ts`,而那两处是方案 26 明列的禁止触碰项,留给后续方案。
|
|
195
|
+
> 另外 `/model` 目前不带交互式选择器,得自己打模型名 —— 这两条都已结案,
|
|
196
|
+
> 判据见 [VERIFY_RECORD-26-model-switch](../../docs/verify/VERIFY_RECORD-26-model-switch.md) 第四节。
|
|
197
|
+
|
|
198
|
+
### 换过之后:这一轮用哪个、以及模型挂了怎么办
|
|
199
|
+
|
|
200
|
+
`/model` 换的是**会话级**选择,但有两种情况会临时或永久地偏离它:
|
|
201
|
+
|
|
202
|
+
- **命令 frontmatter 的 `model:`** —— 一条自定义命令可以声明自己用哪个模型
|
|
203
|
+
(`model: utility` 解析成配置里的工具模型),**只在那一轮生效,收尾无条件还原**。
|
|
204
|
+
Esc 中断走的是生成器的 `return()`,还原照样发生
|
|
205
|
+
- **降级** —— 主模型报 404 / 模型不可用时,引擎按
|
|
206
|
+
「`--fallback-model`(同 provider)→ 内置默认 → 换 provider」的顺序往下试。
|
|
207
|
+
下一轮又会**从你选的那个模型重新起步**:降级是这一次请求的事,不改你的选择
|
|
208
|
+
|
|
209
|
+
同一个模型**连续三次**把请求拖垮,本会话就不再自动用它了,并在那一轮结束时给一条
|
|
210
|
+
提示 —— toast 加一条历史项,两处一起给:toast 抓得住正盯着屏幕的人,历史项留得住
|
|
211
|
+
几分钟后才回来看的人。而这句话说的是「你的账单和能力已经不是你选的那个模型了」。
|
|
212
|
+
重新 `/model` 选回它就清账,那是明确的「我知道,再试一次」。
|
|
213
|
+
|
|
214
|
+
## `@` 文件补全
|
|
215
|
+
|
|
216
|
+
打 `@` 弹文件面板(子序列模糊匹配,`@c/a/loop` 命中
|
|
217
|
+
`packages/core/src/agent/loop.ts`),Tab / Enter 补全路径,提交时**文件内容随消息
|
|
218
|
+
一起发出去**,省掉「模型调 file_read → 再等一轮」。
|
|
219
|
+
|
|
220
|
+
三件事不在这个包里,都是刻意的:
|
|
221
|
+
|
|
222
|
+
| 事情 | 在哪 | 为什么 |
|
|
223
|
+
| -------------- | ---------------------------------------- | ------------------------------------------------- |
|
|
224
|
+
| 候选清单 | 宿主 `host.listWorkspaceFiles()` | 要 spawn `git ls-files`,tui 只依赖 protocol |
|
|
225
|
+
| `@` 的抽取规则 | `@epoch-agent/protocol` 的 `mentions.ts` | 引擎那侧要用同一份,两边写岔了会**静默错位** |
|
|
226
|
+
| 读文件 + 权限 | 宿主 `host.resolveMentions()` | 要过工作区边界和 `file_read` 的权限判定,都在引擎 |
|
|
227
|
+
|
|
228
|
+
> ⚠️ **`input-box.tsx` 的键盘回调只准读 ref。** ink 7 把子组件里注册的 `useInput`
|
|
229
|
+
> 回调钉在首帧,于是组件**画得出来**(渲染是新鲜的)但按键读到的是首帧的值。
|
|
230
|
+
> 这个坑一共有**七个**实例,六个是存量 bug(最后三个 2026-08-13 修掉):
|
|
231
|
+
>
|
|
232
|
+
> | 读了什么 | 症状 |
|
|
233
|
+
> | ----------------- | ----------------------------------------------------------------- |
|
|
234
|
+
> | `pendingImages` | Ctrl+V 粘的图被静默丢掉(方案 12 时发现) |
|
|
235
|
+
> | `matched` | `/cl` + Tab 什么也不做、Enter 把 `/cl` 当普通消息发给模型 |
|
|
236
|
+
> | `mention` | `@` 面板弹得出来但 Tab 没反应 |
|
|
237
|
+
> | `disabled` | **弹窗开着时按的键同时打进输入框** —— 答复审批按的 `1` 会留在里面 |
|
|
238
|
+
> | `history`(prop) | ↑ 永远翻不出上一条 —— 回调里那个数组恒为首帧的空数组 |
|
|
239
|
+
> | `buf.isMultiline` | 恒 false,多行时 ↑↓ 不做行间移动,直接去翻历史 |
|
|
240
|
+
> | `buf.lines / col` | 行尾 `\` + Enter 的续行从来没生效(首帧那行是空的) |
|
|
241
|
+
>
|
|
242
|
+
> 往这个文件加任何「键盘回调要读的 state 或 prop」都会再踩一次。**buffer 状态一律
|
|
243
|
+
> 走 `bufRef`**(整个 `buf` 的镜像,所以往 `TextBufferAPI` 加派生值不会漏)。
|
|
244
|
+
> 七条各有一条会红的回归用例(`app.test.tsx` / `input-box-first-frame.test.tsx`)。
|
|
245
|
+
|
|
246
|
+
## `!` 与 `#`
|
|
247
|
+
|
|
248
|
+
```
|
|
249
|
+
!git status 直接跑一条命令,不过模型
|
|
250
|
+
#测试要用 pnpm test:ci 记一条,不过模型
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
两条都**不产生一轮模型调用**,但也都**不是免检通道**:`!` 走的是和模型调
|
|
254
|
+
`terminal` 完全相同的那条路(危险命令表 → 权限判定 → 审批 → 执行),
|
|
255
|
+
判定在引擎侧,TUI 只负责把确认框接上去。用户手打的命令和模型生成的命令在
|
|
256
|
+
危险性上没有区别。
|
|
257
|
+
|
|
258
|
+
`!` 的输出进两处:TUI 历史(给人看)和会话上下文(给模型看,`host.noteToSession`)。
|
|
259
|
+
只进前者的话,「你看一下这个命令的输出」这句话对模型是空的。被权限拦下的那句
|
|
260
|
+
**不进**上下文 —— 那不是命令的输出,是我们的一句拒绝。
|
|
261
|
+
|
|
262
|
+
`#` 第一次用时弹一次「记到哪一层」(项目知识 / 用户偏好),之后记住不再问:
|
|
263
|
+
每次都问的话这个快捷方式就不快了,永远不问又会让项目知识默默写进全局记忆。
|
|
264
|
+
|
|
265
|
+
判定本身(含 `!!!` / `##` / 多行三条反例)在 `commands/prefix.ts`,纯函数,
|
|
266
|
+
用例在 `completion.test.ts`。
|
|
267
|
+
|
|
268
|
+
## `/tasks` 与状态栏的 ⚙ 计数
|
|
269
|
+
|
|
270
|
+
后台任务(方案 36)活在 `plugin-terminal` 的任务表里,而 tui 不许 import 它 ——
|
|
271
|
+
所以两处都走宿主:`host.listBackgroundTasks()`。计数是**每两秒轮询**一次的,
|
|
272
|
+
且**只在数字真的变了时** setState —— 无条件重渲染会让 `<Static>` 之外的一切
|
|
273
|
+
白重绘,实测就是光标闪。
|
|
274
|
+
|
|
275
|
+
一个都不在跑时那一块**整块不画**:绝大多数会话没有后台任务,一个常驻的
|
|
276
|
+
`⚙ 0` 是纯噪音。
|
|
277
|
+
|
|
278
|
+
## 状态栏的自定义那一段
|
|
279
|
+
|
|
280
|
+
用户在 `~/.epoch/config.yaml` 里配 `statusLine.command` 之后,状态栏右侧那一组的最左多一段
|
|
281
|
+
他自己的内容(通常是分支名)。整条链**只有一个字符串**过 tui 的边界:
|
|
282
|
+
`host.readStatusLine()` 同步返回已经剥过 ANSI、取过第一行、截过 60 字符的那一行。
|
|
283
|
+
|
|
284
|
+
- **超时、截断、失败保留旧值三条都在宿主那侧**([cli/src/statusline.ts](../cli/src/statusline.ts)),
|
|
285
|
+
不在这里。tui 只依赖 protocol,跑不了子进程;而那三条是一组约束,拆到两个包里
|
|
286
|
+
维护迟早有一半失效
|
|
287
|
+
- **没配时宿主压根不注入 `readStatusLine`**,于是这里连计时器都不装、那一段整块不画。
|
|
288
|
+
不是给一个永远返回 `null` 的读取器让 UI 去判空
|
|
289
|
+
- 这里 2 秒问一次是「多久去看一眼有没有新值」,**不是**「多久跑一次那条命令」——
|
|
290
|
+
后者由用户的 `statusLine.intervalMs` 决定,宿主在 `read()` 里判。和上面 ⚙ 那段
|
|
291
|
+
同一个形状(轮询 + 值没变就不 setState),刻意没合并:刷新频率的归属不同
|
|
292
|
+
|
|
293
|
+
## 计划审批框
|
|
294
|
+
|
|
295
|
+
`ApprovalRequest` 带 `plan` 字段时(方案 35),底部弹的是
|
|
296
|
+
[`PlanConfirmation`](src/components/dialogs/plan-confirmation.tsx) 而不是
|
|
297
|
+
`ToolConfirmation`。拆成两个组件是因为两者问的问题根本不同:工具审批的四个选项
|
|
298
|
+
讲的是**授权范围**(一次 / 本会话 / 永久 / 拒),计划审批的四个讲的是
|
|
299
|
+
**接下来怎么走**(批准并执行 / 批准但保持只读 / 让我改 / 拒绝)。
|
|
300
|
+
|
|
301
|
+
两条是安全性质,不是外观:
|
|
302
|
+
|
|
303
|
+
- **`canExecute: false` 时「批准并执行」根本不渲染。** 那意味着用户本来就把权限
|
|
304
|
+
级别设成了 `plan`(他显式要求全程只读),一份计划被批准不能把他升上去。
|
|
305
|
+
画出来再由引擎拒绝更糟 —— 那是在给一个不存在的出口,用户点了才知道点不动
|
|
306
|
+
- **Esc 等于拒绝**,和工具审批同一条口径:想不清楚就走开,必须落在安全的一侧
|
|
307
|
+
|
|
308
|
+
选「让我改一下」时**不立刻答复**,先在框里弹一行输入收用户那句意见,
|
|
309
|
+
Enter 提交、Esc 退回选项。不收的话这个出口是个死循环:模型不知道要改什么,
|
|
310
|
+
只会把同一份计划原样再交一次。
|
|
311
|
+
|
|
312
|
+
> ⚠️ 那一行输入的真值**住在 ref 里**,state 只用于渲染 —— ink 7 在子组件里注册的
|
|
313
|
+
> `useInput` 回调被钉在首帧,普通 state 跨不过这个边界。这不是风格问题,
|
|
314
|
+
> 见上面「布局约束」和 [第四批收尾验收记录第三节](../../docs/verify/VERIFY_RECORD-batch4-closeout.md)。
|
|
315
|
+
|
|
316
|
+
### 批准之后留一张卡片
|
|
317
|
+
|
|
318
|
+
审批框是一次性的:点完「批准并执行」,接下来十分钟的纲就从屏幕上没了。所以批准的
|
|
319
|
+
那一刻往历史里落一条 [`PlanMessage`](src/components/messages/plan-message.tsx),
|
|
320
|
+
写清楚是「批准并执行(权限回到 X)」还是「保持只读」—— 这两者的差别是
|
|
321
|
+
「agent 现在能不能动我的文件」,两分钟后用户就记不住自己点的是哪个了。
|
|
322
|
+
拒绝**不落卡片**:把一份作废的计划留在屏幕上是这里能犯的最坏的错。
|
|
323
|
+
|
|
324
|
+
卡片走 `<Static>` 里的历史项,**不常驻底部**:常驻意味着每一帧都要重绘几十行,
|
|
325
|
+
那正是上面「布局约束」骂的花屏成因。落进历史反而对 —— 它随对话往上滚,
|
|
326
|
+
终端自己的 scrollback 就是回看入口,回看不到了还有 `/plan show`。
|
|
327
|
+
|
|
328
|
+
> ⚠️ `plan` 这一种展示项**不在 `@epoch-agent/view` 里**,是 TUI 独有的
|
|
329
|
+
> (见 [types/message-types.ts](src/types/message-types.ts))。往那个共用联合类型里加
|
|
330
|
+
> 就等于要求 Web 也在时间线上画它,而 Web 的持久形态是检视面板的一个 tab ——
|
|
331
|
+
> 设计稿明确否掉了「在时间线里保留一份长正文」。两个宿主对同一件事的形状本来就不同。
|
|
332
|
+
|
|
333
|
+
## 回退面板(`/rewind` 和 `Esc Esc`)
|
|
334
|
+
|
|
335
|
+
[`RewindPicker`](src/components/dialogs/rewind-picker.tsx),语义全在
|
|
336
|
+
[docs/CHECKPOINTS.md](../../docs/CHECKPOINTS.md),这里只记 TUI 这一侧的决定。
|
|
337
|
+
|
|
338
|
+
**`Esc` 单击的语义一点没变,也没有变慢。** 双击判定的做法是「中断 + 回退」而不是
|
|
339
|
+
「等一等看是不是双击」:第一下 Esc 立刻把该干的干完(中断本轮 / 清空输入),
|
|
340
|
+
顺手武装 500ms 的窗口;第二下在窗口里到达时才开面板。中断慢半秒的代价是模型
|
|
341
|
+
在那半秒里又调了一个工具,所以 `rewind.test.tsx` 里那条断的是「让出一拍就已经
|
|
342
|
+
`aborted`」—— 比双击窗口小一个量级,挂在 `setTimeout` 上的实现必红。
|
|
343
|
+
窗口这套模式和 `Ctrl+C` 两段式退出**共用一份写法**(一个 state + 一个到点撤销的
|
|
344
|
+
effect),没造第二套。窗口取 500ms 而不是 Ctrl+C 的 2000ms:那个窗口是「别急,
|
|
345
|
+
再按一次才真退」,长一点更安全;这个是「刚才那下是不是双击的前半」,长了会把两次
|
|
346
|
+
无关的 Esc 误判成双击。
|
|
347
|
+
|
|
348
|
+
三条必须写在面板上,不能含糊过去:**terminal 里跑的命令改的文件不在范围内**、
|
|
349
|
+
**这条检查点可能不完整**、**工作区有未提交改动时建议先 `git stash`**。最后一条走的是
|
|
350
|
+
`HostActions.gitDirty`(`git status --porcelain`,未跟踪的新文件也算),
|
|
351
|
+
和 `/diff` 的 `gitDiff` 分开 —— 为一行提示读几 MB 补丁不划算,而口径也不一样。
|
|
352
|
+
问不出来(不是 git 仓库 / git 不在 PATH)时**什么都不提示**,不说「工作区是干净的」。
|
|
353
|
+
|
|
354
|
+
**冲突文件默认一个都不覆盖,且没有「全部覆盖」这个按钮。** 要覆盖得用空格逐个点过头,
|
|
355
|
+
勾中的路径才会进 `rewind()` 的 `overwrite`。引擎那侧同样刻意没有 `force: true`。
|
|
356
|
+
|
|
357
|
+
**对话回退多一步确认**,因为它是真删、不可撤销。退完之后 TUI 会清屏并把剩下的历史
|
|
358
|
+
重放一遍(拿当前 sessionId 再 `resume` 一次,也就是从 DB 重装)—— 不重放的话,
|
|
359
|
+
屏幕上留着几条模型已经看不见的消息,而你会照着它们追问,然后收到一句对不上的回答。
|
|
360
|
+
|
|
361
|
+
> ⚠️ 面板的 `useInput` **`isActive` 不跟着步骤走**,分步是在回调里判的。
|
|
362
|
+
> ink 的 `useInput` 在 `isActive` 变 true 的那次 effect 里才挂上监听,而 effect 跑在
|
|
363
|
+
> 渲染提交**之后** —— 中间那一小段时间里屏幕上已经画出了列表、按下去的键却没人接。
|
|
364
|
+
> 症状是用例随机红一条「等不到下一步」,真机上是「面板刚跳出来那一下敲的回车丢了」。
|
|
365
|
+
|
|
366
|
+
## 快捷键
|
|
367
|
+
|
|
368
|
+
下面是**默认值**,不是硬编码:每一行左边那个键对应右边那个**动作**,而动作和键的
|
|
369
|
+
对应关系可以改(见下一节「改键」)。默认表的真源是
|
|
370
|
+
[`keybindings/defaults.ts`](src/keybindings/defaults.ts),它就是方案 31 之前散在
|
|
371
|
+
`app.tsx` / `input-box.tsx` 里的那些 `if`。
|
|
372
|
+
|
|
373
|
+
| 键 | 动作 | 操作 |
|
|
374
|
+
| ------------------------------ | ------------------------ | -------------------------------------- |
|
|
375
|
+
| `Enter` | `submit` | 发送 |
|
|
376
|
+
| `Option+Enter` / `Shift+Enter` | `newline` | 换行 |
|
|
377
|
+
| `Ctrl+J` | `newline` | 换行 |
|
|
378
|
+
| 行尾 `\` + `Enter` | — | 换行(吃掉那个 `\`,不发送) |
|
|
379
|
+
| `/` | — | 打开命令面板(打字自然触发,不是绑定) |
|
|
380
|
+
| `Tab` | `complete` | 补全选中的命令 |
|
|
381
|
+
| `↑` / `↓` | `history-prev/next` | 行间移动;到边界则翻输入历史 |
|
|
382
|
+
| `↑` / `↓`(面板开着时) | `complete-prev/next` | 在补全面板里选 |
|
|
383
|
+
| `←` / `→` | `cursor-left/right` | 光标移动(跨行) |
|
|
384
|
+
| `Home` / `Ctrl+A` | `line-start` | 行首 |
|
|
385
|
+
| `End` / `Ctrl+E` | `line-end` | 行尾 |
|
|
386
|
+
| `Ctrl+W` | `kill-word` | 删词 |
|
|
387
|
+
| `Ctrl+U` / `Ctrl+K` | `kill-line-start/end` | 删到行首 / 删到行尾 |
|
|
388
|
+
| `Backspace` / `Delete` | `delete-char-left/right` | 删字符 |
|
|
389
|
+
| `Ctrl+V` | `paste-image` | 粘贴剪贴板里的图片 |
|
|
390
|
+
| `Ctrl+O` | `open-artifact` | 用系统查看器打开最近的 artifact |
|
|
391
|
+
| `Ctrl+R` | `transcript-search` | 搜会话历史(FTS5,中文也能搜) |
|
|
392
|
+
| `Ctrl+L` | `clear-screen` | 清屏 |
|
|
393
|
+
| `Esc` | `interrupt` | 中断当前流式;空闲时清空输入与待发图片 |
|
|
394
|
+
| `Esc` ×2 | (双击窗口) | 上面那件事照做,外加打开回退面板 |
|
|
395
|
+
| `Ctrl+C` ×2 | `exit` | 退出 |
|
|
396
|
+
| `Ctrl+D` | `exit-if-empty` | 输入为空时退出 |
|
|
397
|
+
| `y` / `n` | — | 工具审批(弹窗自己的键,还没进键位层) |
|
|
398
|
+
|
|
399
|
+
`Esc` 不做退出:方向键和 IME 在部分终端会发 ESC 前缀,误退代价太大。弹窗开着时
|
|
400
|
+
`Esc` 一律让路给弹窗——同一个 Esc 被两边消费会变成「拒绝审批 + 顺手中断整轮」,
|
|
401
|
+
而中断会 break 掉生成器,正在等答复的引擎就永远挂着。
|
|
402
|
+
|
|
403
|
+
> ✅ **上表里有三个键曾经是失效的**(方案 31 PR-1 查出来、那一轮刻意没修,
|
|
404
|
+
> 因为它要证明的恰好是「抽了一层但行为一个键都没变」):行尾 `\` + `Enter` 的续行、
|
|
405
|
+
> 多行时 ↑↓ 的行间移动、以及**翻输入历史**。三者同一个根因:ink 7 把子组件里的
|
|
406
|
+
> `useInput` 回调钉在首帧,而它们读的值每次渲染都重取。**2026-08-13 一起修了**
|
|
407
|
+
> ——镜像进渲染期赋值的 ref(`bufRef` / `historyRef`,见上面「只准读 ref」那段),
|
|
408
|
+
> 回归用例在 [input-box-first-frame.test.tsx](__tests__/input-box-first-frame.test.tsx):
|
|
409
|
+
> 那个文件的规矩是「可变值必须在挂载之后才变」,否则用例修不修都是绿的。
|
|
410
|
+
|
|
411
|
+
### 改键
|
|
412
|
+
|
|
413
|
+
`~/.epoch/keybindings.json`(**只有用户级,没有项目级**——键位是个人偏好,
|
|
414
|
+
而「项目能改键位」等于让一个 clone 下来的仓库改掉你的 Esc):
|
|
415
|
+
|
|
416
|
+
```json
|
|
417
|
+
{
|
|
418
|
+
"clear-screen": ["ctrl+k"],
|
|
419
|
+
"newline": ["shift+enter", "alt+enter", "ctrl+j"],
|
|
420
|
+
"history-prev": []
|
|
421
|
+
}
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
- 键名逐字用上表里那些动作名;一个动作可以绑多个键
|
|
425
|
+
- 写法是 `ctrl+alt+shift+<键>`,大小写和修饰键顺序都无所谓;具名键有
|
|
426
|
+
`escape` `enter` `tab` `space` `backspace` `delete` `up` `down` `left` `right`
|
|
427
|
+
`home` `end` `pageup` `pagedown`(`esc` / `del` / `pgup` 这些短写法也认)
|
|
428
|
+
- **空数组 = 解绑**,默认键不会偷偷回来
|
|
429
|
+
- 序列(`"escape escape"`)**还没做**,写了会报一条「不是一个能识别的按键」
|
|
430
|
+
|
|
431
|
+
四条规矩,都会在启动诊断里出声(**只出声,不拦启动**):
|
|
432
|
+
|
|
433
|
+
| 情况 | 结果 |
|
|
434
|
+
| ---------------------- | -------------------------------------------------------- |
|
|
435
|
+
| 绑 `ctrl+c` / `ctrl+d` | **拒绝那一条**——它们是卡住时的逃生通道,不许被锁死 |
|
|
436
|
+
| 两个动作抢同一个键 | 先到先得,后面那条忽略;**用户写的先到**,所以重绑总能赢 |
|
|
437
|
+
| 键写错 / 动作名拼错 | 那一条忽略,其余照常生效 |
|
|
438
|
+
| 整个文件坏了 | 回落到默认表,诊断里带文件路径,TUI 照常起 |
|
|
439
|
+
|
|
440
|
+
「两个动作抢同一个键」按**同时活着的上下文**判:`global` 和输入框那一层是同时在收
|
|
441
|
+
键的,所以把 `clear-screen` 绑到 `ctrl+k` 会把默认的 `kill-line-end` 挤掉;而
|
|
442
|
+
`input` 和 `dialog` 互斥(面板要么开着要么没开),所以 ↑ 同时是 `history-prev` 和
|
|
443
|
+
`complete-prev` 不算冲突——那正是「面板可见时 ↑↓ 选命令」的实现方式。
|
|
444
|
+
|
|
445
|
+
读盘 / 校验 / 冲突消解**都不在这个包里**(tui 只依赖 protocol,跑不了文件系统也没有
|
|
446
|
+
zod):在 [core/src/config/keybindings.ts](../core/src/config/keybindings.ts),
|
|
447
|
+
由 [cli/src/tui-entry.ts](../cli/src/tui-entry.ts) 调,tui 收到的是一张**已经校验过
|
|
448
|
+
的表**。默认表反过来是 cli 从这个包递给 core 的——那张表的真源只该有一份。
|
|
449
|
+
|
|
450
|
+
## 两个验证脚本,别混用
|
|
451
|
+
|
|
452
|
+
```bash
|
|
453
|
+
pnpm tui:probe # 布局门禁,默认 80x24
|
|
454
|
+
pnpm --filter @epoch-agent/tui probe 120 40 # 指定终端尺寸
|
|
455
|
+
PROBE_APPROVAL=1 pnpm tui:probe # 顺带压测审批弹窗撑高 footer
|
|
456
|
+
PROBE_QUESTION=1 pnpm tui:probe # 同上,换成提问弹窗(更高:标题 + 副标题 + 5 行选项)
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
`scripts/probe.mjs` 是**布局门禁**:在真 pty(Windows ConPTY / macOS forkpty)里跑
|
|
460
|
+
`probe.tsx`——假 `onRun`、场景固定、不需要 API key——把输出重放成屏幕,判两个失败
|
|
461
|
+
信号:出现 `ESC[2J` 整屏清除,或屏幕上有连续重复行。
|
|
462
|
+
|
|
463
|
+
```bash
|
|
464
|
+
pnpm tui:smoke # 端到端冒烟,默认 100x30
|
|
465
|
+
SMOKE_ENTRY=epoch pnpm tui:smoke # 走 `pnpm epoch:dev`(用户的日常路径)
|
|
466
|
+
SMOKE_PROMPT='读一下 package.json' SMOKE_WAIT=120000 pnpm tui:smoke
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
`scripts/smoke.mjs` 是另一件事:**连真模型**(要 API key,会计费)验
|
|
470
|
+
「装配 → 事件流 → usage 回填 → Ctrl+C 两段式退出」这条链没断。判据四条:场景标记、
|
|
471
|
+
状态栏的 `ctx` 真的非零(usage 回填了)、整屏清除次数、以及第一下 Ctrl+C **还活着**、
|
|
472
|
+
第二下才退。中间那句「还活着」才是价值所在——只验「按了会退」的话,二次确认整个
|
|
473
|
+
失效(一按就退)也照样绿。
|
|
474
|
+
|
|
475
|
+
它**不能**用屏幕模型判重复行——真实会话超过一屏之后绝对行号就和终端对不上,必然报
|
|
476
|
+
假红;要判重复行去跑 probe。也**不能**断言模型回了什么:每次措辞都不一样,钉死一个
|
|
477
|
+
字符串是在钉模型而不是钉我们的链路。
|
|
478
|
+
|
|
479
|
+
> ⚠️ **ConPTY 的 attach 前导里自带一个 `ESC[2J`**(`ESC[?9001h ESC[?1004h ESC[?25l
|
|
480
|
+
ESC[2J …`,那时候 node 连起都没起)。probe 盯的是 Ink 的捕获文件,天然躲开;
|
|
481
|
+
> smoke 盯 pty 字节,所以它先剥掉开头那一段「一个可见字符都没有的 CSI」再数。
|
|
482
|
+
> 照单全收的话,Windows 上这条冒烟永远红在一个和我们无关的清屏上。
|
|
483
|
+
|
|
484
|
+
```bash
|
|
485
|
+
pnpm --filter @epoch-agent/tui screen <capture-file> [cols] [rows]
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
`scripts/screen.mjs` 是 probe 用的 ANSI 屏幕模型(**带滚动区**,这是它取代
|
|
489
|
+
`replay.mjs` 的唯一理由),也可以手工拿来看一份 capture。开 pty 和等一帧渲染完这
|
|
490
|
+
两件事由 `scripts/pty-harness.mjs` 共用。
|
|
491
|
+
|
|
492
|
+
## 开发
|
|
493
|
+
|
|
494
|
+
```bash
|
|
495
|
+
pnpm --filter @epoch-agent/tui test
|
|
496
|
+
pnpm tui # 起 TUI(tsx 直跑 cli 的 tui-entry)
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
> ⚠️ **`pnpm tui` 读的是本包的 `dist/`。** [cli](../cli) 是按 `exports` 解析
|
|
500
|
+
> `@epoch-agent/tui` 的(没有 tsconfig paths 映射),改了这里的源码不 `pnpm build`
|
|
501
|
+
> 就去测,测的是上一次的产物——typecheck 也读 dist 的 `.d.ts`,两边一起骗你。
|
|
502
|
+
> 只调布局的话直接 `pnpm tui:probe`(它 import 的是 `../src`,改完即刻生效)。
|
|
503
|
+
|
|
504
|
+
调试日志:设 `EPOCH_DEBUG=1` 后 `debug.ts` 会写 `<系统临时目录>/epoch-debug.log`。
|
|
505
|
+
默认关闭——它是同步写盘,挂在按键处理里会拖慢输入。用 `tmpdir()` 而不是字面量
|
|
506
|
+
`/tmp`:Windows 上 `/tmp` 会被 resolve 成通常不存在的 `C:\tmp`,于是日志功能等于
|
|
507
|
+
不存在,而且一声不响。
|