@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/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as React from 'react';
2
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';
3
+ import { ModelRef, PermissionLevel, SelectionContext, SetSelectionResult, ModelSelection, ExpandedCommand, MentionSet, EpochFilePart, EpochSessionPart, BackgroundTaskInfo, ApprovalRequest, ApprovalOutcome, ContextBreakdown, CustomCommandSource, EpochUserContent, AgentEvent, CustomCommandDef, KeybindingTable, ToolArtifact, TokenUsage, Translate, TranslateVars, KeyChord, KeyContext, KeyAction } from '@epoch-agent/protocol';
4
4
  import { HistoryItemWithoutId } from '@epoch-agent/view';
5
5
  export { DIFF_CONTEXT_LINES, DIFF_MAX_LINES, DiffLine, DiffResult, HistoryItem, HistoryItemWithoutId, StreamReduceResult, applyStreamEvent, collapseContext, diffLines } from '@epoch-agent/view';
6
6
  import { Instance, Key } from 'ink';
@@ -171,6 +171,32 @@ interface ClipboardImagePart {
171
171
  width?: number;
172
172
  height?: number;
173
173
  }
174
+ /**
175
+ * `@:` 补全面板里的一条会话候选(方案 53 PR-2)—— **宿主算好的展示形状**。
176
+ *
177
+ * ## 这个形状里没有转录文本,而且不许加
178
+ *
179
+ * §5.2 那条边界(面板只按 id / cwd / 标题匹配,永远不搜转录文本)是**安全边界**:
180
+ * 面板边打边显示,如果它能搜正文,打 `@:密码` 就会在屏幕上列出所有提到过密码的
181
+ * 会话 —— 而这块屏幕可能正被别人看着(结对、录屏、演示)。
182
+ *
183
+ * 这条边界之所以是**结构性**的而不是一句叮嘱,就在这个接口上:转录文本一个字
184
+ * 都不会到达 tui,所以这一侧再怎么改过滤也搜不出正文。`sessions.preview`
185
+ * (首条消息的前几十字)同理不许出现在这里。
186
+ *
187
+ * `hint` 已经是拼好的一句话:tui 够不着 `t()`,措辞归宿主(同 `PluginInfo`
188
+ * 的 `contributes` 那条判据)。
189
+ */
190
+ interface SessionCandidateView {
191
+ /** sessionId。选中时补进输入行的就是它(`@:<id>`) */
192
+ id: string;
193
+ /** 会话标题。空标题在引擎那侧已经兜成 sessionId 了,所以它永远非空 */
194
+ title: string;
195
+ /** 那段会话的工作目录。**只参与匹配**,显示的是 `hint` 里那一小段 */
196
+ cwd?: string;
197
+ /** 灰字那一行(「12/03 · 42 条 · 本工作区」),宿主已用 `t()` 拼好 */
198
+ hint: string;
199
+ }
174
200
 
175
201
  /**
176
202
  * 消息类型 — 基于 Google Gemini CLI types.ts
@@ -263,6 +289,45 @@ interface GoalActionResult {
263
289
  ok: boolean;
264
290
  message: string;
265
291
  }
292
+ /**
293
+ * 这个进程记下的**一条弹窗答案**(审批缓存,2026-08-28)。
294
+ *
295
+ * core 的 `CachedApproval` 的**结构性镜像**(`tui` 不许 import core),
296
+ * 判据同 {@link HostActions.checkpoints} 那三个方法:直接把 core 的类型绑上来
297
+ * 等于让两边偷偷连在一起,而这一份的作用正是让漂移在编译期就红。
298
+ *
299
+ * ⚠️ **`decision` 只有三档,没有 `allow-once`** —— 那一档压根不入缓存
300
+ * (判据在 core 的 `CachedDecision` 上)。写成联合而不是 `string`,是为了让
301
+ * 「三档各印一句人话」那张表在漏一档时当场红,而不是在屏幕上留一个空白格。
302
+ */
303
+ interface CachedApprovalInfo {
304
+ /**
305
+ * 撤销时的句柄。**按 id 不按目标**:同一个「工具 + 目标」上可以躺着不止一条
306
+ * (比如一条 `deny` 加一条 `allow-session`),按目标撤会一次删掉几条,
307
+ * 而屏幕上那一行「撤销」就成了一个作用范围不确定的按钮。
308
+ */
309
+ id: string;
310
+ /** 操作类型(file_read / file_write / command / network),和判定时的查询 key 一致 */
311
+ toolName: string;
312
+ /** 归一化后的目标,可以是一整条命令行 */
313
+ target: string;
314
+ /** `prefix` 管的是整棵目录树 —— 「这一条该不该撤」靠它判,所以必须印出来 */
315
+ scope: 'exact' | 'prefix';
316
+ decision: 'deny' | 'allow-session' | 'allow-always';
317
+ createdAt: number;
318
+ }
319
+ /**
320
+ * 一次撤销为什么没成。**刻意不收成一个布尔**:两者在屏幕上是两句完全不同的话,
321
+ * 而且只有一种算错 ——
322
+ *
323
+ * - `no-permission-layer`:这一节压根不适用(权限层没起来),下一步是 `/diagnostics`;
324
+ * - `unknown-id`:那条决定已经不在表里了。**不当错误报** —— 用户要的结果已经成立
325
+ * (同 web 那一侧的判据)。
326
+ *
327
+ * 回的是**码而不是句子**:这两句话落在 `tui.permissions.*` 里,由 TUI 挑,
328
+ * 宿主那一层不该替它决定措辞。
329
+ */
330
+ type ApprovalRevokeReason = 'no-permission-layer' | 'unknown-id';
266
331
  /** 宿主(tui-entry)注入的引擎能力。全部可选:缺哪个对应命令就报「不可用」而不是崩 */
