@bolloon/bolloon-agent 0.5.0 → 0.5.2

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 (121) hide show
  1. package/README.md +39 -0
  2. package/dist/agents/agent-lang.js +101 -0
  3. package/dist/agents/agent-manifest-protocol.js +4 -0
  4. package/dist/agents/execution-supervisor.js +87 -5
  5. package/dist/agents/gateway-group.js +44 -3
  6. package/dist/agents/model-policy.js +651 -0
  7. package/dist/agents/pi-sdk-tools.js +84 -33
  8. package/dist/agents/pi-sdk.js +184 -31
  9. package/dist/agents/run-store.js +53 -0
  10. package/dist/agents/task/task-runner.js +8 -1
  11. package/dist/agents/workflow-pivot-loop.js +22 -13
  12. package/dist/cli/clipboard.js +65 -0
  13. package/dist/cli/ink-app.js +145 -17
  14. package/dist/cli/log-gate.js +386 -0
  15. package/dist/cli/mention-data.js +2 -0
  16. package/dist/cli/model-selector.js +538 -0
  17. package/dist/cli/reply-hygiene.js +54 -0
  18. package/dist/cli/setup-wizard.js +686 -69
  19. package/dist/cli/startup-notice.js +160 -0
  20. package/dist/cli/stores.js +22 -1
  21. package/dist/cli/theme.js +49 -0
  22. package/dist/cli/tui-select.js +970 -0
  23. package/dist/cli-entry.js +125 -18
  24. package/dist/efficode/compress.js +78 -0
  25. package/dist/efficode/crc32.js +38 -0
  26. package/dist/efficode/did.js +66 -0
  27. package/dist/efficode/index.js +24 -0
  28. package/dist/efficode/lz77.js +170 -0
  29. package/dist/efficode/negotiate.js +187 -0
  30. package/dist/efficode/ops.js +87 -0
  31. package/dist/efficode/packet.js +271 -0
  32. package/dist/efficode/types.js +45 -0
  33. package/dist/electron-build/agents/agent-manifest-protocol.js +4 -0
  34. package/dist/electron-build/agents/agent-manifest-protocol.js.map +1 -1
  35. package/dist/electron-build/agents/execution-supervisor.js +86 -4
  36. package/dist/electron-build/agents/execution-supervisor.js.map +1 -1
  37. package/dist/electron-build/agents/goal-store.js.map +1 -1
  38. package/dist/electron-build/agents/model-policy.js +706 -0
  39. package/dist/electron-build/agents/model-policy.js.map +1 -0
  40. package/dist/electron-build/agents/pi-sdk-tools.js +84 -33
  41. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  42. package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
  43. package/dist/electron-build/agents/pi-sdk.js +185 -31
  44. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  45. package/dist/electron-build/agents/run-store.js +55 -1
  46. package/dist/electron-build/agents/run-store.js.map +1 -1
  47. package/dist/electron-build/agents/task/task-runner.js +8 -1
  48. package/dist/electron-build/agents/task/task-runner.js.map +1 -1
  49. package/dist/electron-build/agents/workflow-pivot-loop.js +22 -13
  50. package/dist/electron-build/agents/workflow-pivot-loop.js.map +1 -1
  51. package/dist/electron-build/llm/config-store.js +146 -6
  52. package/dist/electron-build/llm/config-store.js.map +1 -1
  53. package/dist/electron-build/llm/connection-probe.js +764 -0
  54. package/dist/electron-build/llm/connection-probe.js.map +1 -0
  55. package/dist/electron-build/llm/custom-provider-store.js +272 -0
  56. package/dist/electron-build/llm/custom-provider-store.js.map +1 -0
  57. package/dist/electron-build/llm/data/provider-catalog-baked.js +23 -0
  58. package/dist/electron-build/llm/data/provider-catalog-baked.js.map +1 -0
  59. package/dist/electron-build/llm/model-catalog.js +778 -0
  60. package/dist/electron-build/llm/model-catalog.js.map +1 -0
  61. package/dist/electron-build/llm/model-discovery.js +1202 -0
  62. package/dist/electron-build/llm/model-discovery.js.map +1 -0
  63. package/dist/electron-build/llm/model-selection.js +1315 -0
  64. package/dist/electron-build/llm/model-selection.js.map +1 -0
  65. package/dist/electron-build/llm/pi-ai.js +751 -55
  66. package/dist/electron-build/llm/pi-ai.js.map +1 -1
  67. package/dist/electron-build/llm/provider-catalog.js +969 -0
  68. package/dist/electron-build/llm/provider-catalog.js.map +1 -0
  69. package/dist/electron-build/llm/provider-registry.js +950 -0
  70. package/dist/electron-build/llm/provider-registry.js.map +1 -0
  71. package/dist/electron-build/llm/tool-name.js +266 -0
  72. package/dist/electron-build/llm/tool-name.js.map +1 -0
  73. package/dist/electron-build/utils/dual-source-facts.js +236 -0
  74. package/dist/electron-build/utils/dual-source-facts.js.map +1 -0
  75. package/dist/electron-build/utils/dual-source.js +32 -156
  76. package/dist/electron-build/utils/dual-source.js.map +1 -1
  77. package/dist/electron-build/utils/update-manager.js +22 -72
  78. package/dist/electron-build/utils/update-manager.js.map +1 -1
  79. package/dist/electron-build/utils/update-state.js +2 -19
  80. package/dist/electron-build/utils/update-state.js.map +1 -1
  81. package/dist/electron-build/utils/version-identity.js +193 -0
  82. package/dist/electron-build/utils/version-identity.js.map +1 -0
  83. package/dist/electron-build/utils/version-info.js +47 -101
  84. package/dist/electron-build/utils/version-info.js.map +1 -1
  85. package/dist/index.js +282 -96
  86. package/dist/ios/bolloon-web.json +10 -0
  87. package/dist/ios/mobile-contacts.js +44 -11
  88. package/dist/ios/mobile-core.js +1723 -84
  89. package/dist/ios/mobile-tasks.js +339 -0
  90. package/dist/ios/mobile-update.js +1442 -0
  91. package/dist/ios/mobile.js +308 -35
  92. package/dist/ios/routes-llm-config.js +344 -52
  93. package/dist/ios/routes-mobile-tasks.js +353 -0
  94. package/dist/ios/server.js +163 -4
  95. package/dist/ios/sw.js +1 -1
  96. package/dist/llm/config-store.js +146 -6
  97. package/dist/llm/connection-probe.js +744 -0
  98. package/dist/llm/custom-provider-store.js +224 -0
  99. package/dist/llm/data/provider-catalog-baked.js +19 -0
  100. package/dist/llm/model-catalog.js +717 -0
  101. package/dist/llm/model-discovery.js +1137 -0
  102. package/dist/llm/model-selection.js +1241 -0
  103. package/dist/llm/pi-ai.js +732 -54
  104. package/dist/llm/provider-catalog.js +898 -0
  105. package/dist/llm/provider-registry.js +917 -0
  106. package/dist/llm/tool-name.js +220 -0
  107. package/dist/setup/onboard.js +34 -8
  108. package/dist/utils/dual-source-facts.js +223 -0
  109. package/dist/utils/dual-source.js +12 -141
  110. package/dist/utils/update-manager.js +16 -59
  111. package/dist/utils/update-state.js +6 -18
  112. package/dist/utils/version-identity.js +176 -0
  113. package/dist/utils/version-info.js +9 -78
  114. package/dist/web/bolloon-web.json +10 -0
  115. package/dist/web/mobile-core.js +1721 -84
  116. package/dist/web/mobile-update.js +1442 -0
  117. package/dist/web/mobile.js +372 -35
  118. package/dist/web/routes-llm-config.js +344 -52
  119. package/dist/web/sw.js +1 -1
  120. package/package.json +1 -1
  121. package/scripts/build-web.ts +36 -0
