@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/dist/index.d.ts
ADDED
|
@@ -0,0 +1,1364 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import React__default from 'react';
|
|
3
|
+
import { ModelRef, PermissionLevel, SelectionContext, SetSelectionResult, ModelSelection, ExpandedCommand, EpochFilePart, BackgroundTaskInfo, ApprovalRequest, ApprovalOutcome, ContextBreakdown, CustomCommandSource, EpochUserContent, AgentEvent, CustomCommandDef, KeybindingTable, ToolArtifact, TokenUsage, KeyChord, KeyContext, KeyAction } from '@epoch-agent/protocol';
|
|
4
|
+
import { HistoryItemWithoutId } from '@epoch-agent/view';
|
|
5
|
+
export { DIFF_CONTEXT_LINES, DIFF_MAX_LINES, DiffLine, DiffResult, HistoryItem, HistoryItemWithoutId, StreamReduceResult, applyStreamEvent, collapseContext, diffLines } from '@epoch-agent/view';
|
|
6
|
+
import { Instance, Key } from 'ink';
|
|
7
|
+
|
|
8
|
+
interface SelectOption<T> {
|
|
9
|
+
/** 选项正文 */
|
|
10
|
+
label: string;
|
|
11
|
+
value: T;
|
|
12
|
+
/** 右侧灰色补充说明 */
|
|
13
|
+
description?: string;
|
|
14
|
+
/** 危险选项(标红),比如「永久允许」「拒绝」 */
|
|
15
|
+
danger?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 检查点与回退在 TUI 这一侧的形状(方案 27 PR-3)。
|
|
20
|
+
*
|
|
21
|
+
* 下面这几个是 core 那几个类型的**结构性镜像**,不是它们本身 —— 同
|
|
22
|
+
* `listTools` / `listSkills` 的既有做法:`CheckpointSummary` / `RewindPreview`
|
|
23
|
+
* 住在 core,而 tui 只依赖 protocol,够不着。逐字抄一份的代价是可能漂移,
|
|
24
|
+
* 换来的是 tui 不必为了画一个面板去依赖引擎;接线那一层
|
|
25
|
+
* (`cli/src/tui-host.ts`)有两条会红的用例守着这几个字段真的对得上。
|
|
26
|
+
*
|
|
27
|
+
* 单独成文件而不是塞进 `commands/types.ts`:那个文件是 `HostActions` 契约的家,
|
|
28
|
+
* 加完这一批就破 500 行的硬线了。语义全在 docs/CHECKPOINTS.md,这里只写形状。
|
|
29
|
+
*/
|
|
30
|
+
/** 检查点列表里的一条 */
|
|
31
|
+
interface CheckpointEntry {
|
|
32
|
+
/** 会话内的轮次序号。它同时是 `preview` / `rewind` 的入参 */
|
|
33
|
+
turnIndex: number;
|
|
34
|
+
createdAt: number;
|
|
35
|
+
/** 用户那一轮说了什么(已截断),列表里靠它认人 */
|
|
36
|
+
preview: string;
|
|
37
|
+
/** 这一轮碰过几个文件 */
|
|
38
|
+
fileCount: number;
|
|
39
|
+
/**
|
|
40
|
+
* 快照本身就有文件没存进来(读失败 / 超单文件上限)。
|
|
41
|
+
*
|
|
42
|
+
* **必须显示出来**:回退这样一条检查点只能恢复一部分,而「部分回退」的工作区
|
|
43
|
+
* 比不回退更难收拾 —— 用户有权在按下去之前知道这件事。
|
|
44
|
+
*/
|
|
45
|
+
incomplete: boolean;
|
|
46
|
+
}
|
|
47
|
+
/** 回退一个文件要做的动作。语义见 docs/CHECKPOINTS.md */
|
|
48
|
+
type RewindFileAction = 'restore' | 'delete' | 'recreate' | 'skip';
|
|
49
|
+
/** 现状相对「agent 留下它时」漂了没有 */
|
|
50
|
+
type RewindFileDrift = 'as-agent-left-it' | 'changed-since' | 'unknown';
|
|
51
|
+
interface RewindFileEntry {
|
|
52
|
+
path: string;
|
|
53
|
+
action: RewindFileAction;
|
|
54
|
+
drift: RewindFileDrift;
|
|
55
|
+
}
|
|
56
|
+
/** 按下去之前先给用户看的那一份 */
|
|
57
|
+
interface RewindPlan {
|
|
58
|
+
turnIndex: number;
|
|
59
|
+
preview: string;
|
|
60
|
+
/**
|
|
61
|
+
* 对话那半能不能退。旧检查点、或者跑在没有持久化的内存模式里时为 false ——
|
|
62
|
+
* 那时只能退文件,面板要把那个选项**说明白**而不是让它静默失败
|
|
63
|
+
*/
|
|
64
|
+
canRewindConversation: boolean;
|
|
65
|
+
incomplete: boolean;
|
|
66
|
+
/** 会被动的文件 */
|
|
67
|
+
files: RewindFileEntry[];
|
|
68
|
+
/**
|
|
69
|
+
* **不会**被动的:agent 改完之后又有人动过(你自己改的、terminal 里的命令改的)。
|
|
70
|
+
*
|
|
71
|
+
* 默认一个都不覆盖。要覆盖得由用户逐个点过头,把路径塞进 `rewind` 的
|
|
72
|
+
* `overwrite` —— 刻意没有「全部覆盖」那个按钮,它毁掉的是用户自己刚写的东西。
|
|
73
|
+
*/
|
|
74
|
+
conflicts: RewindFileEntry[];
|
|
75
|
+
}
|
|
76
|
+
/** 回退的范围(方案 27 §2.4 的三选一) */
|
|
77
|
+
type RewindScope = 'files' | 'conversation' | 'both';
|
|
78
|
+
/** 回退完了到底动了什么。形状和 runtime 的 `RewindResult` 逐字对齐 */
|
|
79
|
+
interface RewindReport {
|
|
80
|
+
/** 文件那半;`scope: 'conversation'` 时为 null(压根没做) */
|
|
81
|
+
files: {
|
|
82
|
+
restored: string[];
|
|
83
|
+
deleted: string[];
|
|
84
|
+
recreated: string[];
|
|
85
|
+
/** 因为漂过而没动的 */
|
|
86
|
+
skippedConflicts: string[];
|
|
87
|
+
/** 整体失败的原因;成功时缺席。**失败即什么都没改** */
|
|
88
|
+
failed?: string;
|
|
89
|
+
} | null;
|
|
90
|
+
/** 对话删掉了几条;没做 / 不支持 / 失败时 null */
|
|
91
|
+
messagesRemoved: number | null;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 检查点与回退能力(`HostActions.checkpoints`)。
|
|
95
|
+
*
|
|
96
|
+
* 三个方法一起给或一起不给:只给 `list` 的话,用户能选中一条然后什么都不会发生。
|
|
97
|
+
* 缺这一整项时 `/rewind` **压根不注册**(`SlashCommand.available`)、Esc Esc 也
|
|
98
|
+
* 不武装 —— 承诺一个不存在的安全网比没有安全网更糟。
|
|
99
|
+
*/
|
|
100
|
+
interface CheckpointActions {
|
|
101
|
+
/** 这个会话有哪些检查点,新的在前 */
|
|
102
|
+
list: () => Promise<readonly CheckpointEntry[]>;
|
|
103
|
+
/** 按下去之前先看:会动哪些文件、哪些因为漂过而不敢动。检查点不存在时回 null */
|
|
104
|
+
preview: (turnIndex: number) => Promise<RewindPlan | null>;
|
|
105
|
+
/**
|
|
106
|
+
* 真的退。`overwrite` 是用户**逐个点过头**的冲突文件路径,
|
|
107
|
+
* 不在里面的冲突一律跳过。
|
|
108
|
+
*/
|
|
109
|
+
rewind: (turnIndex: number, opts: {
|
|
110
|
+
scope: RewindScope;
|
|
111
|
+
overwrite?: readonly string[];
|
|
112
|
+
}) => Promise<RewindReport>;
|
|
113
|
+
}
|
|
114
|
+
/** 回退完了要说的那一句:`ok: false` 表示工作区一个字节都没改 */
|
|
115
|
+
interface RewindSummary {
|
|
116
|
+
text: string;
|
|
117
|
+
ok: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 宿主交给 TUI 的**只读展示形状**。
|
|
122
|
+
*
|
|
123
|
+
* 这几个的共同点是「引擎那边已经算完了,这里只负责画」:判定住在 core / runtime,
|
|
124
|
+
* 而 tui 不许 import core,所以过来的一律是字符串、布尔和数组。
|
|
125
|
+
* 单独一个文件是因为 [commands/types.ts](../commands/types.ts) 要放的是**契约**
|
|
126
|
+
* (`HostActions` / `SlashCommand`),而这几样是契约里传的**数据** ——
|
|
127
|
+
* 混在一起那个文件只会一直长。
|
|
128
|
+
*/
|
|
129
|
+
/**
|
|
130
|
+
* 企业托管策略在面板上的样子(方案 22 §2.6)。
|
|
131
|
+
*/
|
|
132
|
+
interface ManagedInfo {
|
|
133
|
+
/** 这台机器上到底有没有托管文件。没有 ≠ 三个开关全 false,措辞不一样 */
|
|
134
|
+
present: boolean;
|
|
135
|
+
/** 读的是哪个文件。管理员要知道该往哪儿放,所以不存在时也带着 */
|
|
136
|
+
path: string;
|
|
137
|
+
/** 打开了的元开关,逐条一句人话;全关时空数组 */
|
|
138
|
+
switches: readonly string[];
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* 一个已装插件在 `/plugin` 里的样子(方案 32)。
|
|
142
|
+
*
|
|
143
|
+
* `enabled` 和 `loaded` 是**两件事**,所以是两个字段而不是一个状态枚举:
|
|
144
|
+
* 前者是用户的意愿(记录里那个开关),后者是这次启动的事实。
|
|
145
|
+
* 「启用着但没加载」正是最需要被看见的那一格 —— 它意味着出了事,`reason` 说明是什么。
|
|
146
|
+
*/
|
|
147
|
+
interface PluginInfo {
|
|
148
|
+
name: string;
|
|
149
|
+
version: string;
|
|
150
|
+
/** 用户当初敲的那个来源字符串(`./my-plugin` / `github:owner/repo` / …) */
|
|
151
|
+
source: string;
|
|
152
|
+
enabled: boolean;
|
|
153
|
+
loaded: boolean;
|
|
154
|
+
/** 没加载时的原因;加载了就没有 */
|
|
155
|
+
reason?: string;
|
|
156
|
+
/**
|
|
157
|
+
* 这个插件带来的扩展物,每类一句人话(「3 条命令」「1 个 hook ⚠」)。
|
|
158
|
+
*
|
|
159
|
+
* 已经在宿主那侧算好:数它们要扫插件目录、要解析 `hooks.json`,
|
|
160
|
+
* 那份逻辑住在 core 的 `plugin/inventory.ts`,而 tui 不许 import core。
|
|
161
|
+
*/
|
|
162
|
+
contributes: readonly string[];
|
|
163
|
+
}
|
|
164
|
+
/** 宿主读到的一张剪贴板图片。字段与 `EpochImagePart` 对齐,另带展示用的尺寸信息 */
|
|
165
|
+
interface ClipboardImagePart {
|
|
166
|
+
/** 裸 base64,不带 `data:` 前缀 */
|
|
167
|
+
base64: string;
|
|
168
|
+
mediaType: string;
|
|
169
|
+
/** 原始字节数(不是 base64 长度) */
|
|
170
|
+
bytes: number;
|
|
171
|
+
width?: number;
|
|
172
|
+
height?: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 消息类型 — 基于 Google Gemini CLI types.ts
|
|
177
|
+
* @license Apache-2.0 (adapted from google-gemini/gemini-cli)
|
|
178
|
+
* Copyright 2025 Google LLC
|
|
179
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
180
|
+
* Modifications Copyright 2024-2026 bowen
|
|
181
|
+
*
|
|
182
|
+
* ---
|
|
183
|
+
*
|
|
184
|
+
* `HistoryItem*` 那一族已经搬去 `@epoch-agent/view` —— 浏览器端要用同一套
|
|
185
|
+
* (方案 20 §7)。留在这里的是**只有终端才有的**那几样。
|
|
186
|
+
*/
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 一张计划卡片是哪种场合画的(方案 35 PR-2)。
|
|
190
|
+
*
|
|
191
|
+
* 前两档是**用户刚点的那个出口**;第三档是 `/plan show` 的回看形态 ——
|
|
192
|
+
* 它必须和前两档分开,因为那时只有计划正文,不知道当年按的是哪个出口
|
|
193
|
+
* (落盘的只有正文,见 `SessionMeta.plan`)。硬套一个 `execute` 就是在猜,
|
|
194
|
+
* 而猜错的那半句恰好是「你的文件现在能不能被改」。
|
|
195
|
+
*/
|
|
196
|
+
type PlanCardMode = 'execute' | 'readonly' | 'current';
|
|
197
|
+
/**
|
|
198
|
+
* 一份**已批准**的计划,作为时间线上的一条常驻展示项(方案 35 PR-2)。
|
|
199
|
+
*
|
|
200
|
+
* 在这之前,一份计划在屏幕上只出现两次:`exit_plan_mode` 那张工具卡,和审批框。
|
|
201
|
+
* 两者都是一次性的 —— 用户点完「批准并执行」,那份纲就滚走了,而它接下来十分钟
|
|
202
|
+
* 都是这一轮的依据。
|
|
203
|
+
*
|
|
204
|
+
* ## 为什么它**不在** `@epoch-agent/view` 里
|
|
205
|
+
*
|
|
206
|
+
* view 是 tui 和 web 共用的展示项定义,加一种就等于要求两个宿主都画它
|
|
207
|
+
* (`web` 那个 switch 刻意没有 default,加一支两边一起红)。而 web 的持久形态
|
|
208
|
+
* **不是时间线上的一条** —— 设计稿(`design/web-ui/README.md` 第七节)把它定成
|
|
209
|
+
* 检视面板的第四个 tab,并且明确否掉了「在时间线里保留一份长正文」那个方案
|
|
210
|
+
* (理由:一轮结束后还要翻上去找,等于没有持久化)。
|
|
211
|
+
*
|
|
212
|
+
* 也就是说这两个宿主对同一件事的**形状本来就不同**,硬塞进共用联合类型只会逼
|
|
213
|
+
* web 画一个设计上已经否掉的东西。等 web 的检视面板落地,那边走的是面板状态,
|
|
214
|
+
* 不是这条展示项 —— 到那时这里也不需要搬家。
|
|
215
|
+
*/
|
|
216
|
+
type HistoryItemPlan = {
|
|
217
|
+
type: 'plan';
|
|
218
|
+
/** 计划正文,markdown */
|
|
219
|
+
markdown: string;
|
|
220
|
+
/**
|
|
221
|
+
* 用户是按哪个出口批的。两者对「接下来会发生什么」的含义完全不同,
|
|
222
|
+
* 而卡片上必须看得出来 —— 不然用户记不住自己刚才是不是放了写权限出去。
|
|
223
|
+
*
|
|
224
|
+
* `current` 是 `/plan show` 的**回看**形态:那时只知道计划正文,不知道当年
|
|
225
|
+
* 按的是哪个出口(落盘的只有正文),所以它两句都不说。
|
|
226
|
+
*/
|
|
227
|
+
mode: PlanCardMode;
|
|
228
|
+
/** 「批准并执行」时恢复到的权限级别;另两档没有 */
|
|
229
|
+
level?: string;
|
|
230
|
+
};
|
|
231
|
+
/**
|
|
232
|
+
* TUI 的展示项联合 = view 那一套 + 终端独有的几种。
|
|
233
|
+
*
|
|
234
|
+
* 存在的理由见 {@link HistoryItemPlan}。**只在 TUI 内部用** —— 往 view / web
|
|
235
|
+
* 那边传的仍然是 `HistoryItemWithoutId`,这个别名不会漏出包外。
|
|
236
|
+
*/
|
|
237
|
+
type TuiHistoryItemWithoutId = HistoryItemWithoutId | HistoryItemPlan;
|
|
238
|
+
/** 带 id 的完整展示项,对应 view 的 `HistoryItem` */
|
|
239
|
+
type TuiHistoryItem = TuiHistoryItemWithoutId & {
|
|
240
|
+
id: number;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 斜杠命令的契约。
|
|
245
|
+
*
|
|
246
|
+
* 命令拿到的是一组回调(`SlashCommandContext`),自己不碰 React 状态,
|
|
247
|
+
* 也不 import 任何 core 的东西 —— tui 只依赖 protocol,凡是需要引擎能力的
|
|
248
|
+
* (切权限级别、列工具、读启动诊断)都由宿主在 `renderApp({ host })` 时注入。
|
|
249
|
+
*
|
|
250
|
+
* 这样命令实现是纯函数式的,可以直接对着一个假的 context 单测,
|
|
251
|
+
* 不用把整个 Ink 树跑起来。
|
|
252
|
+
*/
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* `/goal` 一次子命令的结果(方案 52 PR-1)。
|
|
256
|
+
*
|
|
257
|
+
* `message` 是**宿主已经渲染好的一句话**,TUI 原样印出来 —— 判据见
|
|
258
|
+
* {@link HostActions.goals} 上那段 ⚠️。`ok` 只决定印成 info 还是 warning:
|
|
259
|
+
* 拒绝的理由本身在 `message` 里,因为「下一步该敲什么」逐条不同
|
|
260
|
+
* (`busy` 要先 `/goal clear`,`no-goal` 要先建一个)。
|
|
261
|
+
*/
|
|
262
|
+
interface GoalActionResult {
|
|
263
|
+
ok: boolean;
|
|
264
|
+
message: string;
|
|
265
|
+
}
|
|
266
|
+
/** 宿主(tui-entry)注入的引擎能力。全部可选:缺哪个对应命令就报「不可用」而不是崩 */
|
|
267
|
+
interface HostActions {
|
|
268
|
+
/**
|
|
269
|
+
* 切换权限级别。
|
|
270
|
+
*
|
|
271
|
+
* 回 `{ok:false}` 时 `reason` 必须说明**为什么** —— 最常见的那一种是企业托管
|
|
272
|
+
* 禁掉了 bypass(`disableBypassPermissionsMode`),而光说一句「切换失败」
|
|
273
|
+
* 会让用户以为撞上了 bug,然后去反复重试一件永远不会成功的事。
|
|
274
|
+
*/
|
|
275
|
+
setPermissionLevel?: (level: PermissionLevel) => {
|
|
276
|
+
ok: boolean;
|
|
277
|
+
reason?: string;
|
|
278
|
+
};
|
|
279
|
+
/** 读当前权限级别(切换后状态栏要跟着变) */
|
|
280
|
+
getPermissionLevel?: () => PermissionLevel;
|
|
281
|
+
/** 已注册(且对模型可见)的工具清单 */
|
|
282
|
+
listTools?: () => Array<{
|
|
283
|
+
name: string;
|
|
284
|
+
description?: string;
|
|
285
|
+
}>;
|
|
286
|
+
/** buildRuntime() 的启动诊断,每个模块一行 */
|
|
287
|
+
getDiagnostics?: () => string[];
|
|
288
|
+
/**
|
|
289
|
+
* provider 类型 / API key 提示,用于 /model。
|
|
290
|
+
*
|
|
291
|
+
* 回 `null` = provider 没起来。**每次调都要重新问宿主**,别缓存:
|
|
292
|
+
* 方案 26 之后 `/model` 能换 provider,缓存下来就会显示启动时那家。
|
|
293
|
+
*/
|
|
294
|
+
getProviderInfo?: () => {
|
|
295
|
+
provider: string;
|
|
296
|
+
apiKeyHint: string;
|
|
297
|
+
} | null;
|
|
298
|
+
/**
|
|
299
|
+
* 换模型(方案 26)。`'reset'` = 回到配置里那个。
|
|
300
|
+
*
|
|
301
|
+
* 返回的是 core 的判定结果原样透传,**不在这里翻译成 boolean**:
|
|
302
|
+
* 三种拒绝(没凭据 / 不认图片 / 窗口装不下)各有各的下一步动作,
|
|
303
|
+
* 收成一个 false 之后 UI 只能编一句笼统的话。
|
|
304
|
+
* 判据全在 core,这里只负责把结果显示出来 —— 少一处会和引擎漂移的地方。
|
|
305
|
+
*
|
|
306
|
+
* `ctx` 是**两边各知道一半**的会话事实,所以两边都填、在宿主那侧合并:
|
|
307
|
+
* - `usedTokens` —— TUI 有(`SessionStats.lastInputTokens`),引擎侧没有
|
|
308
|
+
* 一个「上一次请求送进去多少」的现成读点
|
|
309
|
+
* - `hasImages` —— 宿主有(会话历史里的 image part),TUI 只看得见
|
|
310
|
+
* 还没发出去的那几张
|
|
311
|
+
*/
|
|
312
|
+
setModel?: (ref: ModelRef | 'reset', ctx?: SelectionContext) => SetSelectionResult;
|
|
313
|
+
/**
|
|
314
|
+
* 当前模型选择 + 它的上下文窗口。
|
|
315
|
+
*
|
|
316
|
+
* 和 `getPermissionLevel` 同一个理由:切换之后 `/model` 再看要是**当前值**,
|
|
317
|
+
* 而不是 TUI 启动时那一份快照。
|
|
318
|
+
*/
|
|
319
|
+
getModelSelection?: () => {
|
|
320
|
+
selection: ModelSelection;
|
|
321
|
+
contextLength: number;
|
|
322
|
+
};
|
|
323
|
+
/**
|
|
324
|
+
* 取走引擎在这一轮里攒下的模型提示,取完即清(方案 26 验收 #8)。
|
|
325
|
+
*
|
|
326
|
+
* 目前只有一句:某个模型连续失败到了阈值,本会话不再自动降级重试它。
|
|
327
|
+
* 这件事必须**说出来**,否则用户看到的只是「回答忽然换了个味道」,
|
|
328
|
+
* 而账单和能力都已经不是他选的那个模型了。
|
|
329
|
+
*
|
|
330
|
+
* 拉而不是推:产出它的 `ProviderRouter` 在 `loop.ts` 底下,发不出 `AgentEvent`。
|
|
331
|
+
*/
|
|
332
|
+
drainModelNotices?: () => string[];
|
|
333
|
+
/**
|
|
334
|
+
* 用系统查看器打开一个 artifact。打不开就 reject,消息直接 toast 给用户。
|
|
335
|
+
*
|
|
336
|
+
* 由宿主实现而不是 TUI 自己 spawn:能不能打开是一条**安全判定**
|
|
337
|
+
* (路径必须在 artifact 目录里、扩展名必须在白名单里),那份判定住在
|
|
338
|
+
* `core/src/context/artifact-open.ts`,而 tui 不许 import core。
|
|
339
|
+
*/
|
|
340
|
+
openArtifact?: (path: string) => Promise<void>;
|
|
341
|
+
/**
|
|
342
|
+
* 读系统剪贴板里的图片,没有图片返回 null。
|
|
343
|
+
*
|
|
344
|
+
* 同样是宿主能力:要 spawn `osascript` / `xclip` / PowerShell,
|
|
345
|
+
* 换成 Electron 宿主时是 `clipboard.readImage()`,接口一样、实现不同。
|
|
346
|
+
*/
|
|
347
|
+
readClipboardImage?: () => Promise<ClipboardImagePart | null>;
|
|
348
|
+
/**
|
|
349
|
+
* 当前模型认不认图片。
|
|
350
|
+
*
|
|
351
|
+
* 用来在**粘贴那一刻**就拦住,而不是等一个来回之后由引擎报错 ——
|
|
352
|
+
* 引擎侧那道闸门(loop.ts)仍然是权威判定,这里只是省掉一次白等。
|
|
353
|
+
*/
|
|
354
|
+
supportsImages?: () => boolean;
|
|
355
|
+
/**
|
|
356
|
+
* 把一条自定义斜杠命令展开成一次 turn 的输入(方案 23)。
|
|
357
|
+
*
|
|
358
|
+
* 插值(`$ARGUMENTS` / `$1`)要用方案 21 的 tokenizer,而那住在 infra ——
|
|
359
|
+
* tui 只依赖 protocol,够不着。所以展开在宿主侧做,TUI 拿到的是结果。
|
|
360
|
+
*
|
|
361
|
+
* 认不出这个命令名时回 `undefined`(正常情况下不会:命令表本来就是宿主给的,
|
|
362
|
+
* 除非表在这中间被换掉了)。
|
|
363
|
+
*/
|
|
364
|
+
expandCommand?: (name: string, args: string) => ExpandedCommand | undefined;
|
|
365
|
+
/**
|
|
366
|
+
* 工作区文件清单,`@` 补全的候选(方案 25)。
|
|
367
|
+
*
|
|
368
|
+
* 由宿主提供:它要跑 `git ls-files` / 走目录,而 tui 只依赖 protocol。
|
|
369
|
+
* 异步是因为第一次可能要几十毫秒 —— 不该阻塞首帧。
|
|
370
|
+
*/
|
|
371
|
+
listWorkspaceFiles?: () => Promise<readonly string[]>;
|
|
372
|
+
/**
|
|
373
|
+
* 把 `@路径` 解析成带内容的附件部件(方案 25 §2.2 的选项 B)。
|
|
374
|
+
*
|
|
375
|
+
* **必须由宿主做**,不只是因为 tui 读不了文件:这条路要过工作区边界判定和
|
|
376
|
+
* 权限判定,而两者都在引擎那一侧。TUI 自己 `readFileSync` 等于给 `@`
|
|
377
|
+
* 开了一条绕过 `file_read` 的读取通道。
|
|
378
|
+
*/
|
|
379
|
+
resolveMentions?: (paths: readonly string[]) => {
|
|
380
|
+
parts: EpochFilePart[];
|
|
381
|
+
/** 要念给用户听的话(截断了 / 被拦了 / 超上限了) */
|
|
382
|
+
notices: string[];
|
|
383
|
+
};
|
|
384
|
+
/**
|
|
385
|
+
* 当前的后台任务表(方案 36 PR-2)。
|
|
386
|
+
*
|
|
387
|
+
* 同步返回:它只是读一张内存里的表,而状态栏每两秒要问一次 —— 异步的话
|
|
388
|
+
* 每次都要过一遍微任务队列,还得处理「上一次还没回来」的竞态。
|
|
389
|
+
*/
|
|
390
|
+
listBackgroundTasks?: () => readonly BackgroundTaskInfo[];
|
|
391
|
+
/**
|
|
392
|
+
* 用户自配的状态栏那一段(方案 29 §2.6)。没配时**宿主不注入这一项**,
|
|
393
|
+
* 状态栏据此决定画不画 —— 而不是画一段空的、留个洞。
|
|
394
|
+
*
|
|
395
|
+
* 同步返回、且**不保证是最新的**:宿主给的是上一次跑完的值,顺便判断要不要
|
|
396
|
+
* 在后台再跑一次。这是刻意的,两个理由都写在
|
|
397
|
+
* [cli/src/statusline.ts](../../../cli/src/statusline.ts) 的文件头:
|
|
398
|
+
* 状态栏是装饰,不能因为一条命令卡住就让整个 UI 等;也不该由 TUI 来管
|
|
399
|
+
* 「谁去关那个定时器」。
|
|
400
|
+
*/
|
|
401
|
+
readStatusLine?: () => string | null;
|
|
402
|
+
/**
|
|
403
|
+
* 跑一条**用户手打**的命令(`!ls -la`,方案 25 §2.3)。
|
|
404
|
+
*
|
|
405
|
+
* `approve` 由 TUI 提供:宿主判定要确认时回调它,TUI 弹出和模型那条路
|
|
406
|
+
* **完全相同**的确认框。判定本身在引擎侧 —— `!` 不是免检通道。
|
|
407
|
+
*/
|
|
408
|
+
runShellCommand?: (command: string, approve: (request: ApprovalRequest) => Promise<ApprovalOutcome>) => Promise<{
|
|
409
|
+
output: string;
|
|
410
|
+
ok: boolean;
|
|
411
|
+
blocked?: boolean;
|
|
412
|
+
}>;
|
|
413
|
+
/** 写一条记忆(`#记一下`,方案 25 §2.4)。`target` 决定用户级还是项目级 */
|
|
414
|
+
writeMemory?: (content: string, target: 'user' | 'memory', approve: (request: ApprovalRequest) => Promise<ApprovalOutcome>) => Promise<{
|
|
415
|
+
output: string;
|
|
416
|
+
ok: boolean;
|
|
417
|
+
blocked?: boolean;
|
|
418
|
+
}>;
|
|
419
|
+
/**
|
|
420
|
+
* 把 `!` 跑出来的输出记进**模型的**上下文(方案 25 §2.3)。
|
|
421
|
+
*
|
|
422
|
+
* TUI 自己的历史不是模型的上下文 —— 不接这一条的话,用户看得见输出而模型
|
|
423
|
+
* 看不见,而「你看一下这个命令的输出」正是 `!` 存在的意义。
|
|
424
|
+
*/
|
|
425
|
+
noteToSession?: (text: string) => void;
|
|
426
|
+
/**
|
|
427
|
+
* 当前上下文预算的构成(方案 25 §2.5)。
|
|
428
|
+
*
|
|
429
|
+
* **每次调都要重新问**:历史每轮都在长。缓存下来 `/context` 会一直显示
|
|
430
|
+
* 第一次看的那个数。
|
|
431
|
+
*/
|
|
432
|
+
getContextBreakdown?: () => ContextBreakdown | null;
|
|
433
|
+
/**
|
|
434
|
+
* 已加载的技能(方案 23 §2.5)。
|
|
435
|
+
*
|
|
436
|
+
* `scope` 要露出来:用户看见一条不认识的技能,第一个问题是「这哪来的」——
|
|
437
|
+
* 同 `/help` 把内置命令和自定义命令分两段列的理由。
|
|
438
|
+
*/
|
|
439
|
+
listSkills?: () => ReadonlyArray<{
|
|
440
|
+
name: string;
|
|
441
|
+
description: string;
|
|
442
|
+
category: string;
|
|
443
|
+
/**
|
|
444
|
+
* `plugin` 是方案 32 加的第三种来源,它带的技能名一律是 `<插件名>:<名字>`;
|
|
445
|
+
* `host` 是方案 44 加的第四种(嵌入宿主 ship 的目录),同样一律带前缀。
|
|
446
|
+
*
|
|
447
|
+
* ⚠️ 这个联合是**抄**的(tui 不许 import core),所以它靠 `cli/src/tui-host.ts`
|
|
448
|
+
* 那一行装配去撞真源 —— 真源加一格而这里不加,那一行当场编译不过。
|
|
449
|
+
* 方案 44 就是这么被逮到的。
|
|
450
|
+
*/
|
|
451
|
+
scope: 'user' | 'project' | 'plugin' | 'host';
|
|
452
|
+
}>;
|
|
453
|
+
/**
|
|
454
|
+
* 可派给子 agent 的角色表(方案 18)。
|
|
455
|
+
*
|
|
456
|
+
* `tools` 是**声明值**,实际生效的还要和当时的工具表取交集 ——
|
|
457
|
+
* 显示时要说明这一点,否则用户会以为角色能用一个已经被卸载的工具。
|
|
458
|
+
*/
|
|
459
|
+
listAgentRoles?: () => ReadonlyArray<{
|
|
460
|
+
name: string;
|
|
461
|
+
description: string;
|
|
462
|
+
source?: string;
|
|
463
|
+
tools?: readonly string[];
|
|
464
|
+
}>;
|
|
465
|
+
/**
|
|
466
|
+
* MCP server 的连接状态。
|
|
467
|
+
*
|
|
468
|
+
* **getter 语义,每次调都要重新问**:MCP 会热重连,缓存下来 `/mcp` 会一直
|
|
469
|
+
* 显示启动那一刻的状态 —— 而这条命令存在的唯一理由就是「现在到底连上没有」。
|
|
470
|
+
*/
|
|
471
|
+
listMcpServers?: () => ReadonlyArray<{
|
|
472
|
+
name: string;
|
|
473
|
+
status: string;
|
|
474
|
+
toolCount: number;
|
|
475
|
+
error?: string;
|
|
476
|
+
}>;
|
|
477
|
+
/**
|
|
478
|
+
* 已装插件一览(方案 32)。
|
|
479
|
+
*
|
|
480
|
+
* **停用的和加载失败的都要在里面** —— 这条命令一半的价值就在那儿:
|
|
481
|
+
* 一个因为 `epochVersion` 不满足而没加载的插件,在别处的唯一痕迹是启动时
|
|
482
|
+
* 一行 warn,而用户查「我装的东西怎么没生效」时看的就是这一屏。
|
|
483
|
+
*/
|
|
484
|
+
listPlugins?: () => {
|
|
485
|
+
/** `~/.epoch/plugins.json`,显示出来是为了回答「我该去改哪个文件」 */
|
|
486
|
+
statePath: string;
|
|
487
|
+
plugins: readonly PluginInfo[];
|
|
488
|
+
};
|
|
489
|
+
/**
|
|
490
|
+
* 停用 / 启用一个插件(`/plugin disable <名字>`)。
|
|
491
|
+
*
|
|
492
|
+
* **装和卸不在这里**:那两件事要联网、要解包、要在预览之后问一句,
|
|
493
|
+
* 而那一问是插件这条路上真正的闸门。理由写在 `commands/plugins.ts` 的文件头。
|
|
494
|
+
*
|
|
495
|
+
* 异步是因为写状态文件要先抢一把跨进程的锁 —— 同一台机器上可能还开着另一个
|
|
496
|
+
* `epoch plugin install`。
|
|
497
|
+
*/
|
|
498
|
+
setPluginEnabled?: (name: string, enabled: boolean) => Promise<{
|
|
499
|
+
ok: boolean;
|
|
500
|
+
message: string;
|
|
501
|
+
}>;
|
|
502
|
+
/** 已记住的记忆条目。`target` 区分用户级和项目级 */
|
|
503
|
+
listMemories?: () => ReadonlyArray<{
|
|
504
|
+
content: string;
|
|
505
|
+
target: string;
|
|
506
|
+
}>;
|
|
507
|
+
/**
|
|
508
|
+
* 删一条记忆。
|
|
509
|
+
*
|
|
510
|
+
* 写入不在这里 —— `/memory add` 复用已有的 `writeMemory`(带审批回调)。
|
|
511
|
+
* 不为一条 UI 命令新开一条绕过权限的写路径。
|
|
512
|
+
*/
|
|
513
|
+
removeMemory?: (content: string) => Promise<{
|
|
514
|
+
ok: boolean;
|
|
515
|
+
message: string;
|
|
516
|
+
}>;
|
|
517
|
+
/**
|
|
518
|
+
* 往系统剪贴板写一段文本。
|
|
519
|
+
*
|
|
520
|
+
* 和 `readClipboardImage` 同一个理由归宿主:要 spawn `pbcopy` / `clip.exe` /
|
|
521
|
+
* `xclip`,换成 Electron 宿主时是 `clipboard.writeText()`。
|
|
522
|
+
*/
|
|
523
|
+
copyToClipboard?: (text: string) => Promise<void>;
|
|
524
|
+
/** 工作区当前的 `git diff`。不是 git 仓库、或者 git 不在 PATH 时回 `ok: false` */
|
|
525
|
+
gitDiff?: () => Promise<{
|
|
526
|
+
text: string;
|
|
527
|
+
ok: boolean;
|
|
528
|
+
message?: string;
|
|
529
|
+
}>;
|
|
530
|
+
/**
|
|
531
|
+
* 工作区**有没有**未提交的改动(方案 27 §2.5 那句 `git stash` 提示)。
|
|
532
|
+
*
|
|
533
|
+
* 和 `gitDiff` 分开而不是拿它的 `text` 判空:回退面板要的只是一个布尔和一个
|
|
534
|
+
* 计数,而 `git diff HEAD` 在大仓库上会把几 MB 的补丁读进内存 —— 为了一行提示
|
|
535
|
+
* 付这个代价不划算。口径也不同:这里走 `git status --porcelain`,**未跟踪的
|
|
536
|
+
* 新文件也算**,而 `git diff HEAD` 看不见它们。
|
|
537
|
+
*
|
|
538
|
+
* 不是 git 仓库、git 不在 PATH 时回 `ok: false` —— 那时**什么都不提示**,
|
|
539
|
+
* 而不是提示「工作区是干净的」:我们只是不知道。
|
|
540
|
+
*/
|
|
541
|
+
gitDirty?: () => Promise<{
|
|
542
|
+
ok: boolean;
|
|
543
|
+
dirty: boolean;
|
|
544
|
+
count: number;
|
|
545
|
+
}>;
|
|
546
|
+
/**
|
|
547
|
+
* 检查点与回退(方案 27)。`/rewind` 和 Esc Esc 共用这一份能力。
|
|
548
|
+
*
|
|
549
|
+
* 缺它时那条命令不注册、那个键位不武装 —— 判据见 {@link CheckpointActions}。
|
|
550
|
+
*/
|
|
551
|
+
checkpoints?: CheckpointActions;
|
|
552
|
+
/**
|
|
553
|
+
* 把当前会话导出成 Markdown,返回真正写到的路径。
|
|
554
|
+
*
|
|
555
|
+
* 由宿主做而不是拿 TUI 自己的历史拼:TUI 的历史是**给人看的投影**
|
|
556
|
+
* (工具调用被折叠成一行卡片),而导出要的是完整记录 ——
|
|
557
|
+
* 那份在 `sessionStore.loadMessages()` 里。
|
|
558
|
+
*/
|
|
559
|
+
exportSession?: (file?: string) => Promise<{
|
|
560
|
+
path: string;
|
|
561
|
+
messages: number;
|
|
562
|
+
}>;
|
|
563
|
+
/**
|
|
564
|
+
* 手动压一次上下文(`/compact`,方案 25 §2.6)。
|
|
565
|
+
*
|
|
566
|
+
* `instruction` 是「这次帮我留住什么」。**压缩是一次真实的 LLM 请求**,
|
|
567
|
+
* 所以这条命令会花钱 —— 结果里带上压缩前后的条数,让用户看得见换来了什么。
|
|
568
|
+
*/
|
|
569
|
+
compactContext?: (instruction?: string) => Promise<{
|
|
570
|
+
ran: boolean;
|
|
571
|
+
before: number;
|
|
572
|
+
after: number;
|
|
573
|
+
reason?: string;
|
|
574
|
+
}>;
|
|
575
|
+
/** 最近的会话列表(`/resume` 的候选) */
|
|
576
|
+
listSessions?: () => ReadonlyArray<{
|
|
577
|
+
id: string;
|
|
578
|
+
title: string;
|
|
579
|
+
startedAt: number;
|
|
580
|
+
messageCount: number;
|
|
581
|
+
}>;
|
|
582
|
+
/**
|
|
583
|
+
* 换到另一段会话,**模型真的接上那段上下文**。
|
|
584
|
+
*
|
|
585
|
+
* 返回恢复出来的历史,供 TUI 回放进屏幕。只回放不换上下文的话,
|
|
586
|
+
* 用户会照着屏幕上那段去问「你刚才说的那个方案」,然后收到一句对不上的回答。
|
|
587
|
+
*/
|
|
588
|
+
resumeSession?: (sessionId: string) => ReadonlyArray<{
|
|
589
|
+
role: string;
|
|
590
|
+
content: string;
|
|
591
|
+
}>;
|
|
592
|
+
/** 会话全文检索(Ctrl+R,走 SQLite FTS5) */
|
|
593
|
+
searchSessions?: (query: string) => ReadonlyArray<{
|
|
594
|
+
sessionId: string;
|
|
595
|
+
title: string;
|
|
596
|
+
snippet: string;
|
|
597
|
+
}>;
|
|
598
|
+
/**
|
|
599
|
+
* 当前生效的权限规则 + 遮蔽情况(`/permissions`,方案 22 §2.7)。
|
|
600
|
+
*
|
|
601
|
+
* `layer` 是**来源层**,这条命令存在的一半意义就在它身上 —— 用户查权限问题时
|
|
602
|
+
* 的第一个问题永远是「这条 deny 是谁加的、我该去改哪个文件」。
|
|
603
|
+
*/
|
|
604
|
+
listPermissions?: () => {
|
|
605
|
+
level: string;
|
|
606
|
+
rules: ReadonlyArray<{
|
|
607
|
+
bucket: string;
|
|
608
|
+
rule: string;
|
|
609
|
+
layer: string;
|
|
610
|
+
}>;
|
|
611
|
+
shadows: ReadonlyArray<{
|
|
612
|
+
kind: string;
|
|
613
|
+
message: string;
|
|
614
|
+
}>;
|
|
615
|
+
/** 企业托管策略(方案 22 §2.6)。这台机器上没有托管文件时 `present: false` */
|
|
616
|
+
managed: ManagedInfo;
|
|
617
|
+
};
|
|
618
|
+
/**
|
|
619
|
+
* Plan 模式(方案 35)—— 用户主动那一半。
|
|
620
|
+
*
|
|
621
|
+
* **出口处那次审批不在这里**:它走的是既有的 `approval-request` 链
|
|
622
|
+
* (`exit_plan_mode` 工具发起),TUI 接的还是 `run.requestApproval` 那一个队列。
|
|
623
|
+
* 这里只有「进 / 出 / 现在在不在」,外加「已经批过的那份是什么」。
|
|
624
|
+
*/
|
|
625
|
+
planMode?: {
|
|
626
|
+
active: () => boolean;
|
|
627
|
+
/** 进入前的权限级别;不在模式里时为 null */
|
|
628
|
+
from: () => string | null;
|
|
629
|
+
enter: () => {
|
|
630
|
+
ok: boolean;
|
|
631
|
+
from?: string;
|
|
632
|
+
reason?: string;
|
|
633
|
+
};
|
|
634
|
+
/** 主动退出,返回恢复到的级别;本来就不在模式里时回 null */
|
|
635
|
+
leave: () => string | null;
|
|
636
|
+
/**
|
|
637
|
+
* 当前生效的那份**已批准**计划;没有就是 null(方案 35 PR-2)。
|
|
638
|
+
*
|
|
639
|
+
* `/plan` 拿它回答「我刚才批的那份计划是什么」。这条在 `--resume` 之后
|
|
640
|
+
* 尤其要紧:屏幕上是空的(那张卡片是上一个进程画的),而模型手上那份还在
|
|
641
|
+
* —— 没有这个出口,用户无从知道自己接回来的会话正照着哪份纲跑。
|
|
642
|
+
*
|
|
643
|
+
* 可选:老宿主没实现时 `/plan` 少说一段,不该因此报「不可用」。
|
|
644
|
+
*/
|
|
645
|
+
approvedPlan?: () => string | null;
|
|
646
|
+
};
|
|
647
|
+
/**
|
|
648
|
+
* 长任务的目标(方案 52)—— `/goal` 命令族的全部数据源。
|
|
649
|
+
*
|
|
650
|
+
* ## ⚠️ 为什么这一组回的是**渲染好的字符串**,而别的能力回结构
|
|
651
|
+
*
|
|
652
|
+
* `checkpoints` 那一组回的是数据(TUI 自己画面板),这一组不是 —— 每个方法
|
|
653
|
+
* 回一句已经拼好的话。判据是 **i18n**:
|
|
654
|
+
*
|
|
655
|
+
* `tui` 只依赖 protocol(见 `packages/tui/README.md` 的依赖表),够不着
|
|
656
|
+
* `@epoch-agent/infra` 的 `t()`,所以 TUI 里写的每一句中文都是硬编码的、
|
|
657
|
+
* 也就永远只有中文。这一片是**新增**的用户可见文案,把它硬编码进来等于给
|
|
658
|
+
* i18n 那条棘轮(`scripts/i18n-scan.mjs`,只许降不许升)再添一笔债。
|
|
659
|
+
*
|
|
660
|
+
* 所以措辞归宿主:cli 那侧用 `t()` 从 `locales/{zh,en}.yaml` 的 `goal.*` 取,
|
|
661
|
+
* TUI 只负责**解析子命令**和**把结果印出来**。这不是新发明的形状 ——
|
|
662
|
+
* `listPlugins` / `ManagedInfo` 早就是「宿主算好的展示形状」(见本文件
|
|
663
|
+
* import 那几行上的注释)。
|
|
664
|
+
*
|
|
665
|
+
* 整组可选:宿主没接(会话库起不来)时 `/goal` 压根不注册。
|
|
666
|
+
*/
|
|
667
|
+
goals?: {
|
|
668
|
+
/**
|
|
669
|
+
* 当前目标的多行展示。没有目标时是那句「还没有目标 + 怎么建一个」的提示 ——
|
|
670
|
+
* **不是空串**:`/goal` 不带参数时用户要的答案在两种情况下都存在。
|
|
671
|
+
*/
|
|
672
|
+
describe: () => string;
|
|
673
|
+
/**
|
|
674
|
+
* 用法那一行。今天只有一个调用点:`/goal budget 三轮` 这种数字没看懂的情况。
|
|
675
|
+
*
|
|
676
|
+
* 单独一格而不是复用 `describe()`:那时候用户要的不是「当前目标怎么样」,
|
|
677
|
+
* 是「我刚才那句该怎么敲」。印错一个的代价是他把状态又读一遍,然后再错一次。
|
|
678
|
+
*/
|
|
679
|
+
usage: () => string;
|
|
680
|
+
/** 建一个(人专用)。已经有一个没结束的目标时 `ok: false` */
|
|
681
|
+
create: (objective: string) => GoalActionResult;
|
|
682
|
+
/** 改正文。不重置轮次,也不改状态 */
|
|
683
|
+
edit: (objective: string) => GoalActionResult;
|
|
684
|
+
/** 改预算(轮数)。调到比已用还小时当场变成「预算用完了」 */
|
|
685
|
+
budget: (rounds: number) => GoalActionResult;
|
|
686
|
+
pause: () => GoalActionResult;
|
|
687
|
+
resume: () => GoalActionResult;
|
|
688
|
+
/** 人替模型报完成。依据必填 —— 那段话是回头核对的唯一凭据 */
|
|
689
|
+
done: (evidence: string) => GoalActionResult;
|
|
690
|
+
clear: () => GoalActionResult;
|
|
691
|
+
/**
|
|
692
|
+
* 卡住时那一行开机提示;没卡住(或没有目标)时 null。
|
|
693
|
+
*
|
|
694
|
+
* 宿主(`tui-entry`)把它接在 `startupNotices` 后面 —— 这是验收 8:
|
|
695
|
+
* 一个无人值守跑到一半卡住的任务,人回来打开 TUI 时**第一屏就该看见**
|
|
696
|
+
* 卡在哪,而不是要先想起来敲一条命令。
|
|
697
|
+
*/
|
|
698
|
+
blockedNotice: () => string | null;
|
|
699
|
+
};
|
|
700
|
+
/**
|
|
701
|
+
* 把一次「永久允许」渲染成可提交进 git 的规则字符串(方案 22 §2.7)。
|
|
702
|
+
*
|
|
703
|
+
* 审批缓存是**本机的**、不进 git、换台机器就没了;规则是能提交的。
|
|
704
|
+
* 两者今天完全没有桥,用户想让规则生效只能自己去读文档猜语法。
|
|
705
|
+
* 反推不出有意义的规则时回 `null`(宿主据此不显示提示,而不是显示一条死规则)。
|
|
706
|
+
*/
|
|
707
|
+
suggestRule?: (request: ApprovalRequest) => {
|
|
708
|
+
rule: string;
|
|
709
|
+
hint: string;
|
|
710
|
+
} | null;
|
|
711
|
+
}
|
|
712
|
+
/** 选择框的共同部分 —— 单选和多选只差 `onPick` 的入参 */
|
|
713
|
+
interface PickerBase {
|
|
714
|
+
title: string;
|
|
715
|
+
/**
|
|
716
|
+
* 副标题。结构化提问(方案 34)拿它放「第 2/4 问 · 缓存后端」——
|
|
717
|
+
* 四个问题逐个走完时,用户必须看得出「还有几个」,否则第二个弹窗出现的那一刻
|
|
718
|
+
* 他会以为自己刚才点错了。
|
|
719
|
+
*/
|
|
720
|
+
subtitle?: string;
|
|
721
|
+
options: Array<SelectOption<string>>;
|
|
722
|
+
initialIndex?: number;
|
|
723
|
+
/**
|
|
724
|
+
* 在列表末尾自动追加一个「其他…」,选中后就地敲字(方案 34 验收 3)。
|
|
725
|
+
*
|
|
726
|
+
* **由宿主提供而不是让调用方自己加一个选项**:那样每个调用方都得记得加,
|
|
727
|
+
* 忘了的表现是用户答不上来只能按 Esc —— 而 Esc 的语义是「跳过」,
|
|
728
|
+
* 对模型来说和「用户写了别的」完全不同。
|
|
729
|
+
*/
|
|
730
|
+
allowFreeInput?: boolean;
|
|
731
|
+
/** Esc。不给时 App 只收掉弹窗,不通知发起方 */
|
|
732
|
+
onCancel?: () => void;
|
|
733
|
+
}
|
|
734
|
+
/** 命令要弹选择框时提交的请求(单选,默认形态) */
|
|
735
|
+
interface SinglePickerRequest extends PickerBase {
|
|
736
|
+
multiSelect?: false;
|
|
737
|
+
/** 选中的 label;用户走自由输入时是他打的原文 */
|
|
738
|
+
onPick: (value: string) => void;
|
|
739
|
+
}
|
|
740
|
+
/** 多选(方案 34 验收 2)。答案是数组,一项都没勾时是空数组 —— 那也是个合法答案 */
|
|
741
|
+
interface MultiPickerRequest extends PickerBase {
|
|
742
|
+
multiSelect: true;
|
|
743
|
+
onPick: (values: string[]) => void;
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* 选择框请求。
|
|
747
|
+
*
|
|
748
|
+
* 做成判别联合而不是「`onPick: (v: string | string[]) => void` 一把抓」:
|
|
749
|
+
* 后者会让每个既有调用方(`/permission`、`/plugin`、`/resume`)都得在回调里
|
|
750
|
+
* 先 `Array.isArray` 一次,而它们压根不可能收到数组。判别符让「这个框是几选」
|
|
751
|
+
* 这件事在**声明处**就定死,回调的入参类型跟着自动收窄。
|
|
752
|
+
*/
|
|
753
|
+
type PickerRequest = SinglePickerRequest | MultiPickerRequest;
|
|
754
|
+
/** 提交一条消息时的额外约束(方案 23:自定义命令可以收窄这一轮的工具表) */
|
|
755
|
+
interface SubmitOptions {
|
|
756
|
+
/**
|
|
757
|
+
* 这一轮对模型可见的工具白名单。不给 = 不收窄。
|
|
758
|
+
*
|
|
759
|
+
* 真正的收窄发生在引擎侧(`runtime.commands.scope()`),这里只是把意图
|
|
760
|
+
* 一路带过去 —— TUI 自己没有工具表,也不该有。
|
|
761
|
+
*/
|
|
762
|
+
allowedTools?: readonly string[];
|
|
763
|
+
/**
|
|
764
|
+
* 只在这一轮生效的模型(命令 frontmatter 的 `model:`,方案 26 验收 #9)。
|
|
765
|
+
*
|
|
766
|
+
* 和 `allowedTools` 同一个形状、同一条理由:解析(含 `utility` 关键字)在
|
|
767
|
+
* 引擎侧做完了,TUI 只负责把它原样带到 `onRun`,还原也在那一侧无条件发生。
|
|
768
|
+
*/
|
|
769
|
+
model?: ModelRef;
|
|
770
|
+
}
|
|
771
|
+
/** 命令能改的 UI 开关 */
|
|
772
|
+
interface UiToggles {
|
|
773
|
+
/** 是否把本轮 reasoning 原文落进历史 */
|
|
774
|
+
showThoughts: boolean;
|
|
775
|
+
setShowThoughts: (v: boolean) => void;
|
|
776
|
+
}
|
|
777
|
+
interface SlashCommandContext {
|
|
778
|
+
/** 命令名之后的原始参数串(已 trim,可能为空) */
|
|
779
|
+
args: string;
|
|
780
|
+
/** 往历史里追加消息 */
|
|
781
|
+
push: (item: TuiHistoryItemWithoutId) => void;
|
|
782
|
+
/** 清空历史 */
|
|
783
|
+
clear: () => void;
|
|
784
|
+
/** 退出 TUI */
|
|
785
|
+
exit: () => void;
|
|
786
|
+
/** 弹选择框 */
|
|
787
|
+
pick: (req: PickerRequest) => void;
|
|
788
|
+
/**
|
|
789
|
+
* 打开回退面板(方案 27 PR-3)。
|
|
790
|
+
*
|
|
791
|
+
* **没有走 `pick`**:那个是「从一组固定选项里选一个」,而回退是一台状态机 ——
|
|
792
|
+
* 选检查点 → 逐个勾要覆盖的冲突文件 → 选范围 → 对话那半再确认一次不可撤销。
|
|
793
|
+
* 用一串嵌套的 `pick` 拼出来,等于把「默认一个冲突都不覆盖」这条安全判定
|
|
794
|
+
* 摊在四层回调里。
|
|
795
|
+
*
|
|
796
|
+
* 和 `clear` / `exit` 同一个形状:面板的开关状态住在 `App`,命令只按门铃。
|
|
797
|
+
* Esc Esc 按的是同一个门铃 —— **一个面板,两个入口**。
|
|
798
|
+
*/
|
|
799
|
+
openRewind: () => void;
|
|
800
|
+
/**
|
|
801
|
+
* 请求一次审批,弹出的确认框和模型那条路**完全相同**。
|
|
802
|
+
*
|
|
803
|
+
* 有这一条,命令才不会成为绕过权限的后门:`/memory add` 往磁盘写东西,
|
|
804
|
+
* 和 `#` 速记、和模型自己调 `memory` 工具,在危险性上没有区别。
|
|
805
|
+
* 实现就是 `useAgentRun` 的 `requestApproval` —— 一个来源,一种弹窗。
|
|
806
|
+
*/
|
|
807
|
+
approve: (request: ApprovalRequest) => Promise<ApprovalOutcome>;
|
|
808
|
+
/**
|
|
809
|
+
* 把一段文本当成用户消息发出去(方案 23 的自定义命令走这条)。
|
|
810
|
+
*
|
|
811
|
+
* 内置命令一个都不用它 —— 它们是「在 TUI 里做一件事」,而自定义命令是
|
|
812
|
+
* 「替用户敲一段话」。两者共用同一个注册表但走不同的出口,这是刻意的:
|
|
813
|
+
* 命令**不是**新的 agent 类型,就是一次预置的 turn。
|
|
814
|
+
*/
|
|
815
|
+
submitPrompt: (text: string, opts?: SubmitOptions) => void;
|
|
816
|
+
/** 宿主能力 */
|
|
817
|
+
host: HostActions;
|
|
818
|
+
/** 会话与配置的只读快照 */
|
|
819
|
+
info: {
|
|
820
|
+
sessionId: string;
|
|
821
|
+
model: string;
|
|
822
|
+
cwd: string;
|
|
823
|
+
contextLimit: number;
|
|
824
|
+
permissionLevel: string;
|
|
825
|
+
totalInputTokens: number;
|
|
826
|
+
totalOutputTokens: number;
|
|
827
|
+
costUsd?: number;
|
|
828
|
+
};
|
|
829
|
+
/** UI 开关 */
|
|
830
|
+
toggles: UiToggles;
|
|
831
|
+
/** 全部命令,供 /help 自我描述 */
|
|
832
|
+
commands: readonly SlashCommand[];
|
|
833
|
+
/**
|
|
834
|
+
* 当前显示历史(已落定的 + 还挂在 pending 区的)。
|
|
835
|
+
*
|
|
836
|
+
* `/copy` 要从里面往回找最后一条模型回答。含 pending 是刻意的:
|
|
837
|
+
* 刚说完的那句还没落进 `<Static>` 就敲 `/copy`,用户要的显然是它 ——
|
|
838
|
+
* `Ctrl+O` 找 artifact 时用的也是同一个合并口径。
|
|
839
|
+
*/
|
|
840
|
+
history: readonly TuiHistoryItemWithoutId[];
|
|
841
|
+
}
|
|
842
|
+
interface SlashCommand {
|
|
843
|
+
/** 不带斜杠的命令名 */
|
|
844
|
+
name: string;
|
|
845
|
+
/** 别名,同样不带斜杠 */
|
|
846
|
+
aliases?: string[];
|
|
847
|
+
/** 一行说明,出现在补全面板和 /help 里 */
|
|
848
|
+
description: string;
|
|
849
|
+
/** 参数占位符,例如 `[level]`;只用于展示 */
|
|
850
|
+
argHint?: string;
|
|
851
|
+
/**
|
|
852
|
+
* 来源。内置命令不写这个字段;自定义命令写 `'user'` / `'project'`。
|
|
853
|
+
*
|
|
854
|
+
* `/help` 靠它把两类分开列 —— 用户看见一条不认识的命令时,第一个问题
|
|
855
|
+
* 永远是「这是哪来的」。
|
|
856
|
+
*/
|
|
857
|
+
source?: CustomCommandSource;
|
|
858
|
+
/**
|
|
859
|
+
* 这条命令要不要注册。不给 = 永远注册。
|
|
860
|
+
*
|
|
861
|
+
* 判据只看**宿主注入了哪些能力**,因为那是唯一「这台机器上到底有没有这个东西」
|
|
862
|
+
* 的真源。缺能力时报一句「不可用」和压根不出现,是两件不同的事:
|
|
863
|
+
* 前者适合「本该有、这次没起来」(`/tools` 在 provider 挂掉时仍该在),
|
|
864
|
+
* 后者适合「这个版本就没有这个功能」(方案 22 PR-4 之前的 `/permissions`)。
|
|
865
|
+
* 后者要是注册了,它会出现在 `/help` 和补全面板里 —— 那是在承诺一个不存在的功能。
|
|
866
|
+
*/
|
|
867
|
+
available?: (host: HostActions) => boolean;
|
|
868
|
+
/**
|
|
869
|
+
* 执行。可以是异步的 —— `/copy` `/diff` `/export` 都要等 IO。
|
|
870
|
+
*
|
|
871
|
+
* 返回 promise 时调用方会接住 rejection 并 push 成一条 error 历史项,
|
|
872
|
+
* 和同步抛出走同一个出口。**别在实现里自己 catch 完就吞掉**,
|
|
873
|
+
* 那样错误既不进历史也不进日志。
|
|
874
|
+
*/
|
|
875
|
+
run: (ctx: SlashCommandContext) => void | Promise<void>;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* 共享状态类型 — 对标 Gemini CLI StreamingState + types。
|
|
880
|
+
* @license Apache-2.0 (adapted)
|
|
881
|
+
* Copyright 2025 Google LLC
|
|
882
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
883
|
+
* Modifications Copyright 2024-2026 bowen
|
|
884
|
+
*/
|
|
885
|
+
|
|
886
|
+
/** 流式状态三态 [Gemini CLI StreamingState] */
|
|
887
|
+
declare enum StreamingState {
|
|
888
|
+
Idle = "idle",
|
|
889
|
+
Responding = "responding",
|
|
890
|
+
WaitingForConfirmation = "waiting_for_confirmation"
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* 一次等待用户答复的审批。
|
|
894
|
+
*
|
|
895
|
+
* 携带完整的 `ApprovalRequest` 而不是 `{ toolName, args: unknown }`:
|
|
896
|
+
* 弹窗要分字段渲染「谁 / 干什么 / 目标 / 原因」,拿一个 unknown 只能
|
|
897
|
+
* `JSON.stringify` 出来给人看序列化对象。
|
|
898
|
+
*
|
|
899
|
+
* `respond` 收 `ApprovalOutcome` 而不是 boolean:协议有四种答复,砍成两种
|
|
900
|
+
* 等于把 core 的审批缓存(allow-session / allow-always)废掉。
|
|
901
|
+
*
|
|
902
|
+
* 第二个参数 `note` 只有计划审批的 `plan-revise` 会带(用户的修改意见)。
|
|
903
|
+
* `request.plan` 存在时弹的是计划审批框,那时四个选项讲的是**接下来怎么走**,
|
|
904
|
+
* 不是授权范围 —— 见 `PlanConfirmation`。
|
|
905
|
+
*/
|
|
906
|
+
interface ConfirmationRequest {
|
|
907
|
+
request: ApprovalRequest;
|
|
908
|
+
respond: (outcome: ApprovalOutcome, note?: string) => void;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* 跑一轮的入口。
|
|
913
|
+
*
|
|
914
|
+
* 入参是 `EpochUserContent`(`string | 部件数组`)而不是 `string`:Ctrl+V 粘进来的
|
|
915
|
+
* 图片必须跟着这一次提交走到引擎。**不放宽成联合类型**是不行的 —— 历史项、
|
|
916
|
+
* 队列、debug 日志全都只要文本,它们统一走 `contentToText()` 取投影。
|
|
917
|
+
*/
|
|
918
|
+
type RunFn = (message: EpochUserContent, opts: {
|
|
919
|
+
signal: AbortSignal;
|
|
920
|
+
} & SubmitOptions) => AsyncGenerator<AgentEvent>;
|
|
921
|
+
|
|
922
|
+
interface AppProps {
|
|
923
|
+
welcomeMessage?: string;
|
|
924
|
+
/** 启动诊断,作为首屏的 info 项显示(配置写错了必须看得见) */
|
|
925
|
+
startupNotices?: string[];
|
|
926
|
+
onRun?: RunFn;
|
|
927
|
+
onExit?: () => void;
|
|
928
|
+
/** 宿主注入的引擎能力,供斜杠命令使用 */
|
|
929
|
+
host?: HostActions;
|
|
930
|
+
/** 宿主加载好的自定义斜杠命令(方案 23)。不给就只有内置那几条 */
|
|
931
|
+
customCommands?: readonly CustomCommandDef[];
|
|
932
|
+
/**
|
|
933
|
+
* 生效的键位表(方案 31)。**必须是已经校验过的那一份** ——
|
|
934
|
+
* 读盘 / zod / 保留键 / 冲突检测都在宿主那一侧(`core/src/config/keybindings.ts`,
|
|
935
|
+
* 由 `cli/src/tui-entry.ts` 调),tui 只消费。
|
|
936
|
+
*
|
|
937
|
+
* 不给就是默认表,也就是「用户没配过」和「宿主不支持配」走同一条路 ——
|
|
938
|
+
* 行为与今天逐键一致。
|
|
939
|
+
*/
|
|
940
|
+
keybindings?: KeybindingTable;
|
|
941
|
+
}
|
|
942
|
+
declare function App({ welcomeMessage, startupNotices, onRun, onExit, host, customCommands, keybindings, }: AppProps): React__default.ReactElement;
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* 工具调用的一行摘要 —— 纯函数,可单测。
|
|
946
|
+
*
|
|
947
|
+
* 旧版历史项只画一个工具名(`✓ read_file`),看不出读的哪个文件、跑的哪条命令,
|
|
948
|
+
* 也就没法回头核对 agent 到底干了什么。
|
|
949
|
+
*
|
|
950
|
+
* 参数名不受我们完全控制(MCP 工具的 schema 是别人定的),所以是**候选名
|
|
951
|
+
* 依次尝试 + 兜底**,而不是每个工具写一条规则:加一个 MCP server 不该需要改这里。
|
|
952
|
+
*/
|
|
953
|
+
/**
|
|
954
|
+
* 取出最能代表这次调用的那个参数。
|
|
955
|
+
*
|
|
956
|
+
* 候选名都没命中时退回「第一个能折成字符串的参数」——总比什么都不显示好,
|
|
957
|
+
* 但**不**退回整个 JSON:那就变回了旧版弹窗里那种没人读的序列化对象。
|
|
958
|
+
*/
|
|
959
|
+
declare function primaryArg(input: unknown): string | undefined;
|
|
960
|
+
/** `read_file(src/a.ts)`;取不到主参数时退回单纯的工具名 */
|
|
961
|
+
declare function formatToolCall(toolName: string, input: unknown): string;
|
|
962
|
+
|
|
963
|
+
/** 人类可读的字节数。core 里那份是给模型看的,这份是给终端看的,各在各的包里 */
|
|
964
|
+
declare function formatArtifactBytes(bytes: number): string;
|
|
965
|
+
/**
|
|
966
|
+
* `🖼 1920×1080 PNG 340.0 KB · shot-a1b2.png`
|
|
967
|
+
*
|
|
968
|
+
* 被闸门拦下(没有 `data`)时补一句「未进入上下文」:这是用户最需要知道的一件事 ——
|
|
969
|
+
* 模型**没看见**这张图,它后面的回答不是基于图片内容的。
|
|
970
|
+
*/
|
|
971
|
+
declare function formatArtifact(artifact: ToolArtifact): string;
|
|
972
|
+
/** 值得被「打开」的产物:落了盘的图片。真正能不能打开由 core 说了算 */
|
|
973
|
+
declare function isOpenableArtifact(artifact: ToolArtifact): boolean;
|
|
974
|
+
/**
|
|
975
|
+
* 从历史里找**最近**一个能打开的产物。
|
|
976
|
+
*
|
|
977
|
+
* 从后往前扫历史而不是单独存一份状态:历史本来就是唯一的真源,
|
|
978
|
+
* 另开一个 `artifacts` 数组就得同步清屏、中断、重挂三条路径。
|
|
979
|
+
*/
|
|
980
|
+
declare function lastOpenableArtifact(items: readonly TuiHistoryItemWithoutId[]): ToolArtifact | undefined;
|
|
981
|
+
|
|
982
|
+
interface AppState {
|
|
983
|
+
version: string;
|
|
984
|
+
cwd: string;
|
|
985
|
+
}
|
|
986
|
+
declare const AppContext: React.Context<AppState | null>;
|
|
987
|
+
declare const useAppContext: () => AppState;
|
|
988
|
+
|
|
989
|
+
interface ConfigState {
|
|
990
|
+
model: string;
|
|
991
|
+
workDir: string;
|
|
992
|
+
permissionLevel: string;
|
|
993
|
+
contextLimit: number;
|
|
994
|
+
/** 是否使用背景色渲染消息气泡(stub: 始终返回 false) */
|
|
995
|
+
getUseBackgroundColor: () => boolean;
|
|
996
|
+
}
|
|
997
|
+
declare const ConfigContext: React.Context<ConfigState | undefined>;
|
|
998
|
+
declare const useConfig: () => ConfigState;
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* SessionContext — 会话统计。
|
|
1002
|
+
*
|
|
1003
|
+
* `usage` 事件里的 `cumulative` 来自 `BudgetGuard`,它有**两种口径**:接上
|
|
1004
|
+
* `BudgetStore` 时跨轮累加(常态),store 起不来时每轮从零开始。宿主必须按对应
|
|
1005
|
+
* 口径记账 —— 拿「每轮最终值相加」去算 session 口径会重复计,拿「最新值当总量」
|
|
1006
|
+
* 去算 run 口径会少计。
|
|
1007
|
+
*
|
|
1008
|
+
* **口径不能从事件流里猜**:第二轮的第一个累计值比第一轮的末值大,既可能是接着
|
|
1009
|
+
* 涨,也可能是重新开始后恰好超过。所以由 runtime 显式告知(`EpochRuntime.usageScope`
|
|
1010
|
+
* → `ConfigState.usageScope` → 这里)。曾经试过用「变小就当重置」的启发式,
|
|
1011
|
+
* 在 run 口径下(1000 → 1200)直接算错。
|
|
1012
|
+
*
|
|
1013
|
+
* `lastInputTokens`(状态栏 ctx% 用的那个)在两种口径下都是相邻两次 `usage` 的
|
|
1014
|
+
* **提示词规模**差值 —— 也就是最后那次请求实际送进去多少 token。直接拿累计值算
|
|
1015
|
+
* ctx% 会随轮数一路涨到 100% 再也不下来。
|
|
1016
|
+
*
|
|
1017
|
+
* ⚠️ 这里说的是 `promptTokens(usage)` 而**不是** `usage.inputTokens`:后者按
|
|
1018
|
+
* 方案 42 PR-0 的口径只装**非缓存**那一份。开了 prompt caching 的 provider 上,
|
|
1019
|
+
* 一段 6k 的上下文可能只有 110 个非缓存 token,拿它算 ctx% 会显示 0% ——
|
|
1020
|
+
* 而缓存命中的那 6k 照样占着窗口。
|
|
1021
|
+
*
|
|
1022
|
+
* @license Apache-2.0 (adapted from google-gemini/gemini-cli SessionStatsProvider)
|
|
1023
|
+
* Copyright 2025 Google LLC
|
|
1024
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
1025
|
+
* Modifications Copyright 2024-2026 bowen
|
|
1026
|
+
*/
|
|
1027
|
+
|
|
1028
|
+
/** `usage.cumulative` 的口径,来自 `EpochRuntime.usageScope` */
|
|
1029
|
+
type UsageScope = 'session' | 'run';
|
|
1030
|
+
/** 预算剩余额度;对应上限没配时字段缺席 */
|
|
1031
|
+
interface BudgetRemaining {
|
|
1032
|
+
costUsd?: number;
|
|
1033
|
+
tokens?: number;
|
|
1034
|
+
}
|
|
1035
|
+
interface SessionStatsState {
|
|
1036
|
+
sessionId: string;
|
|
1037
|
+
/** 会话累计(增量累加得来) */
|
|
1038
|
+
totalInputTokens: number;
|
|
1039
|
+
totalOutputTokens: number;
|
|
1040
|
+
/**
|
|
1041
|
+
* 最近一次 LLM 请求的 input tokens —— 这个才代表当前上下文占用。
|
|
1042
|
+
* 状态栏的 ctx% 必须用它。
|
|
1043
|
+
*/
|
|
1044
|
+
lastInputTokens: number;
|
|
1045
|
+
/** 会话累计成本。**从来没拿到过定价数据时为 undefined,不是 0** */
|
|
1046
|
+
totalCostUsd?: number;
|
|
1047
|
+
/** 本轮(自 startRun 起)产出的 output tokens,状态行显示实时进度用 */
|
|
1048
|
+
runOutputTokens: number;
|
|
1049
|
+
/** 本轮最近一次 usage 事件的原始累计值。**是引擎口径的累计,不是本轮用量** */
|
|
1050
|
+
runUsage?: TokenUsage;
|
|
1051
|
+
/** 预算余额,来自 usage 事件;没配预算时为 undefined */
|
|
1052
|
+
budgetRemaining?: BudgetRemaining;
|
|
1053
|
+
promptCount: number;
|
|
1054
|
+
}
|
|
1055
|
+
interface SessionContextValue {
|
|
1056
|
+
stats: SessionStatsState;
|
|
1057
|
+
/** 新一轮开始:清掉上一轮的实时用量 */
|
|
1058
|
+
startRun: () => void;
|
|
1059
|
+
/** usage / finish 事件:按增量入账 */
|
|
1060
|
+
applyUsage: (cumulative: TokenUsage, budgetRemaining?: BudgetRemaining) => void;
|
|
1061
|
+
/** 本轮收尾。`finish.usage` 通常与最后一个 usage 事件相同,此时增量为 0,不会重复计 */
|
|
1062
|
+
endRun: (usage: TokenUsage) => void;
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* 相邻两次累计值之间的增量 = 最近一次 LLM 请求的真实用量。
|
|
1066
|
+
*
|
|
1067
|
+
* 一律 clamp 到 ≥0:口径是由 `usageScope` 决定的,真出现倒退(引擎重置了计数器)
|
|
1068
|
+
* 时把它当 0 处理,宁可少算一次也不要把总量算成负数。
|
|
1069
|
+
*/
|
|
1070
|
+
declare function deltaOf(prev: TokenUsage, next: TokenUsage): TokenUsage;
|
|
1071
|
+
declare const SessionStatsProvider: React__default.FC<{
|
|
1072
|
+
children: React__default.ReactNode;
|
|
1073
|
+
sessionId: string;
|
|
1074
|
+
/** `usage.cumulative` 的口径;不给时按常态(接了 store)算 */
|
|
1075
|
+
usageScope?: UsageScope;
|
|
1076
|
+
}>;
|
|
1077
|
+
declare const useSessionStats: () => SessionContextValue;
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* renderApp — 挂载 TUI。
|
|
1081
|
+
*
|
|
1082
|
+
* provider 树和 render options 收在这里,因为它们是 UI 自己的事:
|
|
1083
|
+
* `exitOnCtrlC: false` 是「Ctrl+C 按两次才退出」的前提(ink 默认会自己吞掉
|
|
1084
|
+
* \x03 直接结束进程,App 里的 useInput 根本收不到),只能在 render() 处传。
|
|
1085
|
+
*/
|
|
1086
|
+
|
|
1087
|
+
interface RenderAppOptions extends AppProps {
|
|
1088
|
+
sessionId: string;
|
|
1089
|
+
app: AppState;
|
|
1090
|
+
config: ConfigState;
|
|
1091
|
+
/**
|
|
1092
|
+
* `usage` 事件里 `cumulative` 的口径,来自 `EpochRuntime.usageScope`。
|
|
1093
|
+
* 不给按 `'session'`(接了 BudgetStore 的常态)算 —— 见 session-context 的文件头。
|
|
1094
|
+
*/
|
|
1095
|
+
usageScope?: UsageScope;
|
|
1096
|
+
}
|
|
1097
|
+
declare function renderApp({ sessionId, app, config, usageScope, ...appProps }: RenderAppOptions): Instance;
|
|
1098
|
+
|
|
1099
|
+
interface BannerProps {
|
|
1100
|
+
/** 终端宽度;分隔线要扣掉容器的左右 padding,否则宽度 = columns + 2 会折行多出一行 */
|
|
1101
|
+
width?: number;
|
|
1102
|
+
}
|
|
1103
|
+
declare function Banner({ width }: BannerProps): React__default.ReactElement;
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* 注册表。顺序即 /help 和补全面板的展示顺序。
|
|
1107
|
+
*
|
|
1108
|
+
* 按用途分了段:会话 → 引擎内省 → 仓库 → 回退 → 开关。后面几个分组住在各自的
|
|
1109
|
+
* 文件里(方案 25 PR-4 起),因为这个文件加完九条命令就会破 500 行的硬线。
|
|
1110
|
+
*
|
|
1111
|
+
* ⚠️ 这份表是**全集**,不是「这台机器上能用的」。按宿主能力过滤发生在
|
|
1112
|
+
* `useSlashCommands` 合并那一步 —— 见 `availableCommands()`。
|
|
1113
|
+
* 保留名检查(方案 23)要的正是全集:一条命令这次没注册,
|
|
1114
|
+
* 不代表自定义命令就可以占用它的名字,否则升个版就撞名了。
|
|
1115
|
+
*/
|
|
1116
|
+
declare const BUILTIN_COMMANDS: readonly SlashCommand[];
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* 斜杠命令的解析与匹配 —— 纯函数,不碰 React,可直接单测。
|
|
1120
|
+
*
|
|
1121
|
+
* 判定「这行输入是不是命令」必须严格:`/` 开头**且**第一行不含空格之前的部分
|
|
1122
|
+
* 能匹配上某个已注册命令。否则用户想问「/etc/hosts 是什么」会被当成未知命令。
|
|
1123
|
+
* 这也是 Claude Code 的行为 —— 认不出来的斜杠输入按普通消息发出去。
|
|
1124
|
+
*/
|
|
1125
|
+
|
|
1126
|
+
interface ParsedSlashInput {
|
|
1127
|
+
/** 命令名(小写,不带斜杠) */
|
|
1128
|
+
name: string;
|
|
1129
|
+
/** 命令名之后的原始参数串(已 trim) */
|
|
1130
|
+
args: string;
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* 把一行输入拆成命令名 + 参数。不是斜杠开头时返回 null。
|
|
1134
|
+
*
|
|
1135
|
+
* 只看**第一行**:多行输入里第一行是 `/clear` 而后面还有正文,说明用户是想
|
|
1136
|
+
* 发一段以斜杠开头的文本,不是执行命令。
|
|
1137
|
+
*/
|
|
1138
|
+
declare function parseSlashInput(text: string): ParsedSlashInput | null;
|
|
1139
|
+
/** 按名字或别名精确找命令 */
|
|
1140
|
+
declare function findCommand(commands: readonly SlashCommand[], name: string): SlashCommand | undefined;
|
|
1141
|
+
/**
|
|
1142
|
+
* 补全用的前缀匹配。
|
|
1143
|
+
*
|
|
1144
|
+
* `/` 单独一个字符时列出全部命令(用户刚敲下斜杠,正等着看有什么可用)。
|
|
1145
|
+
* 已经带了参数(有空格)时不再补全 —— 那时候用户在填参数,不该再弹列表。
|
|
1146
|
+
*/
|
|
1147
|
+
declare function matchCommands(commands: readonly SlashCommand[], text: string): readonly SlashCommand[];
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* 默认绑定表 —— **就是这一轮之前硬编码在 app.tsx / input-box.tsx 里的那些键**。
|
|
1151
|
+
*
|
|
1152
|
+
* 这一点是本 PR 唯一能证明「抽了一层但没改行为」的东西(方案 §4.1 #1:无配置文件
|
|
1153
|
+
* 时逐键一致)。所以改这张表 = 改产品行为,不是改实现细节 —— 动之前先问一句这是
|
|
1154
|
+
* 不是本来就想改的那件事。守卫用例在
|
|
1155
|
+
* [keybindings.test.ts](../../__tests__/keybindings.test.ts):它逐条对着改造前的
|
|
1156
|
+
* 判断列了一遍;而 `app.test.tsx` / `dialogs.test.tsx` 那些从 stdin 打真键的用例
|
|
1157
|
+
* 是另一道 —— 那些用例一个字都没改就绿了,才是「行为没动」的真凭据。
|
|
1158
|
+
*
|
|
1159
|
+
* ## 这张表是**真源**,加载期靠参数拿到它
|
|
1160
|
+
*
|
|
1161
|
+
* `core/src/config/keybindings.ts` 要拿它当底表(用户配置逐动作覆盖上去),但
|
|
1162
|
+
* core 不许依赖 tui。所以不是 core 去 import,而是 **cli 把它当参数递进去**
|
|
1163
|
+
* (`cli/src/tui-entry.ts`)—— 避免了「同一张表在两个包里各存一份然后漂移」,
|
|
1164
|
+
* 那正是 `RESERVED_COMMAND_NAMES` 今天要靠两条双向用例兜着的形态。
|
|
1165
|
+
*/
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* 默认表。每个动作后面的注释是它在改造前的出处。
|
|
1169
|
+
*
|
|
1170
|
+
* 几处刻意的对齐,别当成笔误:
|
|
1171
|
+
*
|
|
1172
|
+
* - **`line-start` / `line-end` 各有两个键**:改造前就是
|
|
1173
|
+
* `key.home || (key.ctrl && input === 'a')`,一个动作两个键是本方案的原生形态
|
|
1174
|
+
* (§2.2「一个动作可以绑多个键」),不是妥协
|
|
1175
|
+
* - **`newline` 有三个键**:`shift+enter` / `alt+enter` 是改造前
|
|
1176
|
+
* `key.return && (key.meta || key.shift)` 那一支拆出来的两半,`ctrl+j` 是
|
|
1177
|
+
* `input === '\n'` 那一支(见 parser.ts 文件头第 1 条)
|
|
1178
|
+
* - **`rewind` 是空的**。第二下 Esc 开回退面板,靠的是 `app.tsx` 里 500ms 的双击
|
|
1179
|
+
* 窗口(`useRewind`),而**序列语法归 PR-2**。留着这个动作而不是删掉它,是为了
|
|
1180
|
+
* 用户今天就能把回退面板绑到一个单键上(`{"rewind": ["ctrl+shift+r"]}` 就能用,
|
|
1181
|
+
* app.tsx 接了这条);PR-2 再给它 `["escape escape"]` 的默认值
|
|
1182
|
+
* - **`ctrl+c` / `ctrl+d` 在表里**,而且同时是保留键(`RESERVED_CHORDS`)。两件事
|
|
1183
|
+
* 不矛盾:默认表得写出来它们干什么,保留只是不许**改**它们
|
|
1184
|
+
*/
|
|
1185
|
+
declare const DEFAULT_KEYBINDINGS: KeybindingTable;
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* ink 的 `(input, key)` → 一个 {@link KeyChord}。
|
|
1189
|
+
*
|
|
1190
|
+
* 这是 chord 语法**属于 tui 的那一半**:另一半(`"ctrl+l"` 字符串 ↔ chord)在
|
|
1191
|
+
* protocol,因为 core 那侧读配置文件时要用同一份规则。这里之所以不能也放过去 ——
|
|
1192
|
+
* `Key` 是 ink 的类型,而 protocol 是零依赖包。
|
|
1193
|
+
*
|
|
1194
|
+
* ## 归一化的三处硬账
|
|
1195
|
+
*
|
|
1196
|
+
* 1. **`\n` 就是 Ctrl+J**。ink 7 只在收到 `\r` 时置 `key.return`;`\n`(Ctrl+J
|
|
1197
|
+
* 在 ASCII 里就是 0x0A)走 `name='enter'` 但**不置** `key.return`,也不置
|
|
1198
|
+
* `key.ctrl`,于是它到这里只是一个普通的 `input === '\n'`。改造前
|
|
1199
|
+
* input-box.tsx 里那句 `if (input === '\n') buf.newline()` 就是在接它。这里把
|
|
1200
|
+
* 它归成 `ctrl+j`,用户于是可以在配置里写 `"newline": ["ctrl+j"]` —— 名副其实。
|
|
1201
|
+
* 2. **macOS 的 Option+Enter 是 `\x1b\r`**,ink 归成 `return` + `meta`。所以
|
|
1202
|
+
* `alt+enter` 和 `shift+enter` 都落在 `enter` 上,只是修饰位不同。
|
|
1203
|
+
* 3. **字母一律小写**,大小写由 `key.shift` 表达。终端里 Ctrl 组合给的本来就是
|
|
1204
|
+
* 小写字母,而直接敲 `K` 给的是大写 + shift —— 不统一的话 `ctrl+k` 和
|
|
1205
|
+
* `shift+k` 会撞进同一个 id。
|
|
1206
|
+
*/
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* 这一下按键是哪个 chord。判不出主键时返回 undefined —— 调用方据此放行
|
|
1210
|
+
* (比如输入框继续走「把这个字符插进去」那条路)。
|
|
1211
|
+
*
|
|
1212
|
+
* 判不出的两类都是真实存在的:**纯修饰键**(只按住 Ctrl 不按别的,ink 那边
|
|
1213
|
+
* `input` 是空串)和**多字符输入**(粘贴一整段文本,ink 一次给完)。两者都不该
|
|
1214
|
+
* 被当成某个键的绑定 —— 后者尤其要小心:一段以 `l` 开头的粘贴内容不能触发清屏。
|
|
1215
|
+
*/
|
|
1216
|
+
declare function chordOf(input: string, key: Key): KeyChord | undefined;
|
|
1217
|
+
|
|
1218
|
+
/** 这一下按键在给定上下文里是哪个动作;没绑(或判不出主键)返回 undefined */
|
|
1219
|
+
type ActionResolver = (input: string, key: Parameters<typeof chordOf>[1], context: KeyContext) => KeyAction | undefined;
|
|
1220
|
+
/**
|
|
1221
|
+
* @param override 显式的表。**只有 `App` 传它** —— 它是表的持有者(从 prop 收),
|
|
1222
|
+
* 而一个组件读不到自己 `Provider` 里的值。其余组件一律不传,
|
|
1223
|
+
* 从 context 拿,于是「表只有一份」这件事在树上是显然的。
|
|
1224
|
+
*/
|
|
1225
|
+
declare function useKeybinding(override?: KeybindingTable): ActionResolver;
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* StreamingContext — 流式状态(直接抄 Gemini CLI StreamingContext)。
|
|
1229
|
+
* @license Apache-2.0 (adapted from google-gemini/gemini-cli)
|
|
1230
|
+
* Copyright 2025 Google LLC
|
|
1231
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
1232
|
+
* Modifications Copyright 2024-2026 bowen
|
|
1233
|
+
*/
|
|
1234
|
+
|
|
1235
|
+
declare const StreamingContext: React__default.Context<StreamingState | undefined>;
|
|
1236
|
+
declare const useStreamingContext: () => StreamingState;
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* UIStateContext — 核心 UI 状态。
|
|
1240
|
+
*
|
|
1241
|
+
* 只放**跨组件**的状态。原来这里还挂着 `isInputActive` / `showErrorDetails` /
|
|
1242
|
+
* `renderMarkdown` / `elapsedTime` / 三个 `is*DialogOpen`,八个字段没有任何
|
|
1243
|
+
* 读者也没有对应组件 —— 它们不是「预留扩展点」,是照抄 Gemini CLI 的
|
|
1244
|
+
* UIStateContext 时连字段一起抄过来的空壳,读代码的人会以为对话框已经实现了。
|
|
1245
|
+
*
|
|
1246
|
+
* @license Apache-2.0 (adapted from google-gemini/gemini-cli UIStateContext)
|
|
1247
|
+
* Copyright 2025 Google LLC
|
|
1248
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
1249
|
+
* Modifications Copyright 2024-2026 bowen
|
|
1250
|
+
*/
|
|
1251
|
+
|
|
1252
|
+
interface UIState {
|
|
1253
|
+
/** 已完成的历史消息 */
|
|
1254
|
+
history: TuiHistoryItem[];
|
|
1255
|
+
/** 流式渲染中的待定消息 */
|
|
1256
|
+
pendingHistoryItems: TuiHistoryItemWithoutId[];
|
|
1257
|
+
/** 当前流式状态 */
|
|
1258
|
+
streamingState: StreamingState;
|
|
1259
|
+
/** 本轮累加的 reasoning 原文。默认不显示,`/thinking` 打开后收尾时落进历史 */
|
|
1260
|
+
thought: string;
|
|
1261
|
+
/** 队首的工具审批请求 */
|
|
1262
|
+
confirmationRequest: ConfirmationRequest | null;
|
|
1263
|
+
/** 斜杠命令弹出的选择框 */
|
|
1264
|
+
picker: PickerRequest | null;
|
|
1265
|
+
/** 分辨率 */
|
|
1266
|
+
terminalWidth: number;
|
|
1267
|
+
terminalHeight: number;
|
|
1268
|
+
/**
|
|
1269
|
+
* 当前权限级别。
|
|
1270
|
+
*
|
|
1271
|
+
* 不能只读 ConfigContext:`/permission` 能在运行期改它,而 ConfigContext
|
|
1272
|
+
* 是 tui-entry 传进来的一个不变对象,改了状态栏不会跟着变。
|
|
1273
|
+
*/
|
|
1274
|
+
permissionLevel: PermissionLevel;
|
|
1275
|
+
/**
|
|
1276
|
+
* 当前模型和它的上下文窗口(方案 26)。
|
|
1277
|
+
*
|
|
1278
|
+
* 和 `permissionLevel` 同一个理由、同一个坑:`/model` 能在运行期换它。
|
|
1279
|
+
* 两者要**一起**变 —— 换模型窗口也变了,状态栏的 ctx% 是拿窗口当分母算的,
|
|
1280
|
+
* 只改模型名的话进度条会按旧模型的窗口继续画,越换越不准。
|
|
1281
|
+
*/
|
|
1282
|
+
model: string;
|
|
1283
|
+
contextLimit: number;
|
|
1284
|
+
}
|
|
1285
|
+
interface UIStateContextValue {
|
|
1286
|
+
state: UIState;
|
|
1287
|
+
setHistory: React__default.Dispatch<React__default.SetStateAction<TuiHistoryItem[]>>;
|
|
1288
|
+
setPendingItems: React__default.Dispatch<React__default.SetStateAction<TuiHistoryItemWithoutId[]>>;
|
|
1289
|
+
setStreamingState: (s: StreamingState) => void;
|
|
1290
|
+
setThought: (t: string) => void;
|
|
1291
|
+
setConfirmation: (r: ConfirmationRequest | null) => void;
|
|
1292
|
+
setPicker: (p: PickerRequest | null) => void;
|
|
1293
|
+
setTerminalSize: (w: number, h: number) => void;
|
|
1294
|
+
setPermissionLevel: (level: PermissionLevel) => void;
|
|
1295
|
+
/** 模型和窗口一起换,不给单独改模型名的入口——见 UIState.model 的注释 */
|
|
1296
|
+
setModel: (model: string, contextLimit: number) => void;
|
|
1297
|
+
}
|
|
1298
|
+
declare const UIStateProvider: React__default.FC<{
|
|
1299
|
+
children: React__default.ReactNode;
|
|
1300
|
+
/** 初始权限级别,来自配置 */
|
|
1301
|
+
initialPermissionLevel?: PermissionLevel;
|
|
1302
|
+
/** 初始模型与窗口,来自配置 */
|
|
1303
|
+
initialModel?: string;
|
|
1304
|
+
initialContextLimit?: number;
|
|
1305
|
+
}>;
|
|
1306
|
+
declare const useUIState: () => UIStateContextValue;
|
|
1307
|
+
|
|
1308
|
+
interface TextBufferState {
|
|
1309
|
+
/** 逻辑行(不含换行符) */
|
|
1310
|
+
lines: string[];
|
|
1311
|
+
/** 光标所在行 */
|
|
1312
|
+
row: number;
|
|
1313
|
+
/** 光标在行内的 code-point 偏移 */
|
|
1314
|
+
col: number;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
interface TextBufferAPI {
|
|
1318
|
+
/** 当前完整文本 */
|
|
1319
|
+
text: string;
|
|
1320
|
+
lines: string[];
|
|
1321
|
+
row: number;
|
|
1322
|
+
col: number;
|
|
1323
|
+
isMultiline: boolean;
|
|
1324
|
+
isEmpty: boolean;
|
|
1325
|
+
/** 光标是否在第一行 / 最后一行(决定 ↑↓ 是行间移动还是翻历史) */
|
|
1326
|
+
atFirstRow: boolean;
|
|
1327
|
+
atLastRow: boolean;
|
|
1328
|
+
insert: (chunk: string) => void;
|
|
1329
|
+
newline: () => void;
|
|
1330
|
+
backspace: () => void;
|
|
1331
|
+
deleteChar: () => void;
|
|
1332
|
+
moveLeft: () => void;
|
|
1333
|
+
moveRight: () => void;
|
|
1334
|
+
moveUp: () => void;
|
|
1335
|
+
moveDown: () => void;
|
|
1336
|
+
moveLineStart: () => void;
|
|
1337
|
+
moveLineEnd: () => void;
|
|
1338
|
+
deleteWordLeft: () => void;
|
|
1339
|
+
killToLineStart: () => void;
|
|
1340
|
+
killToLineEnd: () => void;
|
|
1341
|
+
/** 取出文本并清空(同步返回真实文本) */
|
|
1342
|
+
take: () => string;
|
|
1343
|
+
setText: (text: string) => void;
|
|
1344
|
+
clear: () => void;
|
|
1345
|
+
}
|
|
1346
|
+
declare function useTextBuffer(): TextBufferAPI;
|
|
1347
|
+
|
|
1348
|
+
interface ClampResult {
|
|
1349
|
+
/** 裁剪后的文本 */
|
|
1350
|
+
text: string;
|
|
1351
|
+
/** 被隐藏的视觉行数(0 表示没裁剪) */
|
|
1352
|
+
hiddenLines: number;
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* 把文本裁剪到最多 `maxLines` 个视觉行,保留**尾部**。
|
|
1356
|
+
*
|
|
1357
|
+
* 流式渲染期间必须限制动态帧的高度:Ink 的 log-update 只能擦除它上一帧写过的
|
|
1358
|
+
* 行数,一旦帧比终端还高、内容滚出屏幕,擦除范围就对不上,屏幕上会残留重复行。
|
|
1359
|
+
* 保留尾部是因为流式过程中用户关心的是最新产出的内容。
|
|
1360
|
+
* 对应 Gemini CLI `MaxSizedBox` 的 `overflowDirection='top'`。
|
|
1361
|
+
*/
|
|
1362
|
+
declare function clampTailLines(text: string, width: number, maxLines: number): ClampResult;
|
|
1363
|
+
|
|
1364
|
+
export { type ActionResolver, App, AppContext, type AppProps, type AppState, BUILTIN_COMMANDS, Banner, type BudgetRemaining, type CheckpointActions, type CheckpointEntry, type ClampResult, type ClipboardImagePart, ConfigContext, type ConfigState, type ConfirmationRequest, DEFAULT_KEYBINDINGS, type GoalActionResult, type HistoryItemPlan, type HostActions, type ManagedInfo, type PickerRequest, type PluginInfo, type RenderAppOptions, type RewindFileAction, type RewindFileDrift, type RewindFileEntry, type RewindPlan, type RewindReport, type RewindScope, type RewindSummary, SessionStatsProvider, type SessionStatsState, type SlashCommand, type SlashCommandContext, StreamingContext, StreamingState, type TextBufferAPI, type TextBufferState, type TuiHistoryItem, type TuiHistoryItemWithoutId, type UIState, UIStateProvider, type UsageScope, clampTailLines, deltaOf, findCommand, formatArtifact, formatArtifactBytes, formatToolCall, isOpenableArtifact, lastOpenableArtifact, matchCommands, parseSlashInput, primaryArg, renderApp, useAppContext, useConfig, useKeybinding, useSessionStats, useStreamingContext, useTextBuffer, useUIState };
|