267
332
  interface HostActions {
268
333
  /**
@@ -376,11 +441,29 @@ interface HostActions {
376
441
  * 权限判定,而两者都在引擎那一侧。TUI 自己 `readFileSync` 等于给 `@`
377
442
  * 开了一条绕过 `file_read` 的读取通道。
378
443
  */
379
- resolveMentions?: (paths: readonly string[]) => {
380
- parts: EpochFilePart[];
444
+ resolveMentions?: (mentions: MentionSet) => {
445
+ parts: Array<EpochFilePart | EpochSessionPart>;
381
446
  /** 要念给用户听的话(截断了 / 被拦了 / 超上限了) */
382
447
  notices: string[];
383
448
  };
449
+ /**
450
+ * `@:` 补全面板里的会话候选(方案 53 PR-2)。
451
+ *
452
+ * ## ⚠️ 这个形状里**没有转录文本,而且不许加**
453
+ *
454
+ * §5.2 那条边界(面板只按 id / cwd / 标题匹配,永远不搜转录文本)**是安全边界
455
+ * 不是性能考虑**:面板边打边显示,如果它搜正文,那么打 `@:密码` 就会在屏幕上
456
+ * 列出所有提到过密码的会话 —— 而当前这块屏幕可能正在被别人看着(结对、录屏、
457
+ * 演示)。
458
+ *
459
+ * 落地上这条边界之所以是**结构性**的,就是因为这一层:转录文本一个字都不会
460
+ * 到达 TUI,所以 TUI 里再怎么改过滤也搜不出正文来。`sessions.preview` 那一列
461
+ * (首条消息的前几十字)同理不许出现在这里。
462
+ *
463
+ * 异步 + 带 query:候选要过可读性判定(那在引擎侧),而过滤本身在宿主的
464
+ * SQL 里做 —— TUI 拿到的已经是「这次能看的这几条」。
465
+ */
466
+ listSessionCandidates?: (query: string, limit: number) => Promise<readonly SessionCandidateView[]>;
384
467
  /**
385
468
  * 当前的后台任务表(方案 36 PR-2)。
386
469
  *
@@ -549,6 +632,37 @@ interface HostActions {
549
632
  * 缺它时那条命令不注册、那个键位不武装 —— 判据见 {@link CheckpointActions}。
550
633
  */
551
634
  checkpoints?: CheckpointActions;
635
+ /**
636
+ * 当前生效的键位一览(`/keybindings`,方案 31 PR-2 §2.5)。
637
+ *
638
+ * ## ⚠️ 回的是**渲染好的一整块文本**,不是结构
639
+ *
640
+ * 判据逐字同下面 {@link HostActions.goals} 那一段:`tui` 只依赖 protocol,够不着
641
+ * `t()`,所以这里每写一句中文就是往 i18n 那条棘轮上添一笔债。而这条命令**通篇
642
+ * 都是文案** —— 分组标题、来源标注、被拒绝的理由各一句。
643
+ *
644
+ * 另一半理由是这块内容的**事实来源全在宿主那边**:哪条是用户改的、哪条被保留键
645
+ * 拒了、哪条撞了谁,只有加载期(`core/src/config/keybindings.ts` 的
646
+ * `KeybindingReport`)知道。TUI 手上那张 `KeybindingTable` 是消解完冲突之后的
647
+ * 结果,从它反推来源只能靠猜。
648
+ *
649
+ * 宿主没接就**压根不注册**这条命令(同 `/rewind` 的判据)—— 一条只会回
650
+ * 「不可用」的键位清单没有存在的意义。
651
+ */
652
+ keybindings?: () => string;
653
+ /**
654
+ * 让这台终端能发 Shift+Enter(`/terminal-setup`,方案 31 §4.4 #24~#27)。
655
+ *
656
+ * 和 {@link HostActions.goals} 同一类:回的是**宿主渲染好的一整块文本**,
657
+ * `ok: false` 只是「这次没写成」(认不出的终端、备份失败……),理由本身在
658
+ * `message` 里。TUI 只负责把 `args` 原样带过去、把结果印出来。
659
+ *
660
+ * 宿主没接就**压根不注册**这条命令(同 `/keybindings` 的判据)。
661
+ */
662
+ terminalSetup?: (args: string) => Promise<{
663
+ ok: boolean;
664
+ message: string;
665
+ }>;
552
666
  /**
553
667
  * 把当前会话导出成 Markdown,返回真正写到的路径。
554
668
  *
@@ -614,6 +728,39 @@ interface HostActions {
614
728
  }>;
615
729
  /** 企业托管策略(方案 22 §2.6)。这台机器上没有托管文件时 `present: false` */
616
730
  managed: ManagedInfo;
731
+ /**
732
+ * 这个进程记下的弹窗答案(审批缓存,2026-08-28)。**和 `rules` 不是一回事**:
733
+ * 那一张是配置文件里写下来的策略(能提交进 git),这一张是本机这一程的记忆。
734
+ * 判定时**规则的 deny 排在缓存前面**,所以屏幕上这两者必须分得开。
735
+ *
736
+ * ⚠️ **必填,不是可选。** 这一格和 {@link HostActions.revokeApproval} 是
737
+ * 同一条纵线的两头,而「列得出来、撤不掉」和「宿主忘了接」在屏幕上长得一样
738
+ * (都是那一节不出现)。必填让漏接的宿主在**编译期**就红,
739
+ * 而不是等用户敲 `/permissions` 时才发现少了一节。
740
+ *
741
+ * 权限层没起来时是**空表**而不是抛错:没有权限层就等于什么都没记过。
742
+ */
743
+ approvals: readonly CachedApprovalInfo[];
744
+ };
745
+ /**
746
+ * 撤销**一条**缓存的审批决定(2026-08-28)。
747
+ *
748
+ * ⚠️ **它撤的只是缓存那一层。** 两张规则表的 `deny` 排在缓存**前面**
749
+ * (core 的 `checkRulesAndCache`),这一发一个字都不动它们 ——
750
+ * 撤销缓存的 deny ≠ 撤销规则的 deny,界面上必须说得清这一点,
751
+ * 不然用户点了「撤销」发现还是被拦,比压根没有这个入口更坏。
752
+ *
753
+ * ⚠️ **撤销是把那道题还给用户,不是替他答成「允许」**:撤完之后同一个操作会
754
+ * 重新弹窗。撤成放行的话,一次误点的代价会从「这个工具问不到」变成
755
+ * 「这个目标以后都不问了」—— 那个方向比原来的缺陷坏得多。
756
+ *
757
+ * ⚠️ **没有「全清」那一档**(core 那边 `ApprovalCache.clear()` 一直都在,
758
+ * 刻意不转出来):那一下会把用户攒了一整程的「总是允许」一起清掉,
759
+ * 于是「收回一个误点」的代价变成「后面每一次都重新问一遍」。
760
+ */
761
+ revokeApproval?: (id: string) => {
762
+ ok: boolean;
763
+ reason?: ApprovalRevokeReason;
617
764
  };
618
765
  /**
619
766
  * Plan 模式(方案 35)—— 用户主动那一半。
@@ -913,7 +1060,8 @@ interface ConfirmationRequest {
913
1060
  *
914
1061
  * 入参是 `EpochUserContent`(`string | 部件数组`)而不是 `string`:Ctrl+V 粘进来的
915
1062
  * 图片必须跟着这一次提交走到引擎。**不放宽成联合类型**是不行的 —— 历史项、
916
- * 队列、debug 日志全都只要文本,它们统一走 `contentToText()` 取投影。
1063
+ * 队列、debug 日志全都只要文本,它们统一走 `contentToDisplayText()` 取投影
1064
+ * (**宿主那一份**,走 catalog;protocol 的 `contentToText()` 是给模型的,见 §14)。
917
1065
  */
918
1066
  type RunFn = (message: EpochUserContent, opts: {
919
1067
  signal: AbortSignal;
@@ -938,8 +1086,19 @@ interface AppProps {
938
1086
  * 行为与今天逐键一致。
939
1087
  */
940
1088
  keybindings?: KeybindingTable;
1089
+ /**
1090
+ * Vim 模式(方案 31 §2.6 / §4.3)。**默认关** —— 关着时输入框的行为与 PR-3
1091
+ * 之前逐字节一致(#13)。
1092
+ *
1093
+ * ⚠️ **今天没有任何宿主传它。** 开关的家按方案 §2.2 该在
1094
+ * `~/.epoch/keybindings.json`(键位是个人偏好,只有用户级),而读那份文件的是
1095
+ * `core/src/config/keybindings.ts`、把结果递进来的是 `cli/src/tui-entry.ts` ——
1096
+ * PR-3 这一轮两个文件都归别的分支,所以只留了这个入口。接上那半只有两处一行的
1097
+ * 改动,交接写在 `docs/verify/VERIFY_RECORD-31-keybindings.md` 的 PR-3 那一章。
1098
+ */
1099
+ vimMode?: boolean;
941
1100
  }
942
- declare function App({ welcomeMessage, startupNotices, onRun, onExit, host, customCommands, keybindings, }: AppProps): React__default.ReactElement;
1101
+ declare function App({ welcomeMessage, startupNotices, onRun, onExit, host, customCommands, keybindings, vimMode, }: AppProps): React__default.ReactElement;
943
1102
 
944
1103
  /**
945
1104
  * 工具调用的一行摘要 —— 纯函数,可单测。
@@ -1093,8 +1252,93 @@ interface RenderAppOptions extends AppProps {
1093
1252
  * 不给按 `'session'`(接了 BudgetStore 的常态)算 —— 见 session-context 的文件头。
1094
1253
  */
1095
1254
  usageScope?: UsageScope;
1255
+ /**
1256
+ * 查表函数,**必填**(方案 40 PR-2)。
1257
+ *
1258
+ * tui 只许依赖 protocol + view,够不着 `t()` 的加载那一半 —— 所以 catalog 由
1259
+ * 宿主读盘之后把绑好的查表函数递进来。判据、以及「为什么不是构建期编进 bundle」
1260
+ * 在 [i18n.ts](./i18n.js) 的文件头。
1261
+ *
1262
+ * **刻意是必填而不是可选**:可选的话「宿主忘了传」就退化成满屏 key 路径,
1263
+ * 而那是运行时才看得见的事。必填让它在编译期就红 —— 同 `HostActions` 那条判据。
1264
+ */
1265
+ translate: Translate;
1096
1266
  }
1097
- declare function renderApp({ sessionId, app, config, usageScope, ...appProps }: RenderAppOptions): Instance;
1267
+ declare function renderApp({ sessionId, app, config, usageScope, translate, ...appProps }: RenderAppOptions): Instance;
1268
+
1269
+ /**
1270
+ * tui 那份 `t()` —— **只有接线,没有实现**(方案 40 PR-2)。
1271
+ *
1272
+ * ## 「TUI 怎么拿到 catalog」这道门槛是怎么答的
1273
+ *
1274
+ * RECORD-40-i18n §三十一 把它列成 PR-2 的头道门槛,并且写明「别默认照抄 web 的答案」。
1275
+ * 约束是硬的:`check-layers.mjs` 的 `ALLOWED` 表里 tui 只有 protocol + view,
1276
+ * 而 `t()` 的加载那一半(找 `locales/`、读 yaml、缓存)住在 infra。
1277
+ *
1278
+ * 三条路各自的代价:
1279
+ *
1280
+ * | 路 | 为什么不是它 |
1281
+ * | --------------------------- | ---------------------------------------------------------------------------- |
1282
+ * | 让 tui 依赖 infra | 把 `better-sqlite3` 拖进一个今天零 native 依赖的**已发布**包,方向反了 |
1283
+ * | 照 web 那样构建期编进 bundle | tui 跑在 Node 里,没有「浏览器读不了盘」这个硬约束;换来的是**第三份**回落链 |
1284
+ * | **宿主注入**(选了这条) | `cli/src/tui-entry.ts` 文件头已经写着这个形状:「tui 包不许 import core,能力只能从宿主这一侧递进去」——`HostActions` 就是它 |
1285
+ *
1286
+ * 所以这里只有一个插槽:查表本体在
1287
+ * [protocol/src/i18n.ts](../../protocol/src/i18n.ts) 的 `makeTranslate`
1288
+ * (web 也用同一份,判据在那儿),catalog 由宿主读盘之后递进来。
1289
+ *
1290
+ * ## 为什么是模块级插槽,而不是 React context(web 用的是 context)
1291
+ *
1292
+ * **两个宿主的形态不同,这不是偷懒**:
1293
+ *
1294
+ * - web 上一个进程同时服务多个标签页,语言是**每棵 React 树各一个值**,
1295
+ * 所以必须走 context(判据在 `web/src/i18n/context.tsx`)
1296
+ * - tui 是**独占进程的宿主**,语言是进程级的一次性决定
1297
+ * (`runtime/src/index.ts` 上那句「`setLang()` 不转:语言是进程级的一次性决定,
1298
+ * 由 cli 在启动时定」就是这条)。而且 tui 里一半的文案在**非 React 代码**里
1299
+ * (`commands/*.ts` 那些斜杠命令的实现是普通函数),context 到不了它们
1300
+ *
1301
+ * ## 没注入的时候会怎样:返回 key 路径
1302
+ *
1303
+ * 这不是兜底的敷衍,它**就是回落链的第三层**(见 protocol 的 `makeTranslate`)。
1304
+ * 失败形态因此是「满屏 `tui.commands.help_title` 这样的字符串」——**极响**,
1305
+ * 不会被误当成正常界面。反过来内置一份中文当兜底才危险:那样「宿主忘了注入」
1306
+ * 在中文机器上完全看不出来,等到有人切 English 才炸。
1307
+ *
1308
+ * ## ⚠️ 最容易踩的一条:**别在模块级调 `t()`**
1309
+ *
1310
+ * 插槽是 `renderApp()` 装的,而模块级常量在**那之前**就求值完了(`import` 被提到
1311
+ * 最前面)。所以
1312
+ *
1313
+ * ```ts
1314
+ * const LEVEL_DESC = { default: t('tui.permission.default') }; // ❌ 拿到 key 路径
1315
+ * ```
1316
+ *
1317
+ * 这一行**不会报错**,它只是永久地把 key 路径存进了那个常量。写法改成「求值推迟到
1318
+ * 用的时候」:函数(`levelDesc(level)`)、或者对象字面量上的 getter
1319
+ * (`get description() { return t('…'); }` —— 类型仍是 `string`,调用点一个字不改)。
1320
+ *
1321
+ * 这条有门禁:`__tests__/i18n-no-key-leak.test.tsx` 渲染几屏真界面,
1322
+ * 断言里面**一个 `tui.` 开头的 key 路径都没有**。它盖的正是这一类 ——
1323
+ * 一次断言,不用逐个文件盯。
1324
+ */
1325
+
1326
+ /**
1327
+ * 装上宿主给的查表函数。
1328
+ *
1329
+ * **生产路径不该直接调它** —— `renderApp()` 的入参里有 `translate`,它是**必填**的,
1330
+ * 于是宿主在编译期就忘不掉(判据同 `HostActions`:能忘的接线迟早会被忘)。
1331
+ * 直接调这个函数的只有两类:`scripts/probe.tsx` 那个布局门禁,
1332
+ * 以及 `__tests__/` 里那个装真 catalog 的夹具。
1333
+ */
1334
+ declare function setTranslator(next: Translate): void;
1335
+ /**
1336
+ * 取一条文案。**永远返回字符串,永远不抛。**
1337
+ *
1338
+ * 形状和 infra / web 那两处的 `t()` 逐字一致(同一个 `vars` 语义:没给的占位符
1339
+ * 原样留着),所以三处的调用点长得一样 —— 抽取时不用为「这是哪个包」改写法。
1340
+ */
1341
+ declare function t(key: string, vars?: TranslateVars): string;
1098
1342
 
1099
1343
  interface BannerProps {
1100
1344
  /** 终端宽度;分隔线要扣掉容器的左右 padding,否则宽度 = columns + 2 会折行多出一行 */
@@ -1167,6 +1411,13 @@ declare function matchCommands(commands: readonly SlashCommand[], text: string):
1167
1411
  /**
1168
1412
  * 默认表。每个动作后面的注释是它在改造前的出处。
1169
1413
  *
1414
+ * ## 为什么每条绑定都多套一层方括号
1415
+ *
1416
+ * 一条绑定是**一个序列**(`KeySequence`),长度 1 是单键、长度 ≥ 2 是序列
1417
+ * (PR-2)。所以 `[[{ key: 'l', ctrl: true }]]` 读作「一条绑定,它由一个 chord
1418
+ * 组成」。看着啰嗦,但另一种写法(单键表和序列表分开两张)要在每一处构造点同时
1419
+ * 填对两张,理由写在 protocol 的 `KeySequence` 上。
1420
+ *
1170
1421
  * 几处刻意的对齐,别当成笔误:
1171
1422
  *
1172
1423
  * - **`line-start` / `line-end` 各有两个键**:改造前就是
@@ -1175,15 +1426,71 @@ declare function matchCommands(commands: readonly SlashCommand[], text: string):
1175
1426
  * - **`newline` 有三个键**:`shift+enter` / `alt+enter` 是改造前
1176
1427
  * `key.return && (key.meta || key.shift)` 那一支拆出来的两半,`ctrl+j` 是
1177
1428
  * `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"]` 的默认值
1429
+ * - **`rewind` `escape escape`**,而 `interrupt` `escape` —— 两条**同时**
1430
+ * 存在,这不是冲突。第一下 Esc 立刻中断,第二下在 500ms 窗口里到达才开回退面板
1431
+ * (PR-2 §4.2,机制在 `sequences.ts`)。PR-1 那会儿它是空的,双击窗口硬编码在
1432
+ * `app.tsx` 里;这一轮那个特例被这一行取代了
1182
1433
  * - **`ctrl+c` / `ctrl+d` 在表里**,而且同时是保留键(`RESERVED_CHORDS`)。两件事
1183
1434
  * 不矛盾:默认表得写出来它们干什么,保留只是不许**改**它们
1184
1435
  */
1185
1436
  declare const DEFAULT_KEYBINDINGS: KeybindingTable;
1186
1437
 
1438
+ /**
1439
+ * 序列(`escape escape`)—— 那台「上一下是什么 + 窗口过期没有」的状态机。
1440
+ *
1441
+ * ## ⚠️ 形状:**先触发前缀动作,再在窗口内等后续键**
1442
+ *
1443
+ * 这是本文件存在的全部理由,也是 [resolver.ts](resolver.ts) 文件头点名不许写反的
1444
+ * 那一条(方案 31 §2.4 / 方案 27 §2.6):
1445
+ *
1446
+ * ```
1447
+ * ✅ 第一下 escape → 立刻返回 interrupt(该中断的中断了)→ 武装 500ms 窗口
1448
+ * 第二下在窗口里到 → 返回 rewind
1449
+ * ❌ 第一下 escape → 先不返回动作,挂起 500ms 看会不会有第二下 → 再决定
1450
+ * ```
1451
+ *
1452
+ * 落到代码上就是 {@link stepSequence} 里那句 `return { action: single, … }`:
1453
+ * **武装窗口和返回单键动作发生在同一次调用里**,中间没有任何等待。想把它改成挂起
1454
+ * 式的人,改的会是那一行 —— 而 `keybindings-sequences.test.tsx` 的验收 10
1455
+ * (单击 Esc 必须在一拍之内中断)当场红。
1456
+ *
1457
+ * ## 为什么是纯函数,React 那半在别处
1458
+ *
1459
+ * {@link stepSequence} 不碰 state、不读时钟(`now` 是入参)—— 于是「窗口过期」这
1460
+ * 件事能被**直接**测出来,不用睡 500ms,也不用把断言挂在墙上时钟上
1461
+ * (AGENTS.md 那条「别把断言挂在墙上时钟上」)。React 外壳在
1462
+ * [use-key-sequences.ts](use-key-sequences.ts)。
1463
+ */
1464
+
1465
+ /**
1466
+ * 序列的时间窗。
1467
+ *
1468
+ * 这个数字和它的理由是从 `hooks/use-rewind.ts` 的 `REWIND_DOUBLE_MS` 搬过来的
1469
+ * ——PR-2 把那个双击特例一般化成了序列,窗口跟着搬家,不留两份:
1470
+ *
1471
+ * 比 Ctrl+C 那个(2000ms)短很多,因为**两个窗口的语义相反**:Ctrl+C 的窗口是
1472
+ * 「别急,再按一次才真退」,长一点更安全;这个窗口是「刚才那下是不是序列的前半」,
1473
+ * 长了会让两次无关的 Esc(中断一轮、过几百毫秒又清一次输入)被误判成一次序列,
1474
+ * 平白弹出一个面板。
1475
+ */
1476
+ declare const SEQUENCE_WINDOW_MS = 500;
1477
+ /** 前缀之后**再按一下**会发生什么 */
1478
+ interface Continuation {
1479
+ chord: KeyChord;
1480
+ /** 按下它就命中的动作;只是往更长的序列里走一步时没有 */
1481
+ action?: KeyAction;
1482
+ }
1483
+ /** 正在等后续键。提示行印的就是它 */
1484
+ interface PendingSequence {
1485
+ /** 已经按下的那几下 */
1486
+ chords: readonly KeyChord[];
1487
+ /** 在哪个上下文里起的头。换了上下文就不算数了 —— 见 {@link stepSequence} */
1488
+ context: KeyContext;
1489
+ /** 窗口的起点(上一下按键的时刻) */
1490
+ at: number;
1491
+ next: readonly Continuation[];
1492
+ }
1493
+
1187
1494
  /**
1188
1495
  * ink 的 `(input, key)` → 一个 {@link KeyChord}。
1189
1496
  *
@@ -1217,13 +1524,32 @@ declare function chordOf(input: string, key: Key): KeyChord | undefined;
1217
1524
 
1218
1525
  /** 这一下按键在给定上下文里是哪个动作;没绑(或判不出主键)返回 undefined */
1219
1526
  type ActionResolver = (input: string, key: Parameters<typeof chordOf>[1], context: KeyContext) => KeyAction | undefined;
1220
- /**
1221
- * @param override 显式的表。**只有 `App` 传它** —— 它是表的持有者(从 prop 收),
1222
- * 而一个组件读不到自己 `Provider` 里的值。其余组件一律不传,
1223
- * 从 context 拿,于是「表只有一份」这件事在树上是显然的。
1224
- */
1527
+ /** @param override 见 {@link useKeybindingTable} */
1225
1528
  declare function useKeybinding(override?: KeybindingTable): ActionResolver;
1226
1529
 
1530
+ interface UseKeySequencesOptions {
1531
+ /**
1532
+ * 显式的表。只有 `App` 传它(它是表的持有者),其余组件从 context 拿 ——
1533
+ * 同 {@link useKeybindingTable}。
1534
+ */
1535
+ table?: KeybindingTable;
1536
+ /**
1537
+ * 这个动作现在接得住吗。缺省全接得住。
1538
+ *
1539
+ * **不需要**每次渲染保持同一个引用 —— 它进 ref,按键那一刻才读。为什么需要这
1540
+ * 一格见 `StepInput.enabled`:宿主接不住的动作不值得为它武装窗口,否则第二下
1541
+ * 按键会凭空消失。
1542
+ */
1543
+ enabled?: (action: KeyAction) => boolean;
1544
+ }
1545
+ interface KeySequenceApi {
1546
+ /** 这一下按键是哪个动作,**含序列**。形状同 {@link ActionResolver} */
1547
+ resolve: ActionResolver;
1548
+ /** 正在等后续键;提示行画的就是它。没在等就是 null */
1549
+ pending: PendingSequence | null;
1550
+ }
1551
+ declare function useKeySequences({ table, enabled }?: UseKeySequencesOptions): KeySequenceApi;
1552
+
1227
1553
  /**
1228
1554
  * StreamingContext — 流式状态(直接抄 Gemini CLI StreamingContext)。
1229
1555
  * @license Apache-2.0 (adapted from google-gemini/gemini-cli)
@@ -1341,10 +1667,141 @@ interface TextBufferAPI {
1341
1667
  /** 取出文本并清空(同步返回真实文本) */
1342
1668
  take: () => string;
1343
1669
  setText: (text: string) => void;
1670
+ /**
1671
+ * 整份状态换成给定的那一份(**含光标**)。
1672
+ *
1673
+ * 给 vim 那一层用(方案 31 PR-3):它那台状态机算出来的就是一整个新
1674
+ * {@link TextBufferState}(文本 + 行 + 列一起变),而 `setText` 只能给文本、
1675
+ * 光标一律扔到末尾。少了这个口子,调用方就得靠「setText 之后再 moveLeft 几次」
1676
+ * 把光标挪回去 —— `input-box.tsx` 里 `@` 补全那一段正是这么写的,
1677
+ * 而那段循环的次数是从绝对偏移倒推的,读它比读这一行贵得多。
1678
+ */
1679
+ setState: (next: TextBufferState) => void;
1344
1680
  clear: () => void;
1345
1681
  }
1346
1682
  declare function useTextBuffer(): TextBufferAPI;
1347
1683
 
1684
+ /**
1685
+ * 终端识别与 Shift+Enter 探测(方案 31 §4.4,#22/#23)。
1686
+ *
1687
+ * 这一层刻意**不含任何文案**:tui 只依赖 protocol / view,够不着 `t()`,
1688
+ * 所以这里回的全部是结构(kind / label / 字节的 hex),措辞由宿主
1689
+ * (`cli/src/commands/status.ts` 的 doctor 一节、`cli/src/terminal-setup-view.ts`)
1690
+ * 用 `t()` 拼 —— 判据同 `commands/types.ts` 里 `HostActions.goals` 那一段。
1691
+ *
1692
+ * ## 探测为什么是「实测」而不是「查表」
1693
+ *
1694
+ * 「这台终端发不发 Shift+Enter」是一个**运行时事实**:同一款终端换个版本、
1695
+ * 换一层 ssh、换个宿主程序,答案都可能变。所以 #22 的预期是让用户按一下,
1696
+ * 我们读这一下真正收到的字节 —— 和 doctor 里沙箱那一节「实测值不是配置值」
1697
+ * 是同一个道理。
1698
+ *
1699
+ * ## ⚠️ 探测时**不开启** kitty 键盘协议
1700
+ *
1701
+ * 我们的 TUI 没在 `render()` 里传 `kittyKeyboard`(ink 只在显式协商后才真正
1702
+ * 上报 CSI-u 序列),所以探测也要在协议关着的情况下读 —— 开着协议测出来的
1703
+ * 「能发」,TUI 里照样收不到,那是拿一个假阳性当结论。
1704
+ */
1705
+ /** 已知终端种类。只有这四种 + unknown —— 写配置文件只认这些(方案 31 §2.7 第 4 条) */
1706
+ type TerminalKind = 'iterm2' | 'vscode' | 'windows-terminal' | 'apple-terminal' | 'unknown';
1707
+ interface TerminalClass {
1708
+ kind: TerminalKind;
1709
+ /** 给人看的名字。英文名直接印(iTerm2 这种没有对应中文),宿主拿去拼标题 */
1710
+ label: string;
1711
+ }
1712
+ /**
1713
+ * 从环境变量识别当前终端。
1714
+ *
1715
+ * 识别面刻意收窄:`TERM_PROGRAM` 是 macOS 上三家的通用做法(VS Code 集成终端
1716
+ * 在别的平台上也设),Windows Terminal 用 `WT_SESSION`。认不出来的全部归
1717
+ * `unknown` —— 未知终端**不写任何文件**(#27),宁可退回手动指南。
1718
+ */
1719
+ declare function classifyTerminal(env?: Record<string, string | undefined>): TerminalClass;
1720
+ /** Shift+Enter 探测时按下的那一键收到的字节(latin1 渲染,终端字节流就是单字节的) */
1721
+ type ShiftEnterSample =
1722
+ /** `\x1b[13;2u`(CSI-u 上报,Shift+Enter 有独立序列) */
1723
+ {
1724
+ kind: 'shift-enter';
1725
+ hex: string;
1726
+ }
1727
+ /** 裸回车(`\r` 或 `\n`)—— 终端把 Shift+Enter 和 Enter 看成了同一个键 */
1728
+ | {
1729
+ kind: 'enter-only';
1730
+ hex: string;
1731
+ }
1732
+ /** 收到别的按键 —— 按的好像不是 Shift+Enter */
1733
+ | {
1734
+ kind: 'other';
1735
+ hex: string;
1736
+ }
1737
+ /** 探测期间收到 Ctrl+C(`\x03`)—— 取消 */
1738
+ | {
1739
+ kind: 'interrupted';
1740
+ };
1741
+ /** 探测的失败形态(没有样本可判) */
1742
+ type ShiftEnterProbeFailure =
1743
+ /** stdin 不是 TTY(doctor 在管道里跑)—— 连提示都不打 */
1744
+ {
1745
+ kind: 'no-tty';
1746
+ }
1747
+ /** 窗口期内什么都没收到 */
1748
+ | {
1749
+ kind: 'timeout';
1750
+ timeoutMs: number;
1751
+ };
1752
+ type ShiftEnterProbeResult = {
1753
+ ok: true;
1754
+ sample: ShiftEnterSample;
1755
+ } | {
1756
+ ok: false;
1757
+ failure: ShiftEnterProbeFailure;
1758
+ };
1759
+ /**
1760
+ * 判定一串收到的字节是哪一种按键。
1761
+ *
1762
+ * 认的只有两个形态:
1763
+ * - `\x1b[13;2u` —— kitty 风格 CSI-u,`13` = 回车,`2` 是 Shift 修饰位;
1764
+ * ink 7.1.1 无条件解析这种序列(实测 `{key:'enter', shift:true}`,进我们
1765
+ * `keybindings/parser.ts` 的 `chordOf` 就是 shift+enter → newline)
1766
+ * - `\r` / `\n` —— 裸回车。macOS 的 Option+Enter 是 `\x1b\r`,**不归这一类**
1767
+ * (那是 alt+enter),所以收到它说明按的确实是 Enter 系的键
1768
+ *
1769
+ * 其余一律 `other`(带着 hex,宿主拿去拼「刚才按的好像不是」那一句)。
1770
+ */
1771
+ declare function classifyShiftEnterBytes(bytes: Buffer | string): ShiftEnterSample;
1772
+ /** probeShiftEnter 能接的最小 stdin 形状 —— 单测拿假对象替,不用真终端 */
1773
+ interface ProbeStdin {
1774
+ isTTY?: boolean;
1775
+ setRawMode: (raw: boolean) => void;
1776
+ on: (event: 'data', listener: (chunk: Buffer) => void) => void;
1777
+ removeListener: (event: 'data', listener: (chunk: Buffer) => void) => void;
1778
+ pause: () => void;
1779
+ }
1780
+ /** probeShiftEnter 能接的最小 stdout 形状 */
1781
+ interface ProbeStdout {
1782
+ write: (chunk: string) => void;
1783
+ }
1784
+ interface ProbeShiftEnterOptions {
1785
+ /** 窗口期。到点没收到就报 timeout(默认 8000ms) */
1786
+ timeoutMs?: number;
1787
+ /** 提示语。由宿主用 `t()` 拼好传进来,这里不生产文案 */
1788
+ prompt: string;
1789
+ stdin?: ProbeStdin;
1790
+ stdout?: ProbeStdout;
1791
+ }
1792
+ /**
1793
+ * 实测「当前终端发不发 Shift+Enter」:进 raw 模式 → 等一次按键 → 归类 → 还原。
1794
+ *
1795
+ * 约定:
1796
+ * - stdin 不是 TTY 时**什么都不干**直接回 `no-tty`(提示都不打 —— 管道那一头
1797
+ * 没有人在按键)
1798
+ * - raw 模式是 try/finally 还原的,超时、出错、Ctrl+C 都走同一个出口
1799
+ * - 收到**第一段**字节就收手。整段转义序列(`\x1b[13;2u`)终端通常一次写完;
1800
+ * 真被拆成两段的话第一段以 `\x1b` 开头、不匹配任何已知形态,会归成 `other` ——
1801
+ * 那是诚实的「没测到」,比猜一个答案强
1802
+ */
1803
+ declare function probeShiftEnter(options: ProbeShiftEnterOptions): Promise<ShiftEnterProbeResult>;
1804
+
1348
1805
  interface ClampResult {
1349
1806
  /** 裁剪后的文本 */
1350
1807
  text: string;
@@ -1361,4 +1818,4 @@ interface ClampResult {
1361
1818
  */
1362
1819
  declare function clampTailLines(text: string, width: number, maxLines: number): ClampResult;
1363
1820
 
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 };
1821
+ 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 KeySequenceApi, type ManagedInfo, type PickerRequest, type PluginInfo, type ProbeShiftEnterOptions, type ProbeStdin, type ProbeStdout, type RenderAppOptions, type RewindFileAction, type RewindFileDrift, type RewindFileEntry, type RewindPlan, type RewindReport, type RewindScope, type RewindSummary, SEQUENCE_WINDOW_MS, type SessionCandidateView, SessionStatsProvider, type SessionStatsState, type ShiftEnterProbeFailure, type ShiftEnterProbeResult, type ShiftEnterSample, type SlashCommand, type SlashCommandContext, StreamingContext, StreamingState, type TerminalClass, type TerminalKind, type TextBufferAPI, type TextBufferState, type TuiHistoryItem, type TuiHistoryItemWithoutId, type UIState, UIStateProvider, type UsageScope, clampTailLines, classifyShiftEnterBytes, classifyTerminal, deltaOf, findCommand, formatArtifact, formatArtifactBytes, formatToolCall, isOpenableArtifact, lastOpenableArtifact, matchCommands, parseSlashInput, primaryArg, probeShiftEnter, renderApp, setTranslator, t, useAppContext, useConfig, useKeySequences, useKeybinding, useSessionStats, useStreamingContext, useTextBuffer, useUIState };