@@ -0,0 +1,970 @@
1
+ /**
2
+ * tui-select.ts — 全屏可滚动光标选择器 + 掩码输入 (模型切换交互层, 2026-09-27)
3
+ *
4
+ * ## 为什么单列一个模块
5
+ *
6
+ * 分步选择器有七步 (供应商 → 凭证 → 模型 → 参数 → 作用域 → 测试 → 确认), 每一步都要"从候选里挑一个"。
7
+ * 曾经每步各印一份清单再问序号, 于是:
8
+ * · 清单比终端高就"印满就没了" —— 用户看不到后面的项 (223 家目录就是这个规模);
9
+ * · 没有光标, 只能用眼睛找序号, 也没有高亮/颜色可辨认;
10
+ * · 七步各写一套渲染 = 七份会各自腐烂的代码。
11
+ * 所以本模块提供**一个**选择器组件 + 一个掩码输入组件, 七步共用 (调用方只给候选与标题)。
12
+ *
13
+ * ## 六种同级的一等选择方式 (不是"主/次", 谁都别退化成脚本专用)
14
+ *
15
+ * ① `↑` / `↓` 移动光标行 (行首 `→` + **accent 底/深色字**高亮), 到边上自动滚窗;
16
+ * ② `Enter` 确认当前光标行 (**落在分组标题上 = 展开/收起那一组**, 不会误选);
17
+ * ③ **数字跳行** 直接敲 `12` = 跳到第 12 行 (高亮当场跟过去, 状态行 `第 i/N` 同时变),
18
+ * 多位数逐位收窄, 越界**说清范围**而不是静默不动;
19
+ * ④ **字母/`/` 筛选** 敲字符/`/` 进入过滤 (大小写不敏感, 匹配 id/名字/族), **命中项平铺** ——
20
+ * 收起的分组也挡不住命中 (等价于"自动展开命中的那一组"); `Backspace` 逐字退, 空 = 回全量;
21
+ * ⑤ **分组折叠** `空格` 切换 / `←` 收起 / `→` 展开 —— 光标在哪一组就作用于哪一组
22
+ * (标题行上直接按也行); 收起的分组**只占一行且照写家数** (`── 未配置凭据 (200 家) ›`);
23
+ * ⑥ **滚动指示** 视窗上下边各一行 `↑ 上面还有 N 家` / `↓ 下面还有 N 家`; 视窗外的行**根本不画**。
24
+ *
25
+ * 取消: `Esc` / `Ctrl-C` 随时干净取消; 列表末尾还有一行 `Cancel` 可移动过去 + `Enter` 取消;
26
+ * 查询为空时 `q` 也取消, 空格=折叠 (查询非空时 `q`/空格只是过滤字符 —— 否则"敲字筛"与快捷键会打架)。
27
+ *
28
+ * ## 为什么是"固定高度视窗 + 可折叠分组" (leo 2026-09-27 口径)
29
+ *
30
+ * 候选集是**全部家** (内置 + 自定义 + 整个目录 ≈ 233 家), 一屏画不完是必然的 ——
31
+ * 但**不能靠藏家数来让页面放得下**: 中间候选区高度固定 (`min(12, 终端高-头尾)`),
32
+ * 只画可见的那几行; 其余靠滚动/搜索/折叠消化。折叠只决定"画不画成员行",
33
+ * 家数永远写在标题上, 搜索也一定搜得到 —— 折叠是可逆的排版, 不是隐藏。
34
+ *
35
+ * ## 符号 + 颜色**双通道** (leo 的要求: 不许只靠颜色)
36
+ *
37
+ * 语义全部先写在**符号/文字**里 (`●` 可用 / `○` 未配置 / `← 当前` / `special (需专用鉴权, 未支持)` /
38
+ * `无基址 …` / `内置|自定义|目录`), 颜色只是加速扫视的第二通道 —— 且颜色**只有一个来源**:
39
+ * `./theme.ts` 的 `THEME` token (与 Web UI 同一套 bolloon 色系), 本文件一个 hex 字面量都没有。
40
+ * `NO_COLOR` / `TERM=dumb` / 非终端下关掉颜色也**仍然分得清** —— 有真终端字节的验收门钉住这两条。
41
+
42
+ *
43
+ * ## 降级 (三条路径, 都要能走)
44
+ *
45
+ * · 真终端 + 有 raw mode → 本模块的全屏菜单;
46
+ * · 没有 raw mode / 终端太矮 / 显式 `BOLLOON_NO_TUI=1` → **退回数字文本问答** (由
47
+ * `model-selector.ts` 的 `printOptions` 那条路承担), 脚本仍可用;
48
+ * · 非 TTY (管道/重定向) → 清单 + 用法 dump, 不等待输入。
49
+ * 本模块自己**不做任何降级决定**, 由调用方按 `tuiCapable()` 分流 —— 判据只有一处。
50
+ *
51
+ * ## 边界 (硬)
52
+ *
53
+ * 本模块**只产"用户选了哪个 value"**, 不读配置文件、不写配置文件、不碰模型运行时。
54
+ * 写盘仍只有 `selectModel()` 一处 (见 `src/llm/model-selection.ts`)。
55
+ * 掩码输入的值只活在内存里, 从不进屏幕/日志/报告 —— 屏幕上只有 `•` 与长度。
56
+ */
57
+ import { THEME, fg, bg, colorEnabled, TONE_TOKEN } from './theme.js';
58
+ // ============================================================
59
+ // ANSI 原语 (散落的转义码只在这里)
60
+ // ============================================================
61
+ const E = '\x1b';
62
+ const RESET = `${E}[0m`;
63
+ /** 反白 (当前光标行的高亮: 结构判据 + 无真彩时的兜底) */
64
+ const REVERSE = `${E}[7m`;
65
+ const BOLD = `${E}[1m`;
66
+ const HIDE_CURSOR = `${E}[?25l`;
67
+ const SHOW_CURSOR = `${E}[?25h`;
68
+ /** 从光标清到屏幕末 (重绘一帧的起点: 既真的清掉上一帧, 也是帧的分界) */
69
+ const ERASE_DOWN = `${E}[J`;
70
+ /** 清到行末 (每一行都用它, 免得窄终端里残留上一帧的长尾巴) */
71
+ const ERASE_EOL = `${E}[K`;
72
+ /** 调子 → 前景 SGR。表**从 `theme.ts` 的 `TONE_TOKEN` 生成**, 这里不再写第二份映射。 */
73
+ const TONE = Object.fromEntries(Object.keys(TONE_TOKEN).map((t) => [t, t === 'plain' ? '' : fg(THEME[TONE_TOKEN[t]])]));
74
+ /**
75
+ * 光标行的高亮 SGR (2026-09-27 leo 口径: 切换界面上色, 别灰白; 三改: 对比要够)。
76
+ *
77
+ * 写法刻意是 **REVERSE + accent 底 + 近黑字**: 反白会把前景/底色互换, 于是实际渲染出来是
78
+ * **accent 底 + `THEME.cursor` 近黑字** —— bolloon 主色块 + 深色字, 对比度 ~11:1。
79
+ * (这里刻意不写那个 hex —— 本文件 hex 字面量必须为 0, 颜色只从 `theme.ts` 来。)
80
+ * 为什么不直接 `bg(accent)`: 反白序列是**结构判据** (验收门靠 `ESC[7m…ESC[0m` 认"哪一行是高亮"),
81
+ * 拿掉它等于把可核证据删了; 而且万一哪台终端吃掉 `7m`, 也还是"深字压主色块"能看清。
82
+ * 无真彩时 (`color=false`) 整段不上, 只剩 `→ ` 前缀 —— 符号通道不依赖颜色。
83
+ */
84
+ const CURSOR_SGR = `${REVERSE}${BOLD}${fg(THEME.cursor)}${bg(THEME.accent)}`;
85
+ /**
86
+ * 帮助行 —— 每一种一等选择方式 + 折叠键 + 取消, 全都写出来 (leo 口径: 键位写进头行)。
87
+ * 大清单靠"固定高度视窗 + 分组折叠"消化, 所以折叠/展开键必须与 ↑↓ 同权写在头行。
88
+ */
89
+ export const TUI_HINT = '↑↓ 移动 · ←→/空格 折叠分组 · Enter 确认 · / 搜索 · 数字跳行 · Esc 取消';
90
+ /** 掩码字符 (屏幕上绝不出现明文) */
91
+ export const MASK_CHAR = '•';
92
+ /** 中间候选区的**固定高度上限** (每步可见行数 ≤ 12; 超出靠滚窗, 不是靠减少家数) */
93
+ export const VIEWPORT_MAX = 12;
94
+ /** 分组收起的标记 (收起 = 只画标题 + 家数; 展开 = 标题 + 成员) */
95
+ export const GROUP_COLLAPSED_MARK = '›';
96
+ /** 分组展开的标记 */
97
+ export const GROUP_EXPANDED_MARK = '▾';
98
+ // ============================================================
99
+ // 颜色开关 (一处判据)
100
+ // ============================================================
101
+ /**
102
+ * 输出流要不要上色。
103
+ *
104
+ * 三条判据, 缺一不可: ① 目标流是 TTY (管道里塞 ANSI 是污染, 脚本没法读);
105
+ * ② `NO_COLOR` 非空 (这是公认约定: 只要设了就关色); ③ `TERM` 不是 `dumb`。
106
+ * 拿不到 `isTTY` (某些宿主) 时按"不是终端"处理 —— 宁可不猜。
107
+ */
108
+ export function ansiEnabled(out = process.stdout) {
109
+ return colorEnabled(!!out.isTTY);
110
+ }
111
+ /** 一个 tone 的着色函数 (不上色时原样返回) */
112
+ export function toneWrap(text, tone, color) {
113
+ if (!color || tone === 'plain' || !TONE[tone])
114
+ return text;
115
+ return `${TONE[tone]}${text}${RESET}`;
116
+ }
117
+ // ============================================================
118
+ // 显示宽度 (CJK 双宽 / 组合符零宽) —— 窄终端不撑破靠它
119
+ // ============================================================
120
+ const ANSI_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[=>]/g;
121
+ /** 去掉 ANSI 转义 (算宽度前必须先去, 否则颜色码被当成可见字符) */
122
+ export function stripAnsi(s) {
123
+ return String(s ?? '').replace(ANSI_RE, '');
124
+ }
125
+ /** 一个 code point 占几列 (东亚宽 = 2; 组合符/零宽 = 0; 其余 = 1) */
126
+ function codePointWidth(cp) {
127
+ if (cp === 0x200b || cp === 0x200c || cp === 0x200d || cp === 0x2060)
128
+ return 0;
129
+ if (cp >= 0x0300 && cp <= 0x036f)
130
+ return 0; // 组合用附加符
131
+ if (cp >= 0xfe00 && cp <= 0xfe0f)
132
+ return 0; // 变体选择符
133
+ if (cp >= 0x1100 && cp <= 0x115f)
134
+ return 2; // 谚文字母
135
+ if (cp >= 0x2e80 && cp <= 0x303e)
136
+ return 2; // CJK 部首/标点
137
+ if (cp >= 0x3041 && cp <= 0x33ff)
138
+ return 2; // 假名/CJK 兼容
139
+ if (cp >= 0x3400 && cp <= 0x4dbf)
140
+ return 2; // CJK 扩展 A
141
+ if (cp >= 0x4e00 && cp <= 0x9fff)
142
+ return 2; // CJK 统一表意
143
+ if (cp >= 0xa000 && cp <= 0xa4cf)
144
+ return 2; // 彝文
145
+ if (cp >= 0xac00 && cp <= 0xd7a3)
146
+ return 2; // 谚文音节
147
+ if (cp >= 0xf900 && cp <= 0xfaff)
148
+ return 2; // CJK 兼容表意
149
+ if (cp >= 0xfe30 && cp <= 0xfe6f)
150
+ return 2; // CJK 兼容形式
151
+ if (cp >= 0xff00 && cp <= 0xff60)
152
+ return 2; // 全角形式
153
+ if (cp >= 0xffe0 && cp <= 0xffe6)
154
+ return 2; // 全角符号
155
+ if (cp >= 0x1f300 && cp <= 0x1f64f)
156
+ return 2; // emoji
157
+ if (cp >= 0x1f900 && cp <= 0x1f9ff)
158
+ return 2;
159
+ if (cp >= 0x20000 && cp <= 0x3fffd)
160
+ return 2;
161
+ return 1;
162
+ }
163
+ /** 可见宽度 (先剥 ANSI; 宽字符按 2 算) */
164
+ export function displayWidth(s) {
165
+ let w = 0;
166
+ for (const ch of stripAnsi(s))
167
+ w += codePointWidth(ch.codePointAt(0));
168
+ return w;
169
+ }
170
+ /**
171
+ * 按可见宽度截断到 `max` 列 (超了结尾放一个 `…`)。
172
+ *
173
+ * ⚠️ **本来就放得下时原样返回 `s`** (不 stripAnsi) —— 否则调用方刚上好的颜色/高亮会被这一句抹掉。
174
+ * 真需要截断时返回的是**纯文本**截断结果 (颜色码不保留): 单行截断没法知道"该在哪儿把 SGR 补回来",
175
+ * 与其截出一串半截转义码, 不如老实给出纯文本。所以**要保留颜色的行必须先按纯文本截好再上色**。
176
+ */
177
+ export function truncateToWidth(s, max) {
178
+ if (max <= 0)
179
+ return '';
180
+ if (displayWidth(s) <= max)
181
+ return s;
182
+ const plain = stripAnsi(s);
183
+ const budget = Math.max(0, max - 1);
184
+ let out = '';
185
+ let w = 0;
186
+ for (const ch of plain) {
187
+ const cw = codePointWidth(ch.codePointAt(0));
188
+ if (w + cw > budget)
189
+ break;
190
+ out += ch;
191
+ w += cw;
192
+ }
193
+ return `${out}…`;
194
+ }
195
+ /** 按可见宽度右填空格到 `width` (高亮行要铺满底色用); 只接受**纯文本** */
196
+ function padToWidth(plain, width) {
197
+ const w = displayWidth(plain);
198
+ return w >= width ? truncateToWidth(plain, width) : plain + ' '.repeat(width - w);
199
+ }
200
+ /** 已知的"多字节转义序列"前缀 —— 尾巴停在这些前缀上时先别当 Esc, 等下一块数据 */
201
+ const SEQ_PREFIXES = ['\x1b[', '\x1bO'];
202
+ function seqEvent(seq) {
203
+ switch (seq) {
204
+ case '\x1b[A':
205
+ case '\x1bOA': return { type: 'up' };
206
+ case '\x1b[B':
207
+ case '\x1bOB': return { type: 'down' };
208
+ case '\x1b[D':
209
+ case '\x1bOD': return { type: 'left' }; // ← 收起光标所在分组
210
+ case '\x1b[C':
211
+ case '\x1bOC': return { type: 'right' }; // → 展开光标所在分组
212
+ case '\x1b[5~': return { type: 'pageup' };
213
+ case '\x1b[6~': return { type: 'pagedown' };
214
+ case '\x1b[H':
215
+ case '\x1b[1~':
216
+ case '\x1bOH': return { type: 'home' };
217
+ case '\x1b[F':
218
+ case '\x1b[4~':
219
+ case '\x1bOF': return { type: 'end' };
220
+ default: return null;
221
+ }
222
+ }
223
+ /**
224
+ * 把一块输入拆成按键事件。**返回的 `rest` 是"还没凑齐的尾巴"** —— 调用方留着与下一块拼。
225
+ *
226
+ * 为什么要有 `rest`: 真终端里 `\x1b`(Esc) 与 `\x1b[B`(↓) 是**同一段前缀**。一块数据刚好停在 `\x1b`
227
+ * 上时, 不能立刻当成 Esc (那会把方向键拆成"取消 + [B"两个键)。所以尾巴是完整序列前缀就先留着,
228
+ * 由调用方的超时把"孤独的 Esc"补成真 Esc。
229
+ */
230
+ export function parseKeys(chunk, rest = '') {
231
+ let buf = rest + chunk;
232
+ const events = [];
233
+ while (buf.length) {
234
+ // 括号粘贴: 整段取出, 一个字都不回显 (只记长度)
235
+ if (buf.startsWith('\x1b[200~')) {
236
+ const end = buf.indexOf('\x1b[201~');
237
+ if (end < 0) {
238
+ buf = buf.slice('\x1b[200~'.length);
239
+ continue;
240
+ }
241
+ events.push({ type: 'paste', text: buf.slice(6, end) });
242
+ buf = buf.slice(end + '\x1b[201~'.length);
243
+ continue;
244
+ }
245
+ if (buf.startsWith('\x1b')) {
246
+ // 尽量吃掉一个完整转义序列: ESC [ 参数 终字符 / ESC O 字符
247
+ const m = /^\x1b\[([0-9;]*)([a-zA-Z~])/.exec(buf) || /^\x1b(O.)/.exec(buf);
248
+ if (m) {
249
+ const ev = seqEvent(m[0]);
250
+ if (ev)
251
+ events.push(ev);
252
+ buf = buf.slice(m[0].length);
253
+ continue;
254
+ }
255
+ if (SEQ_PREFIXES.some((p) => p.startsWith(buf) || buf.startsWith(p))) {
256
+ // `\x1b` / `\x1b[` / `\x1bO` 这种前缀: 等下一块
257
+ if (buf.length <= 2 && SEQ_PREFIXES.some((p) => p.startsWith(buf)))
258
+ break;
259
+ buf = buf.slice(1); // 认不出来的序列: 丢掉 ESC 本身, 不把后续字符当输入
260
+ continue;
261
+ }
262
+ // 后面已经是普通字符 → 这是一个"孤独的 Esc"
263
+ events.push({ type: 'esc' });
264
+ buf = buf.slice(1);
265
+ continue;
266
+ }
267
+ const ch = buf[0];
268
+ buf = buf.slice(1);
269
+ if (ch === '\r' || ch === '\n')
270
+ events.push({ type: 'enter' });
271
+ else if (ch === '\x03')
272
+ events.push({ type: 'ctrl-c' });
273
+ else if (ch === '\x04')
274
+ events.push({ type: 'ctrl-d' });
275
+ else if (ch === '\x7f' || ch === '\x08')
276
+ events.push({ type: 'backspace' });
277
+ else if (ch === '\t')
278
+ events.push({ type: 'tab' });
279
+ else if (ch === '\x15')
280
+ events.push({ type: 'ctrl-u' });
281
+ else if (ch === '\x1b')
282
+ events.push({ type: 'esc' });
283
+ else if (ch.codePointAt(0) < 0x20) { /* 其余控制字符: 忽略 */ }
284
+ else
285
+ events.push({ type: 'char', ch });
286
+ }
287
+ return { events, rest: buf };
288
+ }
289
+ /** 尾巴是不是"只差一块数据的转义前缀" (是的话不能立刻当成 Esc) */
290
+ function tailIsEscapePrefix(rest) {
291
+ if (!rest.startsWith('\x1b'))
292
+ return false;
293
+ return SEQ_PREFIXES.some((p) => p.startsWith(rest) || rest.startsWith(p));
294
+ }
295
+ // ============================================================
296
+ // 终端能力判定 (只有一处)
297
+ // ============================================================
298
+ export const MIN_ROWS = 6;
299
+ /**
300
+ * 能不能用全屏菜单。判据全在这里, 别的文件不许再各判一次:
301
+ * · stdin/stdout 都是 TTY (管道/重定向 → 走 dump 或文本问答);
302
+ * · stdin 有 `setRawMode` (没有 raw mode 就拿不到逐键输入, 退回文本问答);
303
+ * · 终端高度 ≥ `MIN_ROWS` (太矮画不出菜单 —— 退回文本问答, 而不是画个畸形的);
304
+ * · `TERM` 不是 `dumb`; `BOLLOON_NO_TUI=1` 时强制退回 (给"无 curses 降级"留一条可测的路)。
305
+ */
306
+ export function tuiCapable() {
307
+ if (process.env.BOLLOON_NO_TUI === '1')
308
+ return false;
309
+ if (String(process.env.TERM || '').toLowerCase() === 'dumb')
310
+ return false;
311
+ const sin = process.stdin;
312
+ const sout = process.stdout;
313
+ if (!sin?.isTTY || !sout?.isTTY)
314
+ return false;
315
+ if (typeof sin.setRawMode !== 'function')
316
+ return false;
317
+ const rows = Number(sout.rows || 0);
318
+ if (rows && rows < MIN_ROWS)
319
+ return false;
320
+ return true;
321
+ }
322
+ /** 终端尺寸 (拿不到就给保守默认值; `cols` 小于 20 不可信, 按 80 处理) */
323
+ function termSize(out, override) {
324
+ const c = Number(override?.cols ?? out.columns ?? 80);
325
+ const r = Number(override?.rows ?? out.rows ?? 24);
326
+ return { cols: c >= 20 ? Math.floor(c) : 80, rows: r >= MIN_ROWS ? Math.floor(r) : 24 };
327
+ }
328
+ class RawSession {
329
+ rest = '';
330
+ timer = null;
331
+ dataFn = null;
332
+ resizeFn = null;
333
+ closed = false;
334
+ stdin;
335
+ stdout;
336
+ constructor(stdout) {
337
+ this.stdin = process.stdin;
338
+ this.stdout = stdout;
339
+ }
340
+ open(onKey, onResize) {
341
+ this.stdin.setRawMode(true);
342
+ this.stdin.resume();
343
+ try {
344
+ this.stdin.setEncoding('utf8');
345
+ }
346
+ catch { /* 有的宿主不给设, 不影响 */ }
347
+ this.dataFn = (chunk) => {
348
+ const { events, rest } = parseKeys(chunk, this.rest);
349
+ this.rest = rest;
350
+ for (const ev of events)
351
+ onKey(ev);
352
+ // 尾巴停在转义前缀上: 给 30ms 凑齐, 凑不齐就把"孤独的 Esc"补出去
353
+ if (this.timer) {
354
+ clearTimeout(this.timer);
355
+ this.timer = null;
356
+ }
357
+ if (tailIsEscapePrefix(this.rest)) {
358
+ this.timer = setTimeout(() => {
359
+ const lone = this.rest;
360
+ this.rest = '';
361
+ this.timer = null;
362
+ if (lone)
363
+ onKey({ type: 'esc' });
364
+ }, 30);
365
+ }
366
+ };
367
+ this.stdin.on('data', this.dataFn);
368
+ if (onResize) {
369
+ this.resizeFn = onResize;
370
+ process.on('SIGWINCH', this.resizeFn);
371
+ }
372
+ this.write(HIDE_CURSOR);
373
+ }
374
+ write(s) { this.stdout.write(s); }
375
+ close() {
376
+ if (this.closed)
377
+ return;
378
+ this.closed = true;
379
+ if (this.timer) {
380
+ clearTimeout(this.timer);
381
+ this.timer = null;
382
+ }
383
+ if (this.dataFn)
384
+ this.stdin.removeListener('data', this.dataFn);
385
+ if (this.resizeFn)
386
+ process.removeListener('SIGWINCH', this.resizeFn);
387
+ this.write(SHOW_CURSOR);
388
+ try {
389
+ this.stdin.setRawMode(false);
390
+ }
391
+ catch { /* 已恢复 */ }
392
+ try {
393
+ this.stdin.pause();
394
+ }
395
+ catch { /* 无所谓 */ }
396
+ }
397
+ }
398
+ /** 过滤: 大小写不敏感, 匹配 value(id) / label(名字+状态) / hint / group(族) */
399
+ export function matchesQuery(item, query) {
400
+ const q = String(query || '').trim().toLowerCase();
401
+ if (!q)
402
+ return true;
403
+ const hay = [item.value, item.label, item.hint || '', item.group || ''].join('\n').toLowerCase();
404
+ return hay.includes(q);
405
+ }
406
+ /**
407
+ * 把候选行摊成"可画/可落"的行 (**纯函数** —— 单测与验收门都能直接喂它复核)。
408
+ *
409
+ * 三条规矩:
410
+ * ① 有搜索词时**平铺命中项**: 不画分组标题, 也不受任何分组收起状态影响 ——
411
+ * 搜索结果被折叠挡住是最气人的事, 所以命中项一律可见 (等价于"自动展开命中的那一组");
412
+ * ② 无搜索词时按分组分段: 每个分组一行标题 (`── 名字 (N) ›|▾`, **家数永远照写**),
413
+ * 收起的分组不画成员行 (这就是"只画可见的 N 行"的出处);
414
+ * ③ 无分组的候选项 (模型/参数/作用域那些步骤) 原样逐行出 —— 行为与从前一致。
415
+ */
416
+ export function buildDisplayLines(items, collapsed, query) {
417
+ const lines = [];
418
+ if (String(query || '').trim()) {
419
+ items.forEach((item, itemIndex) => lines.push({ kind: 'item', item, itemIndex }));
420
+ return lines;
421
+ }
422
+ const counts = new Map();
423
+ for (const it of items)
424
+ if (it.group)
425
+ counts.set(it.group, (counts.get(it.group) || 0) + 1);
426
+ let lastGroup;
427
+ items.forEach((item, itemIndex) => {
428
+ const g = item.group;
429
+ if (g && g !== lastGroup) {
430
+ const isCollapsed = collapsed.has(g);
431
+ lines.push({ kind: 'sep', group: g, count: counts.get(g) || 0, collapsed: isCollapsed });
432
+ lastGroup = g;
433
+ }
434
+ if (g && collapsed.has(g))
435
+ return; // 收起: 只留标题行, 成员行不渲染
436
+ if (item.cancel) {
437
+ lines.push({ kind: 'item', item, itemIndex });
438
+ return;
439
+ }
440
+ lines.push({ kind: 'item', item, itemIndex });
441
+ });
442
+ return lines;
443
+ }
444
+ /** 视窗高度: 中间候选区**固定高度** = min(12, 终端高 - 头尾各一行) —— 超出的行根本不画 */
445
+ export function viewportHeight(rows) {
446
+ return Math.max(3, Math.min(VIEWPORT_MAX, Math.max(0, rows - 3)));
447
+ }
448
+ /**
449
+ * 全屏光标选择器。返回选中项的 `value`; `null` = 取消
450
+ * (Esc / Ctrl-C / `q`(查询为空时) / 末尾 Cancel 行 / 输入流结束)。
451
+ *
452
+ * 屏幕上永远只有: 头行(标题+计数+键位) · **固定高度视窗内的可见行** · 状态行。
453
+ * 视窗外的行**根本不画** (不是画完再滚出屏幕); 上下边各有一行"还有 N 家"的滚动指示,
454
+ * 让人知道"没画完"而不是"没有"。分组标题可折叠: 收起的分组只占一行且照写家数。
455
+ */
456
+ export function tuiSelect(choices, title, opts = {}) {
457
+ if (!choices.length)
458
+ return Promise.resolve(null);
459
+ const out = opts.out ?? process.stdout;
460
+ const color = opts.color ?? ansiEnabled(out);
461
+ const unit = opts.unit ?? '项';
462
+ const real = choices.filter((c) => !c.cancel);
463
+ const cancelRow = {
464
+ value: '__cancel__',
465
+ label: opts.cancelLabel ?? 'Cancel',
466
+ hint: '取消本次切换 (不改动任何配置)',
467
+ tone: 'warn',
468
+ cancel: true,
469
+ };
470
+ let query = '';
471
+ let cursor = 0; // 光标**行**号 (下标进 lines: 分组标题行也算一行)
472
+ let scrollTop = 0;
473
+ let note = ''; // 非法输入/提示 (状态行里说清为什么)
474
+ let numBuf = ''; // 数字跳行的累计缓冲 (多位数)
475
+ let rendered = 0; // 上一帧写了几行 (重绘时向上回退这么多行)
476
+ let first = true;
477
+ let done = false;
478
+ // ★ 分组折叠状态 (候选集本身**不动**): 默认从候选的 `groupCollapsed` 标记来
479
+ // (第 1 步的"未配置凭据 / 需专用鉴权 / 无基址"三组默认收起), 用户按键只改这一份。
480
+ const collapsed = new Set();
481
+ for (const c of real)
482
+ if (c.group && c.groupCollapsed)
483
+ collapsed.add(c.group);
484
+ /** 当前该画/该落哪些行 (过滤 → 折叠 → 摊行, 全在纯函数里) */
485
+ const linesOf = () => buildDisplayLines([...real.filter((c) => matchesQuery(c, query)), cancelRow], collapsed, query);
486
+ /** 落在第一行**候选**上 (跳过分组标题 —— 标题是折叠手柄, 不是默认落点) */
487
+ const firstItemRow = (ls) => {
488
+ const i = ls.findIndex((l) => l.kind === 'item');
489
+ return i >= 0 ? i : 0;
490
+ };
491
+ if (opts.initialValue) {
492
+ const ls = linesOf();
493
+ const i = ls.findIndex((l) => l.kind === 'item' && l.item.value === opts.initialValue);
494
+ cursor = i >= 0 ? i : firstItemRow(ls);
495
+ }
496
+ else {
497
+ cursor = firstItemRow(linesOf());
498
+ }
499
+ /** 光标所在行 (越界时钳回最后一行) */
500
+ const cursorLine = (ls) => ls[Math.max(0, Math.min(cursor, ls.length - 1))];
501
+ /** 光标当前所属的分组名 (标题行 → 自己; 候选行 → 它那一组; 没有 → undefined) */
502
+ const groupAtCursor = (ls) => {
503
+ const l = cursorLine(ls);
504
+ if (!l)
505
+ return undefined;
506
+ return l.kind === 'sep' ? l.group : l.item.group;
507
+ };
508
+ /**
509
+ * 展开/收起"光标所在的那一组"。收起后把光标**停在该分组的标题行**上 ——
510
+ * 光标永远站在看得见的行上 (收起的那一瞬间原候选行就没了, 不挪光标等于把光标画丢)。
511
+ */
512
+ const setGroupCollapsed = (g, want) => {
513
+ if (want === collapsed.has(g)) {
514
+ note = want ? `${g} 已经是收起的` : `${g} 已经是展开的`;
515
+ return;
516
+ }
517
+ if (want)
518
+ collapsed.add(g);
519
+ else
520
+ collapsed.delete(g);
521
+ const after = linesOf();
522
+ const at = after.findIndex((l) => (l.kind === 'sep' ? l.group === g : l.item.group === g));
523
+ if (at >= 0)
524
+ cursor = at;
525
+ const sep = after.find((l) => l.kind === 'sep' && l.group === g);
526
+ const count = sep && sep.kind === 'sep' ? sep.count : 0;
527
+ note = want
528
+ ? `已收起 ${g} (${count} ${unit}) — 空格/→ 可再展开`
529
+ : `已展开 ${g} (${count} ${unit})`;
530
+ };
531
+ const render = () => {
532
+ const { cols, rows } = termSize(out, opts);
533
+ const H = viewportHeight(rows); // 固定高度视窗 (≤12 行)
534
+ const lines = linesOf();
535
+ const total = lines.length;
536
+ if (cursor > total - 1)
537
+ cursor = total - 1;
538
+ if (cursor < 0)
539
+ cursor = 0;
540
+ // ── 定位视窗: 先把光标放进窗口, 再为"还有 N 家"指示行让位 (让位后可能再挤一次) ──
541
+ let top = Math.max(0, Math.min(scrollTop, Math.max(0, total - H)));
542
+ for (let pass = 0; pass < 4; pass++) {
543
+ const above0 = top > 0;
544
+ const below0 = top + H - (above0 ? 1 : 0) < total;
545
+ const body0 = Math.max(1, H - (above0 ? 1 : 0) - (below0 ? 1 : 0));
546
+ const start0 = top + (above0 ? 1 : 0);
547
+ let nt = top;
548
+ if (cursor < start0)
549
+ nt = Math.max(0, cursor - (above0 ? 1 : 0));
550
+ else if (cursor > start0 + body0 - 1)
551
+ nt = cursor - body0 + 1 - (above0 ? 1 : 0);
552
+ nt = Math.max(0, Math.min(nt, Math.max(0, total - H)));
553
+ if (nt === top)
554
+ break;
555
+ top = nt;
556
+ }
557
+ scrollTop = top;
558
+ const above = top > 0;
559
+ const below = top + H - (above ? 1 : 0) < total;
560
+ const body = Math.max(1, H - (above ? 1 : 0) - (below ? 1 : 0));
561
+ const start = top + (above ? 1 : 0);
562
+ // `已筛` = **命中筛选的家数** (与折叠无关: 收起只是不画成员行, 家数照数) ——
563
+ // 这是"折叠不是隐藏"的可读证据: 全量时 `共 231 家 · 已筛 231 家`, 收起 3 组也不变。
564
+ const filteredItems = real.filter((c) => matchesQuery(c, query)).length;
565
+ const collapsedGroups = [...collapsed].filter((g) => real.some((c) => c.group === g));
566
+ // 头行: 标题 + 计数 (共 N 家 · 已筛 M 家) + 键位提示 —— **不滚** (每帧都画)
567
+ const head = `${String(title).replace(/[::]\s*$/, '')} · 共 ${real.length} ${unit} · 已筛 ${filteredItems} ${unit} ${TUI_HINT}`;
568
+ // 状态行: `第 i/N` 与 `筛选 "x"` 必须相邻 (既有验收门按这个形状钉"数字真的跟着动")
569
+ const shownIdx = cursor + 1;
570
+ const status = [
571
+ `第 ${shownIdx}/${total}`,
572
+ query ? `筛选 "${query}"` : '',
573
+ query ? '命中平铺 (折叠不挡命中)' : '',
574
+ !query && collapsedGroups.length ? `收起 ${collapsedGroups.length} 组` : '',
575
+ note,
576
+ query ? '' : 'q=取消',
577
+ ].filter(Boolean).join(' · ');
578
+ const buf = [];
579
+ buf.push(`${first ? '' : `${E}[${rendered}A`}${ERASE_DOWN}`);
580
+ // 头行: 先按纯文本截到宽度, 再上色 (顺序反了的话颜色码会被截断丢掉)
581
+ buf.push(`${toneWrap(truncateToWidth(head, cols), 'accent', color)}${ERASE_EOL}\r\n`);
582
+ if (above)
583
+ buf.push(`${toneWrap(truncateToWidth(` ↑ 上面还有 ${top} ${unit}`, cols), 'muted', color)}${ERASE_EOL}\r\n`);
584
+ for (let i = 0; i < body; i++) {
585
+ const line = lines[start + i];
586
+ // 先算**纯文本**行内容并按宽度截断/补齐, 再决定怎么上色 —— 保证"颜色不被截断抹掉 + 不撑破"
587
+ let plain = '';
588
+ let tone = 'plain';
589
+ if (line && line.kind === 'sep') {
590
+ const mark = line.collapsed ? GROUP_COLLAPSED_MARK : GROUP_EXPANDED_MARK;
591
+ // 分组标题**照写家数** (折叠 ≠ 藏家数): `── 未配置凭据 (200 家) ›`
592
+ plain = ` ── ${line.group} (${line.count} ${unit}) ${mark}`;
593
+ tone = 'muted';
594
+ }
595
+ else if (line) {
596
+ const bodyText = `${line.item.label}${line.item.hint ? ` — ${line.item.hint}` : ''}`;
597
+ plain = ` ${bodyText}`;
598
+ tone = line.item.tone ?? 'plain';
599
+ }
600
+ else {
601
+ continue; // 窗口尾部没有行: **不画空行**
602
+ }
603
+ // ★ 光标行 (2026-09-27 三改: **光标在任意行类上都有明显选中态**)。
604
+ // leo 亲测报的正是"光标停在分组标题行上, 一行粉都没有" —— 那不是配色问题, 是**这一行类
605
+ // 压根没进高亮分支**: 从前 `isCursor` 只在候选行那一支里算, 分组标题行 (sep) 永远是 false,
606
+ // 于是光标站在标题上时屏上与"没选中"字节完全相同 (只有个 ` ` 前缀, 连 `→` 都没有)。
607
+ // 现在: 判据只有一句 `start + i === cursor`, 与行类无关; 分组标题 / 普通项 / `special` /
608
+ // `无基址` / `← 当前` / `Cancel` 一律照此高亮。
609
+ const isCursor = start + i === cursor;
610
+ if (isCursor) {
611
+ // ` ` 前导换成 `→ ` (两者都占 2 列, 补齐宽度不受影响) —— 符号通道在 NO_COLOR 下也分得清
612
+ plain = `→ ${plain.slice(2)}`;
613
+ // ★ 光标行: `→ ` 前缀 + **accent 底 / 近黑字** (见 CURSOR_SGR; 反白序列同时是结构判据)
614
+ const padded = padToWidth(truncateToWidth(plain, cols), cols);
615
+ buf.push(`${color ? `${CURSOR_SGR}${padded}${RESET}` : truncateToWidth(plain, cols)}${ERASE_EOL}\r\n`);
616
+ continue;
617
+ }
618
+ buf.push(`${toneWrap(truncateToWidth(plain, cols), tone, color)}${ERASE_EOL}\r\n`);
619
+ }
620
+ if (below)
621
+ buf.push(`${toneWrap(truncateToWidth(` ↓ 下面还有 ${total - (start + body)} ${unit}`, cols), 'muted', color)}${ERASE_EOL}\r\n`);
622
+ buf.push(`${toneWrap(truncateToWidth(status, cols), 'muted', color)}${ERASE_EOL}`);
623
+ // 帧共 1(头) + (above?1:0) + body + (below?1:0) + 1(状态) ≤ 终端高度 行 (硬预算)
624
+ rendered = 1 + (above ? 1 : 0) + body + (below ? 1 : 0);
625
+ out.write(buf.join(''));
626
+ first = false;
627
+ };
628
+ const finish = (value) => {
629
+ if (done)
630
+ return;
631
+ done = true;
632
+ if (rendered > 0)
633
+ out.write(`${E}[${rendered}A${ERASE_DOWN}`);
634
+ resolve(value);
635
+ };
636
+ let resolve = () => { };
637
+ const promise = new Promise((res) => { resolve = res; });
638
+ const session = new RawSession(out);
639
+ const onKey = (k) => {
640
+ const maxIdx = linesOf().length - 1;
641
+ const clampCursor = () => { cursor = Math.max(0, Math.min(cursor, linesOf().length - 1)); };
642
+ switch (k.type) {
643
+ case 'up': {
644
+ if (cursor > 0)
645
+ cursor--;
646
+ else
647
+ cursor = maxIdx;
648
+ numBuf = '';
649
+ note = '';
650
+ break;
651
+ }
652
+ case 'down': {
653
+ if (cursor < maxIdx)
654
+ cursor++;
655
+ else
656
+ cursor = 0;
657
+ numBuf = '';
658
+ note = '';
659
+ break;
660
+ }
661
+ case 'pageup': {
662
+ cursor = Math.max(0, cursor - Math.max(1, Math.floor(viewportHeight(termSize(out, opts).rows) / 2)));
663
+ numBuf = '';
664
+ note = '';
665
+ break;
666
+ }
667
+ case 'pagedown': {
668
+ cursor = Math.min(maxIdx, cursor + Math.max(1, Math.floor(viewportHeight(termSize(out, opts).rows) / 2)));
669
+ numBuf = '';
670
+ note = '';
671
+ break;
672
+ }
673
+ case 'home':
674
+ cursor = 0;
675
+ numBuf = '';
676
+ note = '';
677
+ break;
678
+ case 'end':
679
+ cursor = maxIdx;
680
+ numBuf = '';
681
+ note = '';
682
+ break;
683
+ // ── 折叠/展开 (leo 口径: `←` 收起 / `→` 展开 / `空格` 切换) ──
684
+ case 'left': {
685
+ const g = groupAtCursor(linesOf());
686
+ if (g)
687
+ setGroupCollapsed(g, true);
688
+ else
689
+ note = '这一行没有分组可收起';
690
+ numBuf = '';
691
+ break;
692
+ }
693
+ case 'right': {
694
+ const g = groupAtCursor(linesOf());
695
+ if (g)
696
+ setGroupCollapsed(g, false);
697
+ else
698
+ note = '这一行没有分组可展开';
699
+ numBuf = '';
700
+ break;
701
+ }
702
+ case 'char': {
703
+ const ch = k.ch;
704
+ if (ch === '/' && query === '') {
705
+ // `/` = 显式进入筛选 (输字同样即时筛; 这一条只是把"我要筛选"说明白)
706
+ note = '筛选模式: 直接敲字即过滤 (Backspace 退字, 清空 = 回全量)';
707
+ break;
708
+ }
709
+ if (ch === ' ' && query === '') {
710
+ // 空格 = 折叠/展开光标所在分组 (搜索时它是**普通过滤字符**, 免得"搜带空格的词"没法打)
711
+ const g = groupAtCursor(linesOf());
712
+ if (g)
713
+ setGroupCollapsed(g, !collapsed.has(g));
714
+ else
715
+ note = '这一行没有分组可折叠';
716
+ numBuf = '';
717
+ break;
718
+ }
719
+ if (/[0-9]/.test(ch)) {
720
+ // ★ 数字跳行: 逐位累计 (先 1 再 2 = 第 12 行), 高亮当场跟过去
721
+ const next = `${numBuf}${ch}`;
722
+ const n = Number(next);
723
+ if (n >= 1 && n <= maxIdx + 1) {
724
+ numBuf = next;
725
+ cursor = n - 1;
726
+ note = `已跳到第 ${n} 项`;
727
+ }
728
+ else if (numBuf === '' && ch === '0') {
729
+ note = `✗ 序号从 1 开始 (没有第 0 项)`;
730
+ }
731
+ else {
732
+ // 多位数越界 → 退化成"只有最后这一位"再试 (标准做法), 单个数越界就报范围
733
+ const last = Number(ch);
734
+ if (next.length > 1 && last >= 1 && last <= maxIdx + 1) {
735
+ numBuf = ch;
736
+ cursor = last - 1;
737
+ note = `已跳到第 ${last} 项 (${next} 越界)`;
738
+ }
739
+ else {
740
+ numBuf = '';
741
+ note = `✗ 序号 ${next} 超出范围 (这里只有 1~${maxIdx + 1} 项)`;
742
+ }
743
+ }
744
+ }
745
+ else if (ch === 'q' && query === '') {
746
+ session.close();
747
+ finish(null);
748
+ return;
749
+ }
750
+ else {
751
+ // ★ 字母/文字 = 真筛选 (过滤后序号按新序重排, 命中项平铺 —— 收起的分组也挡不住命中)
752
+ query += ch;
753
+ numBuf = '';
754
+ note = '';
755
+ cursor = firstItemRow(linesOf());
756
+ scrollTop = 0;
757
+ }
758
+ break;
759
+ }
760
+ case 'paste': {
761
+ query += k.text.replace(/[\r\n]+/g, ' ');
762
+ numBuf = '';
763
+ note = '';
764
+ cursor = firstItemRow(linesOf());
765
+ scrollTop = 0;
766
+ break;
767
+ }
768
+ case 'backspace': {
769
+ if (query) {
770
+ query = query.slice(0, -1);
771
+ cursor = firstItemRow(linesOf());
772
+ scrollTop = 0;
773
+ note = '';
774
+ }
775
+ else if (numBuf) {
776
+ numBuf = '';
777
+ note = '';
778
+ }
779
+ else {
780
+ note = '已在全量列表 (没有可退的筛选词)';
781
+ }
782
+ break;
783
+ }
784
+ case 'ctrl-u': {
785
+ query = '';
786
+ numBuf = '';
787
+ note = '筛选已清空 (回全量)';
788
+ cursor = firstItemRow(linesOf());
789
+ scrollTop = 0;
790
+ break;
791
+ }
792
+ case 'enter': {
793
+ const hit = linesOf()[cursor];
794
+ if (!hit) {
795
+ session.close();
796
+ finish(null);
797
+ return;
798
+ }
799
+ // 落在分组标题上: Enter = 展开/收起 (它不是候选, 选不了 —— 所以不会"选了个标题"这种鬼事)
800
+ if (hit.kind === 'sep') {
801
+ setGroupCollapsed(hit.group, !hit.collapsed);
802
+ clampCursor();
803
+ render();
804
+ return;
805
+ }
806
+ if (hit.item.cancel) {
807
+ session.close();
808
+ finish(null);
809
+ return;
810
+ }
811
+ session.close();
812
+ finish(hit.item.value);
813
+ return;
814
+ }
815
+ case 'esc':
816
+ case 'ctrl-c':
817
+ case 'ctrl-d': {
818
+ session.close();
819
+ finish(null);
820
+ return;
821
+ }
822
+ default: break;
823
+ }
824
+ render();
825
+ };
826
+ session.open(onKey, () => { render(); });
827
+ // 先把终端切进 raw (关掉 ONLCR 之类的输出后处理), 再画第一帧 —— 否则首帧的 `\r\n` 会被
828
+ // tty 驱动再插一个 `\r` (量到 `\r\r\n`), 而且"首帧之前"那段是唯一没有逐键能力的窗口。
829
+ render();
830
+ return promise;
831
+ }
832
+ /**
833
+ * 掩码输入 —— **API key 只能走这里**。
834
+ *
835
+ * 三条硬规矩:
836
+ * ① 屏幕上只出现 `•` 与长度, **明文一个字节都不回显** (也不进日志/报告);
837
+ * ② 粘贴整段也算输入 (括号粘贴标记被吃掉), 同样只画掩码;
838
+ * ③ 拿不到 raw mode 时**如实降级并说明** —— 交给调用方走 readline 隐藏输入, 绝不静默变成明文回显。
839
+ */
840
+ export function tuiAskMasked(opts) {
841
+ const out = opts.out ?? process.stdout;
842
+ const color = opts.color ?? ansiEnabled(out);
843
+ const cap = opts.maskCap ?? 24;
844
+ let value = '';
845
+ let done = false;
846
+ let resolve = () => { };
847
+ const promise = new Promise((res) => { resolve = res; });
848
+ const line = () => {
849
+ const n = displayWidth(value);
850
+ const mask = MASK_CHAR.repeat(Math.min(n, cap)) + (n > cap ? `+${n - cap}` : '');
851
+ return `${opts.prompt} [${mask}] ${n ? `(${n} 字符)` : '(还没输入)'}`;
852
+ };
853
+ const draw = () => {
854
+ const { cols } = termSize(out, opts);
855
+ out.write(`\r${truncateToWidth(line(), cols)}${ERASE_EOL}`);
856
+ };
857
+ const finish = (r) => {
858
+ if (done)
859
+ return;
860
+ done = true;
861
+ session.close();
862
+ out.write('\r\n');
863
+ resolve(r);
864
+ };
865
+ /** 掩码输入的按键处理: 只累加/退格, 屏幕上永远只有掩码 */
866
+ const onKey = (k) => {
867
+ switch (k.type) {
868
+ case 'char':
869
+ value += k.ch;
870
+ draw();
871
+ break;
872
+ case 'paste':
873
+ value += k.text.replace(/[\r\n]+/g, '');
874
+ draw();
875
+ break;
876
+ case 'backspace':
877
+ value = value.slice(0, -1);
878
+ draw();
879
+ break;
880
+ case 'ctrl-u':
881
+ value = '';
882
+ draw();
883
+ break;
884
+ // 掩码规则: 方向键/Home/End 一律不动内容 (改 key 只能退格重打, 免得半截编辑出错)
885
+ case 'enter':
886
+ finish({ value: value.trim(), eof: false, cancelled: false });
887
+ break;
888
+ case 'esc':
889
+ case 'ctrl-c':
890
+ finish({ value: '', eof: false, cancelled: true });
891
+ break;
892
+ case 'ctrl-d':
893
+ finish({ value: '', eof: true, cancelled: true });
894
+ break;
895
+ default: break;
896
+ }
897
+ };
898
+ if (opts.note)
899
+ out.write(`${truncateToWidth(opts.note, termSize(out, opts).cols)}${ERASE_EOL}\r\n`);
900
+ const session = new RawSession(out);
901
+ session.open(onKey);
902
+ draw();
903
+ return promise;
904
+ }
905
+ /** 普通单行输入 (搜索词/温度那种); 同样回退行内重绘, 但**明文可见** (它不是秘密) */
906
+ export function tuiAskLine(opts) {
907
+ const out = opts.out ?? process.stdout;
908
+ let value = '';
909
+ let done = false;
910
+ let resolve = () => { };
911
+ const promise = new Promise((res) => { resolve = res; });
912
+ const draw = () => {
913
+ const { cols } = termSize(out, opts);
914
+ const tail = opts.defaultValue !== undefined && !value ? ` [${opts.defaultValue}]` : '';
915
+ out.write(`\r${truncateToWidth(`${opts.prompt}${tail} ${value}`, cols)}${ERASE_EOL}`);
916
+ };
917
+ const finish = (r) => {
918
+ if (done)
919
+ return;
920
+ done = true;
921
+ session.close();
922
+ out.write('\r\n');
923
+ resolve(r);
924
+ };
925
+ /** 普通行的按键处理 (明文可见 —— 这条输入不是秘密) */
926
+ const onKey = (k) => {
927
+ switch (k.type) {
928
+ case 'char':
929
+ value += k.ch;
930
+ draw();
931
+ break;
932
+ case 'paste':
933
+ value += k.text.replace(/[\r\n]+/g, '');
934
+ draw();
935
+ break;
936
+ case 'backspace':
937
+ value = value.slice(0, -1);
938
+ draw();
939
+ break;
940
+ case 'ctrl-u':
941
+ value = '';
942
+ draw();
943
+ break;
944
+ case 'enter':
945
+ finish({ value: value.trim() || (opts.defaultValue ?? ''), eof: false, cancelled: false });
946
+ break;
947
+ case 'esc':
948
+ case 'ctrl-c':
949
+ finish({ value: '', eof: false, cancelled: true });
950
+ break;
951
+ case 'ctrl-d':
952
+ finish({ value: '', eof: true, cancelled: true });
953
+ break;
954
+ default: break;
955
+ }
956
+ };
957
+ if (opts.note)
958
+ out.write(`${truncateToWidth(opts.note, termSize(out, opts).cols)}${ERASE_EOL}\r\n`);
959
+ const session = new RawSession(out);
960
+ session.open(onKey);
961
+ draw();
962
+ return promise;
963
+ }
964
+ /** 掩码输入的自我描述 (进报告/日志/界面都只用这一句, 里面没有明文) */
965
+ export function maskSummary(value) {
966
+ const n = displayWidth(value);
967
+ if (!n)
968
+ return '(空)';
969
+ return `${MASK_CHAR.repeat(Math.min(n, 8))} (${n} 字符)`;
970
+ }