@bolloon/bolloon-agent 0.6.1 → 0.6.3

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 (78) hide show
  1. package/README.md +128 -111
  2. package/dist/agents/gateway-group.js +231 -6
  3. package/dist/agents/group-autopilot.js +256 -0
  4. package/dist/agents/pi-sdk-tools.js +302 -0
  5. package/dist/agents/pi-sdk.js +89 -140
  6. package/dist/agents/task-group.js +21 -4
  7. package/dist/cli/log-gate.js +68 -4
  8. package/dist/cli/mention-data.js +1 -1
  9. package/dist/cli/model-selector.js +84 -104
  10. package/dist/cli/setup-wizard.js +2 -10
  11. package/dist/cli/tui-select.js +99 -10
  12. package/dist/cli-entry.js +1 -9
  13. package/dist/electron-build/agents/gateway-group.js +814 -0
  14. package/dist/electron-build/agents/gateway-group.js.map +1 -0
  15. package/dist/electron-build/agents/group-autopilot.js +302 -0
  16. package/dist/electron-build/agents/group-autopilot.js.map +1 -0
  17. package/dist/electron-build/agents/pi-sdk-tools.js +302 -0
  18. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  19. package/dist/electron-build/agents/pi-sdk.js +88 -139
  20. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  21. package/dist/electron-build/agents/task-group.js +601 -0
  22. package/dist/electron-build/agents/task-group.js.map +1 -0
  23. package/dist/electron-build/agents/task-public-text.js +97 -0
  24. package/dist/electron-build/agents/task-public-text.js.map +1 -0
  25. package/dist/electron-build/efficode/compress.js +86 -0
  26. package/dist/electron-build/efficode/compress.js.map +1 -0
  27. package/dist/electron-build/efficode/crc32.js +44 -0
  28. package/dist/electron-build/efficode/crc32.js.map +1 -0
  29. package/dist/electron-build/efficode/did.js +74 -0
  30. package/dist/electron-build/efficode/did.js.map +1 -0
  31. package/dist/electron-build/efficode/lz77.js +175 -0
  32. package/dist/electron-build/efficode/lz77.js.map +1 -0
  33. package/dist/electron-build/efficode/negotiate.js +233 -0
  34. package/dist/electron-build/efficode/negotiate.js.map +1 -0
  35. package/dist/electron-build/efficode/ops.js +96 -0
  36. package/dist/electron-build/efficode/ops.js.map +1 -0
  37. package/dist/electron-build/efficode/packet.js +279 -0
  38. package/dist/electron-build/efficode/packet.js.map +1 -0
  39. package/dist/electron-build/efficode/types.js +50 -0
  40. package/dist/electron-build/efficode/types.js.map +1 -0
  41. package/dist/electron-build/kernel/code-write-gate.js +55 -0
  42. package/dist/electron-build/kernel/code-write-gate.js.map +1 -0
  43. package/dist/electron-build/kernel/run-lifecycle.js +23 -0
  44. package/dist/electron-build/kernel/run-lifecycle.js.map +1 -1
  45. package/dist/electron-build/kernel/run-resume.js +62 -0
  46. package/dist/electron-build/kernel/run-resume.js.map +1 -0
  47. package/dist/electron-build/kernel/session-lifecycle.js +223 -0
  48. package/dist/electron-build/kernel/session-lifecycle.js.map +1 -0
  49. package/dist/electron-build/kernel/turn-selfcheck.js +65 -0
  50. package/dist/electron-build/kernel/turn-selfcheck.js.map +1 -0
  51. package/dist/electron-build/llm/config-store.js +2 -2
  52. package/dist/electron-build/llm/config-store.js.map +1 -1
  53. package/dist/electron-build/llm/model-selection.js +16 -2
  54. package/dist/electron-build/llm/model-selection.js.map +1 -1
  55. package/dist/electron-build/llm/pi-ai.js +2 -2
  56. package/dist/electron-build/llm/pi-ai.js.map +1 -1
  57. package/dist/electron-build/orbitdb/cid-database.js +98 -19
  58. package/dist/electron-build/orbitdb/cid-database.js.map +1 -1
  59. package/dist/electron-build/orbitdb/group-access.js +399 -0
  60. package/dist/electron-build/orbitdb/group-access.js.map +1 -0
  61. package/dist/index.js +143 -10
  62. package/dist/ios/bolloon-web.json +3 -3
  63. package/dist/ios/sw.js +1 -1
  64. package/dist/kernel/code-write-gate.js +47 -0
  65. package/dist/kernel/gate-scan.js +5 -1
  66. package/dist/kernel/plan-harness.js +3 -2
  67. package/dist/kernel/roster.js +6 -6
  68. package/dist/kernel/run-lifecycle.js +21 -0
  69. package/dist/kernel/run-resume.js +58 -0
  70. package/dist/kernel/session-lifecycle.js +210 -0
  71. package/dist/kernel/turn-selfcheck.js +60 -0
  72. package/dist/llm/config-store.js +2 -2
  73. package/dist/llm/model-selection.js +16 -2
  74. package/dist/llm/pi-ai.js +2 -2
  75. package/dist/orbitdb/cid-database.js +95 -19
  76. package/dist/web/bolloon-web.json +3 -3
  77. package/dist/web/sw.js +1 -1
  78. package/package.json +1 -1
@@ -4,10 +4,13 @@
4
4
  * 流程 (与命令面同一条路, 只是把参数逐步问出来):
5
5
  *
6
6
  * ```
7
- * 1 选择供应商 → 2 未配置则输 API key → 3 选择模型 (模糊搜索) → 4 reasoning/temperature
8
- * → 5 Session 还是 Global → 6 测试连接 → 7 确认切换
7
+ * 1 选择供应商 → 2 (需要 key 则) 凭证 → 3 选择模型 (模糊搜索) → 4 确认 (连通测试+确认)
9
8
  * ```
10
9
  *
10
+ * 2026-10-05 (leo: 不该七层嵌套): 从 7 步压成 4 步 —— 删掉单独的
11
+ * `生成参数` 与 `作用域` 两步 (一律走全局默认、沿用配置里的参数), 把 `测试连接`
12
+ * 与 `确认切换` 合并成第 4 步 (一次点完)。
13
+ *
11
14
  * ## 两条硬纪律
12
15
  *
13
16
  * ① **切换只有一次写盘动作, 且只走统一入口**。本文件不碰配置文件、不碰模型运行时 ——
@@ -26,8 +29,8 @@
26
29
  * 三态到人话的翻译与排版**, 一个能力值都不产生 —— 拿不到真数据时列表上写的是"未知",
27
30
  * 而不是一个看起来像真的常量。
28
31
  */
29
- import { buildProviderSummaries, listModelsFor, formatModelMenuRow, providerGroupSummary, providerTierOf, providerTierCollapsedByDefault, orderProvidersForMenu, PROVIDER_GROUPS, providerRowTone, formatProviderMenuRow, unknownFootnote, searchModelEntries, resolvedApiKeyOf, } from '../llm/model-catalog.js';
30
- import { selectModel, probeSelection, credentialFingerprintOf, envKeyNamesOf as envKeysOf, } from '../llm/model-selection.js';
32
+ import { buildProviderSummaries, listModelsFor, formatModelMenuRow, providerGroupSummary, orderProvidersForMenu, providerRowTone, formatProviderMenuRow, unknownFootnote, searchModelEntries, resolvedApiKeyOf, } from '../llm/model-catalog.js';
33
+ import { selectModel, probeSelection, credentialFingerprintOf, envKeyNamesOf as envKeysOf, baseDefaultsOf, } from '../llm/model-selection.js';
31
34
  import { admitManualModel } from '../llm/model-discovery.js';
32
35
  // 颜色只从 `theme.ts` 来 (唯一事实源): 调子→token 的映射与 `tint()` 都住在那边。
33
36
  import { tint } from './theme.js';
@@ -200,8 +203,8 @@ export async function runModelSelector(io, opts = {}) {
200
203
  return done({ ok: false, reachedStep: 'provider', message: `读供应商列表失败: ${String(e?.message || e).slice(0, 160)}` });
201
204
  }
202
205
  const live = summaries.filter((s) => s.configured);
203
- // "共 N 家" 从盘上真算 (内置 + 自定义 + 目录全部), 不写死 —— 验收门拿它跟盘上数字对。
204
- push(`步骤 1/7 供应商 (共 ${summaries.length} 家登记: 内置+自定义+目录全部 · 可用 ${live.length} 家 · 分组可折叠)`);
206
+ // 2026-10-05 (leo: 不该七层嵌套 → 压成 4 步): 供应商 → 凭证(仅需 key) → 模型 → 确认(合并原测试/作用域/参数)
207
+ push(`步骤 1/4 供应商 (共 ${summaries.length} 家登记: 内置+自定义+目录全部 · 可用 ${live.length} 家 · 全部平铺可滚动)`);
205
208
  verbose(` ${providerGroupSummary(summaries)}`);
206
209
  try {
207
210
  // 动态 import: 选择器 → provider-catalog 是单向的 (目录层不 import 选择器), 不构成环;
@@ -218,19 +221,14 @@ export async function runModelSelector(io, opts = {}) {
218
221
  if (!summaries.length)
219
222
  return done({ ok: false, reachedStep: 'provider', message: '没有任何登记在册的供应商' });
220
223
  // 排序 = 分组优先级 (当前 → 可用 → 未配置凭据 → 需专用鉴权 → 无 api 基址);
221
- // **全部家都在候选数组里** (包括后三组), 只是它们的分组**默认收起** (标题上照写家数)。
224
+ // 全部家都是平铺的单选行 (固定高度视窗 + 滚动消化一屏放不下的部分), 不再分组折叠。
222
225
  const ordered = orderProvidersForMenu(summaries);
223
226
  const providerChoices = ordered.map((s) => {
224
- const tier = providerTierOf(s);
225
227
  return {
226
228
  value: s.id,
227
229
  label: formatProviderMenuRow(s),
228
230
  hint: `${s.name}${s.providerReasoning === 'yes' ? ' · 登记支持 reasoning' : ''}${s.configuredModel ? ` · 配置里 model=${s.configuredModel}` : ''}`,
229
- // 分组标题只在切换分组时印一次; 家数由选择器写在标题行上 (`── 名字 (N 家) ›`)
230
- group: PROVIDER_GROUPS[tier],
231
- // 后三组默认收起: 页面放得下, 而家数照样看得见 (`空格`/`→` 一键展开)
232
- groupCollapsed: providerTierCollapsedByDefault(tier),
233
- // 颜色只做**第二通道**: 语义本体是 label 里的符号 (●/○) 与分组标题 (special/无基址 → warn)
231
+ // 颜色只做**第二通道**: 语义本体是 label 里的符号 (●/○) 与分组 (special/无基址 → warn)
234
232
  tone: providerRowTone(s),
235
233
  };
236
234
  });
@@ -249,7 +247,7 @@ export async function runModelSelector(io, opts = {}) {
249
247
  : summary.keyState === 'missing' ? '缺 key'
250
248
  : summary.keyState === 'env' ? '来自环境变量' : '已配置';
251
249
  const envKeys = envKeysOf(providerId);
252
- push(`步骤 2/7 凭证 — ${providerId}: ${keyZh}${fp ? ` (指纹 ${fp})` : ''}`);
250
+ push(`步骤 2/4 凭证 — ${providerId}: ${keyZh}${fp ? ` (指纹 ${fp})` : ''}`);
253
251
  if (summary.requiresApiKey && io.askHidden) {
254
252
  const choices = [];
255
253
  if (summary.keyState !== 'missing')
@@ -298,6 +296,56 @@ export async function runModelSelector(io, opts = {}) {
298
296
  else if (summary.requiresApiKey) {
299
297
  push(`沿用现有 key (指纹 ${fp || '已配置'})`);
300
298
  }
299
+ // ── 2.5) URL 怎么处理 (leo 2026-10-05: TUI 里要能管理 URL, 和 key 同级) ──────
300
+ // 展示口径: 当前值 + **官方默认**并排 —— 默认给官方来源, 用户再决定 保持/替换/清除。
301
+ let baseUrlAction = 'keep';
302
+ let pendingBaseUrl;
303
+ const officialUrl = (() => {
304
+ try {
305
+ return baseDefaultsOf(providerId, null)?.baseUrl || '';
306
+ }
307
+ catch {
308
+ return '';
309
+ }
310
+ })();
311
+ const currentUrl = summary.baseUrl || officialUrl || '(无)';
312
+ if (io.choose || io.ask) {
313
+ const urlChoices = [
314
+ { value: 'keep', label: '[K]eep 保持当前 URL', hint: `当前: ${currentUrl}`, tone: 'ok' },
315
+ { value: 'replace', label: '[R]eplace 输入新 URL', hint: '覆盖配置里这一格', tone: 'accent' },
316
+ // 清除 = 回落官方默认 (目录家没有官方基址则这步会拦下)
317
+ { value: 'clear', label: '[C]lear 回落官方默认', hint: officialUrl ? `官方: ${officialUrl}` : '这家没有官方基址, 需自定义', tone: 'warn' },
318
+ ];
319
+ const urlAct = await pick(urlChoices, `URL 怎么处理 (当前: ${currentUrl} · 官方: ${officialUrl || '(无)'}):`);
320
+ if (!urlAct)
321
+ return done({ ok: false, cancelled: true, reachedStep: 'key', message: '已取消, 未改动任何配置' });
322
+ if (urlAct === 'replace') {
323
+ const raw = await text(`输入 ${providerId} 的 base URL (含 https://, 不带 /v1 尾缀)`);
324
+ if (raw === null) {
325
+ push(EOF_CANCEL);
326
+ return done({ ok: false, cancelled: true, reachedStep: 'key', message: '输入已结束 (EOF/Ctrl-D) —— 未改动任何配置' });
327
+ }
328
+ if (!raw.trim()) {
329
+ push('URL 为空 → 按保持当前处理');
330
+ }
331
+ else {
332
+ pendingBaseUrl = raw.trim();
333
+ baseUrlAction = 'replace';
334
+ }
335
+ }
336
+ else if (urlAct === 'clear') {
337
+ if (!officialUrl) {
338
+ push(`✗ ${providerId} 没有官方基址可回落 —— 只能保持或替换。未改动任何配置。`);
339
+ }
340
+ else {
341
+ baseUrlAction = 'clear';
342
+ push(`将回落官方默认: ${officialUrl}`);
343
+ }
344
+ }
345
+ else {
346
+ push(`保持当前 URL: ${currentUrl}`);
347
+ }
348
+ }
301
349
  // ── 3) 模型 (模糊搜索 + 当前置顶) ─────────────────────────
302
350
  let entries;
303
351
  try {
@@ -312,7 +360,7 @@ export async function runModelSelector(io, opts = {}) {
312
360
  message: `${providerId} 没有可用模型 (无内置目录且配置为空) —— 请用 \`/model ${providerId} <model>\` 直接指定, 或先配好目录`,
313
361
  });
314
362
  }
315
- push(`步骤 3/7 模型 — ${providerId}, ${entries.length} 个候选`);
363
+ push(`步骤 3/4 模型 — ${providerId}, ${entries.length} 个候选`);
316
364
  for (const f of unknownFootnote(entries))
317
365
  verbose(` ${f}`);
318
366
  let candidates = entries;
@@ -386,92 +434,23 @@ export async function runModelSelector(io, opts = {}) {
386
434
  : entry?.upstreamSeen === true ? ` · ${entry.upstreamOrigin === 'live' ? '上游目录' : '上游目录(缓存)'}`
387
435
  : '';
388
436
  push(`已选模型: ${chosenModelId}${srcTag}`);
389
- // ── 4) 生成参数 (reasoning / temperature) ────────────────
437
+ // ── 4) 确认 (原 测试+作用域+参数 合并收进一步; 只写全局默认, 参数沿用配置) ──────
438
+ // 2026-10-05 (leo: 不该七层嵌套 → 压成 4 步): 参数/作用域不再单列 —— 参数沿用配置,
439
+ // 作用域由 opts.scope 显式传 (不传 = global; `--session` 语义靠它保留)。
390
440
  const params = {};
391
- push('步骤 4/7 生成参数');
392
- // reasoning: 只有"登记为支持"时才提供开关; 能力未知就不给这一项 (不猜)
393
- if (summary.providerReasoning === 'yes') {
394
- const r = await pick([
395
- { value: 'unset', label: '不设 (沿用配置里的值)' },
396
- { value: 'on', label: '开 (请求推理/思考模式)' },
397
- { value: 'off', label: '关' },
398
- ], `${providerId} 登记支持 reasoning — 要不要开? (本机偏好, 不是模型能力数据)`);
399
- if (r === 'on')
400
- params.reasoningMode = true;
401
- else if (r === 'off')
402
- params.reasoningMode = false;
403
- }
404
- else {
405
- verbose(`reasoning: 该供应商的模型级能力未知 → 不提供开关, 也不写默认值`);
406
- }
407
- const temp = await pick([
408
- { value: 'skip', label: '不设 (沿用配置里的值)' },
409
- { value: '0', label: 'temperature = 0' },
410
- { value: '0.3', label: 'temperature = 0.3' },
411
- { value: '0.7', label: 'temperature = 0.7' },
412
- { value: '1.0', label: 'temperature = 1.0' },
413
- { value: '1.5', label: 'temperature = 1.5' },
414
- ...(io.ask ? [{ value: '__custom__', label: '手工输入 (0~2)' }] : []),
415
- ], 'temperature (0~2)');
416
- if (temp === null)
417
- return done({ ok: false, cancelled: true, reachedStep: 'params', message: '已取消, 未改动任何配置' });
418
- if (temp === '__custom__') {
419
- const raw = await text('temperature (0~2)', '0.7');
420
- if (raw === null) {
421
- push(EOF_CANCEL);
422
- return done({ ok: false, cancelled: true, reachedStep: 'params', message: '输入已结束 (EOF/Ctrl-D) —— 未改动任何配置' });
423
- }
424
- if (raw === '') {
425
- push('没输入 → 本项不设');
426
- }
427
- else {
428
- const n = Number(raw);
429
- if (!Number.isFinite(n) || n < 0 || n > 2) {
430
- return done({ ok: false, reachedStep: 'params', failureClass: 'invalid_temperature', message: `temperature 只接受 0~2 的数字, 收到 '${raw}' — 未改动任何配置` });
431
- }
432
- params.temperature = n;
433
- }
434
- }
435
- else if (temp !== 'skip') {
436
- params.temperature = Number(temp);
437
- }
438
- push(`temperature: ${params.temperature === undefined ? '不设' : params.temperature}`);
439
- // ── 5) 作用域 ────────────────────────────────────────────
440
- let scope = 'global';
441
- push('步骤 5/7 作用域');
442
- for (let round = 0; round < 2; round++) {
443
- const s = await pick([
444
- { value: 'global', label: '全局默认 (影响新会话 + 未绑定模型的任务)' },
445
- { value: 'session', label: '仅当前会话 (不动全局默认)' },
446
- ], '这次切换的作用域?');
447
- if (s === null)
448
- return done({ ok: false, cancelled: true, reachedStep: 'scope', message: '已取消, 未改动任何配置' });
449
- scope = s === 'session' ? 'session' : 'global';
450
- if (scope === 'session' && pendingKey) {
451
- push('✗ 会话级切换只做路由、不写凭证 —— 要么改成全局默认, 要么先 `bolloon model key ' + providerId + '` 再回来只切路由。');
452
- continue;
453
- }
454
- break;
455
- }
456
- if (scope === 'session' && pendingKey) {
457
- return done({
458
- ok: false, reachedStep: 'scope', failureClass: 'credential_scope_conflict',
459
- message: '会话级切换不写凭证 — 未改动任何配置 (请先配全局凭证, 再用 --session 只切路由)',
460
- });
461
- }
462
- push(`作用域: ${scope === 'global' ? '全局默认' : '仅当前会话'}`);
441
+ const scope = opts.scope === 'session' ? 'session' : 'global';
463
442
  return await commit(done, push, confirm, verbose, {
464
- providerId, summary, pendingKey, credentialAction, modelId: chosenModelId, baseUrl: summary.baseUrl, opts, params, scope,
443
+ providerId, summary, pendingKey, credentialAction, modelId: chosenModelId, baseUrl: summary.baseUrl,
444
+ baseUrlAction, pendingBaseUrl, opts, params, scope,
465
445
  });
466
446
  }
467
447
  async function commit(done, push, confirm, verbose, a) {
468
448
  const params = a.params || {};
469
449
  const scope = a.scope || 'global';
470
- // ── 6) 测试连接 (对**候选**配置探测; 不写任何东西) ─────────
471
- // 版面: **失败就直接停**, 一行说清"什么错 + 怎么办" —— 不再问"还要不要继续"
472
- // (那种问句按 y 也照样被第 7 步拦下, 是假选择)。
450
+ // ── 4) 确认 (对**候选**配置连通测试, 一次直接到确认; 不写任何东西前置) ──────────
451
+ // 版面: **失败就直接停**, 一行说清"什么错 + 怎么办" —— 不再问"还要不要继续"。
473
452
  if (a.opts.verify === false) {
474
- verbose('步骤 6/7 已按 --no-verify 跳过连通探测 (第 6 步预检与第 7 步切换前校验都关)');
453
+ verbose('步骤 4/4 已按 --no-verify 跳过连通探测 (预检与切换前校验都关)');
475
454
  }
476
455
  else if (!a.opts.skipProbe) {
477
456
  // 预检必须用**和落盘时同一份凭证**: 本次新输入的 key 优先, 否则用该供应商已配置/环境变量里的那一份。
@@ -487,16 +466,11 @@ async function commit(done, push, confirm, verbose, a) {
487
466
  + (probe.failureClass === 'model_not_found' ? ` 换个模型, 或用 \`bolloon model ${a.providerId} <model>\` 直接指定。` : ''),
488
467
  });
489
468
  }
490
- push(`步骤 6/7 连通测试通过 (${a.modelId} @ ${a.baseUrl}, ${ms} ms)`
469
+ push(`步骤 4/4 连通测试通过 (${a.modelId} @ ${a.baseUrl}, ${ms} ms)`
491
470
  + (probe.modelAcceptedOutsideCatalog ? ' — 上游目录未列出, 但端点接受' : ''));
492
- // 第 7 步的"确认"必须先把**要提交的东西**印出来 —— 否则用户是在确认一份看不见的配置。
493
- // 版面压到最少: 供应商/模型/作用域/参数 (探测结论上一步已说, 不重复; 基址只在 verbose)。
494
- push('步骤 7/7 确认');
471
+ // "确认"必须先把**要提交的东西**印出来 —— 否则用户是在确认一份看不见的配置。
472
+ // 版面压到最少: 供应商/模型/作用域 (参数沿用配置, 基址只在 verbose)。
495
473
  push(` ${a.providerId} / ${a.modelId} · ${scope === 'global' ? '全局默认' : '仅当前会话'}`);
496
- if (params.temperature !== undefined || params.reasoningMode !== undefined) {
497
- push(` 参数: ${params.temperature === undefined ? '' : `temperature=${params.temperature}`}`
498
- + `${params.reasoningMode === undefined ? '' : `${params.temperature === undefined ? '' : ' · '}reasoning=${params.reasoningMode ? '开' : '关'}`}`);
499
- }
500
474
  verbose(` 基址: ${a.baseUrl}`);
501
475
  verbose(` 凭证: ${a.pendingKey ? '本次新输入的 key (掩码输入, 落盘为全局凭证)' : '沿用已配置 / 环境变量里的那一份'}`);
502
476
  const proceed = a.opts.assumeYes ? true : await confirm('确认按上面的配置切换?', true);
@@ -504,14 +478,20 @@ async function commit(done, push, confirm, verbose, a) {
504
478
  return done({ ok: false, cancelled: true, reachedStep: 'test', message: '已取消, 未改动任何配置' });
505
479
  }
506
480
  else {
507
- verbose('步骤 6/7 已跳过预检 (skipProbe) —— 第 7 步的切换前校验仍在');
481
+ verbose('步骤 4/4 已跳过预检 (skipProbe) —— 切换前校验仍在');
508
482
  }
509
- // ── 7) 唯一写盘点 ───────────────────────────────────────
483
+ // ── 唯一写盘点 (第 4 步确认后执行) ───────────────────────────
510
484
  const req = {
511
485
  provider: a.providerId,
512
486
  model: a.modelId,
513
- baseUrl: a.baseUrl,
514
487
  scope,
488
+ // URL 意图 (leo 2026-10-05): replace 用新输入的 URL; clear 让 selectModel 回落官方默认;
489
+ // keep 则只带当前值 (与历史语义一致)。baseUrlAction 缺省时传 baseUrl 保持旧行为。
490
+ ...(a.baseUrlAction === 'replace' && a.pendingBaseUrl
491
+ ? { baseUrl: a.pendingBaseUrl, baseUrlAction: 'replace' }
492
+ : a.baseUrlAction === 'clear'
493
+ ? { baseUrlAction: 'clear' }
494
+ : { baseUrl: a.baseUrl }),
515
495
  ...(a.pendingKey ? { apiKey: a.pendingKey } : {}),
516
496
  ...(a.credentialAction !== 'keep' ? { credentialAction: a.credentialAction } : {}),
517
497
  ...(params.temperature !== undefined ? { temperature: params.temperature } : {}),
@@ -21,7 +21,7 @@ import * as path from 'path';
21
21
  import * as os from 'os';
22
22
  import { llmConfigStore } from '../llm/config-store.js';
23
23
  import { selectModel, resetModelSelection, probeSelection, effectiveModelConfig, formatEffectiveModel, normalizeBaseUrl, validateBaseUrlShape, currentSessionKey, } from '../llm/model-selection.js';
24
- import { buildProviderSummaries, formatProviderMenuRow, providerGroupSummary, providerTierOf, providerTierCollapsedByDefault, orderProvidersForMenu, PROVIDER_GROUPS, } from '../llm/model-catalog.js';
24
+ import { buildProviderSummaries, formatProviderMenuRow, providerGroupSummary, orderProvidersForMenu, } from '../llm/model-catalog.js';
25
25
  import { runModelSelector } from './model-selector.js';
26
26
  // 着色判据只有一处 (`theme.ts`) —— 主屏与全屏选择器共用
27
27
  import { colorEnabled } from './theme.js';
@@ -551,19 +551,11 @@ export function parseModelCommand(arg) {
551
551
  async function providerLines(sessionKey) {
552
552
  const summaries = await buildProviderSummaries({ sessionKey });
553
553
  // 2026-09-27 (目录驱动, 二改): 这里**列全量** (内置 13 + 自定义 + 目录全部), 并按第 1 步同一套
554
- // 分组优先级排出分组标题 (带家数)。纯文本这条路没有视窗/折叠 (那是全屏选择器的事), 所以逐行列全 ——
554
+ // 分组优先级排序, **不再带分组分隔条** (全平铺)。纯文本这条路没有视窗/折叠 (那是全屏选择器的事), 所以逐行列全 ——
555
555
  // 门禁钉着"不许只给计数": 任何一家都要能在这份输出里被看见。
556
556
  const lines = [];
557
557
  const ordered = orderProvidersForMenu(summaries);
558
- let lastGroup = '';
559
558
  for (const s of ordered) {
560
- const tier = providerTierOf(s);
561
- const g = PROVIDER_GROUPS[tier];
562
- if (g !== lastGroup) {
563
- lastGroup = g;
564
- lines.push(` ── ${g} (${ordered.filter((x) => providerTierOf(x) === tier).length} 家)`
565
- + `${providerTierCollapsedByDefault(tier) ? ' · 全屏选择器里默认收起 (空格/→ 展开)' : ''}`);
566
- }
567
559
  lines.push(` ${formatProviderMenuRow(s)}`);
568
560
  }
569
561
  lines.push(` ${providerGroupSummary(summaries)}`);
@@ -87,10 +87,19 @@ const CURSOR_SGR = `${REVERSE}${BOLD}${fg(THEME.cursor)}${bg(THEME.accent)}`;
87
87
  * 大清单靠"固定高度视窗 + 分组折叠"消化, 所以折叠/展开键必须与 ↑↓ 同权写在头行。
88
88
  */
89
89
  export const TUI_HINT = '↑↓ 移动 · ←→/空格 折叠分组 · Enter 确认 · / 搜索 · 数字跳行 · Esc 取消';
90
+ /**
91
+ * 纯平铺单选列表 (无分组) 的提示 —— 不带折叠键, 与参考的 provider 单选列表一致
92
+ * (leo 2026-10-05: `bolloon model` 显示一直折叠不方便 → 供应商列表改纯平铺)。
93
+ * 折叠键只在候选真的带分组 (有 `group` 字段) 时才出现在头行。
94
+ */
95
+ export const TUI_HINT_FLAT = '↑↓ 移动 · Enter 确认 · / 搜索 · 数字跳行 · Esc 取消';
90
96
  /** 掩码字符 (屏幕上绝不出现明文) */
91
97
  export const MASK_CHAR = '•';
92
- /** 中间候选区的**固定高度上限** (每步可见行数 ≤ 12; 超出靠滚窗, 不是靠减少家数) */
93
- export const VIEWPORT_MAX = 12;
98
+ /**
99
+ * 中间候选区的**固定高度上限** (每步可见行数上限; 实际视窗 = min(VIEWPORT_MAX, 终端高-头尾),
100
+ * leo 2026-10-05: 「选择高度窗口要扩大」→ 从 12 提到 40, 高终端下一屏能看到更多家, 超出靠滚窗)。
101
+ */
102
+ export const VIEWPORT_MAX = 40;
94
103
  /** 分组收起的标记 (收起 = 只画标题 + 家数; 展开 = 标题 + 成员) */
95
104
  export const GROUP_COLLAPSED_MARK = '›';
96
105
  /** 分组展开的标记 */
@@ -220,6 +229,20 @@ function seqEvent(seq) {
220
229
  default: return null;
221
230
  }
222
231
  }
232
+ /**
233
+ * SGR 鼠标上报的滚轮码 (触控板两指滚动 / 鼠标滚轮)。
234
+ * 序列形如 `\x1b[<状态码;列;行M` (按下) 或 `…m` (释放, 滚轮无释放)。状态码 = 基准 + 修饰位:
235
+ * · 64 = 滚轮上 / 65 = 滚轮下 (卷滚类没有"行坐标", 但上报仍带; 我们只用方向)
236
+ * · 滚轮方向键 (向上滚 = 看更早内容 ⇒ 等价 ↑; 向下滚 = 看更后内容 ⇒ 等价 ↓)
237
+ * 注意: 只有卷滚状态码 (64-67) 会持续滚动期间反复出现; 按下类 (0-2) 是点击, 由调用方忽略。
238
+ */
239
+ export function wheelFromSgr(code) {
240
+ if (code === 64)
241
+ return { dy: 1 }; // 滚轮上 → 列表往上滚 (等价 ↑)
242
+ if (code === 65)
243
+ return { dy: -1 }; // 滚轮下 → 列表往下滚 (等价 ↓)
244
+ return null; // 其它码 (左/中/右键、平移) 不当作滚动
245
+ }
223
246
  /**
224
247
  * 把一块输入拆成按键事件。**返回的 `rest` 是"还没凑齐的尾巴"** —— 调用方留着与下一块拼。
225
248
  *
@@ -243,6 +266,24 @@ export function parseKeys(chunk, rest = '') {
243
266
  continue;
244
267
  }
245
268
  if (buf.startsWith('\x1b')) {
269
+ // SGR 鼠标上报 (触控板/鼠标滚轮): `\x1b[<状态码;列;行M`(按下) 或 `…m`(释放)。
270
+ // 状态码前有个 `<`, 通用 `ESC[ 参数 终字符` 正则会把它吞掉, 所以要在这里单独吃。
271
+ // 只把滚轮 (64/65) 转成 `wheel` 事件; 点击/平移 (0-2 等) 就此忽略 —— 用户只要滚动翻列表。
272
+ if (buf.startsWith('\x1b[<')) {
273
+ const sm = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/.exec(buf);
274
+ if (sm) {
275
+ const w = wheelFromSgr(Number(sm[1]));
276
+ if (w)
277
+ events.push({ type: 'wheel', dy: w.dy });
278
+ buf = buf.slice(sm[0].length);
279
+ continue;
280
+ }
281
+ // `\x1b[<` 或 `\x1b[<64` 这种不完整尾巴: 等下一块拼全 (别把数字当输入)
282
+ if (/^\x1b\[<[0-9;]*$/.test(buf))
283
+ break;
284
+ buf = buf.slice(1);
285
+ continue;
286
+ }
246
287
  // 尽量吃掉一个完整转义序列: ESC [ 参数 终字符 / ESC O 字符
247
288
  const m = /^\x1b\[([0-9;]*)([a-zA-Z~])/.exec(buf) || /^\x1b(O.)/.exec(buf);
248
289
  if (m) {
@@ -290,6 +331,10 @@ export function parseKeys(chunk, rest = '') {
290
331
  function tailIsEscapePrefix(rest) {
291
332
  if (!rest.startsWith('\x1b'))
292
333
  return false;
334
+ // SGR 鼠标尾巴 (`\x1b[<`, `\x1b[<64`, …): 是不完整的上报序列, **不是**孤独 Esc —— 等完整 `…M/m` 来
335
+ // 再解析成滚轮/忽略。若 30ms 后仍不完整, 丢弃即可 (鼠标上报被终端自己保证成块到达)。
336
+ if (rest.startsWith('\x1b[<'))
337
+ return true;
293
338
  return SEQ_PREFIXES.some((p) => p.startsWith(rest) || rest.startsWith(p));
294
339
  }
295
340
  // ============================================================
@@ -369,6 +414,9 @@ class RawSession {
369
414
  this.resizeFn = onResize;
370
415
  process.on('SIGWINCH', this.resizeFn);
371
416
  }
417
+ // 开 SGR 鼠标上报 (触控板/滚轮) —— 让终端把两指滚动当成 `\x1b[<64|65;…` 发进来,
418
+ // `?1006h` = SGR 坐标格式, `?1000h` = 按下/释放事件。关闭时还原 (`close`)。
419
+ this.write(`${E}[?1006h${E}[?1000h`);
372
420
  this.write(HIDE_CURSOR);
373
421
  }
374
422
  write(s) { this.stdout.write(s); }
@@ -384,6 +432,11 @@ class RawSession {
384
432
  this.stdin.removeListener('data', this.dataFn);
385
433
  if (this.resizeFn)
386
434
  process.removeListener('SIGWINCH', this.resizeFn);
435
+ // 还原鼠标上报 + 显示光标 (退出后触控板/鼠标回到终端自己的行为: 能划选文本、能正常右键)
436
+ try {
437
+ this.write(`${E}[?1000l${E}[?1006l`);
438
+ }
439
+ catch { /* 已关闭 */ }
387
440
  this.write(SHOW_CURSOR);
388
441
  try {
389
442
  this.stdin.setRawMode(false);
@@ -559,12 +612,16 @@ export function tuiSelect(choices, title, opts = {}) {
559
612
  const below = top + H - (above ? 1 : 0) < total;
560
613
  const body = Math.max(1, H - (above ? 1 : 0) - (below ? 1 : 0));
561
614
  const start = top + (above ? 1 : 0);
562
- // `已筛` = **命中筛选的家数** (与折叠无关: 收起只是不画成员行, 家数照数) ——
563
- // 这是"折叠不是隐藏"的可读证据: 全量时 `共 231 家 · 已筛 231 家`, 收起 3 组也不变。
615
+ // `已筛` = **命中筛选的家数** ——
616
+ // 折叠是通用能力: 只在候选真的带分组时才出现在提示与状态里 (模型选择器现为纯平铺单选列表,
617
+ // 不带分组 → 提示里不出现折叠键, 与参考图一致)。折叠机制仍保留给需要分组的调用方用。
564
618
  const filteredItems = real.filter((c) => matchesQuery(c, query)).length;
565
619
  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}`;
620
+ const hasGroups = real.some((c) => c.group);
621
+ // 头行: 标题 + 计数 (共 N 家 · 已筛 M 家) + 键位提示 —— **不滚** (每帧都画)。
622
+ // 提示按候选是否带分组裁剪: 无分组 (纯平铺) 时只给移动/确认/搜索/数字/Esc, 不带折叠键。
623
+ const hint = hasGroups ? TUI_HINT : TUI_HINT_FLAT;
624
+ const head = `${String(title).replace(/[::]\s*$/, '')} · 共 ${real.length} ${unit} · 已筛 ${filteredItems} ${unit} ${hint}`;
568
625
  // 状态行: `第 i/N` 与 `筛选 "x"` 必须相邻 (既有验收门按这个形状钉"数字真的跟着动")
569
626
  const shownIdx = cursor + 1;
570
627
  const status = [
@@ -576,11 +633,18 @@ export function tuiSelect(choices, title, opts = {}) {
576
633
  query ? '' : 'q=取消',
577
634
  ].filter(Boolean).join(' · ');
578
635
  const buf = [];
636
+ // ★ 重绘回退 = **实际画出的行数** (drawn), 不是视窗容量 body。
637
+ // leo 2026-10-05 报「切换吞掉之前的内容 + 高度回退」的根因: 筛选后候选只剩 2 项,
638
+ // 帧实际只画 4 行, 但 rendered 用 body(=27) 记成了 28 → finish 发 `ESC[28A` 上移 28 行,
639
+ // 把上面主屏的 `步骤 1/4`/`已选供应商` 全吞掉。VIEWPORT_MAX 12→40 后 body 变大, 吞得更狠。
579
640
  buf.push(`${first ? '' : `${E}[${rendered}A`}${ERASE_DOWN}`);
580
641
  // 头行: 先按纯文本截到宽度, 再上色 (顺序反了的话颜色码会被截断丢掉)
581
642
  buf.push(`${toneWrap(truncateToWidth(head, cols), 'accent', color)}${ERASE_EOL}\r\n`);
582
- if (above)
643
+ let drawn = 1; // 头行
644
+ if (above) {
583
645
  buf.push(`${toneWrap(truncateToWidth(` ↑ 上面还有 ${top} ${unit}`, cols), 'muted', color)}${ERASE_EOL}\r\n`);
646
+ drawn++;
647
+ }
584
648
  for (let i = 0; i < body; i++) {
585
649
  const line = lines[start + i];
586
650
  // 先算**纯文本**行内容并按宽度截断/补齐, 再决定怎么上色 —— 保证"颜色不被截断抹掉 + 不撑破"
@@ -613,15 +677,22 @@ export function tuiSelect(choices, title, opts = {}) {
613
677
  // ★ 光标行: `→ ` 前缀 + **accent 底 / 近黑字** (见 CURSOR_SGR; 反白序列同时是结构判据)
614
678
  const padded = padToWidth(truncateToWidth(plain, cols), cols);
615
679
  buf.push(`${color ? `${CURSOR_SGR}${padded}${RESET}` : truncateToWidth(plain, cols)}${ERASE_EOL}\r\n`);
680
+ drawn++;
616
681
  continue;
617
682
  }
618
683
  buf.push(`${toneWrap(truncateToWidth(plain, cols), tone, color)}${ERASE_EOL}\r\n`);
684
+ drawn++;
619
685
  }
620
- if (below)
686
+ if (below) {
621
687
  buf.push(`${toneWrap(truncateToWidth(` ↓ 下面还有 ${total - (start + body)} ${unit}`, cols), 'muted', color)}${ERASE_EOL}\r\n`);
688
+ drawn++;
689
+ }
622
690
  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);
691
+ drawn++; // 状态行
692
+ // 帧共 drawn 行 (头 + 上下指示 + 实际候选行数 + 状态) ≤ 终端高度 (硬预算)。
693
+ // finish 的 `ESC[nA` 用 drawn-1: 光标从状态行回到帧首行, ESC[J 清掉整帧;
694
+ // 用容量 body 会导致上移过头, 把上面的主屏行也吞掉 (本 bug 的根因)。
695
+ rendered = Math.max(1, drawn - 1);
625
696
  out.write(buf.join(''));
626
697
  first = false;
627
698
  };
@@ -658,6 +729,24 @@ export function tuiSelect(choices, title, opts = {}) {
658
729
  note = '';
659
730
  break;
660
731
  }
732
+ // 触控板两指滚动 / 鼠标滚轮 (SGR 上报): 滚上=↑, 滚下=↓ —— 和方向键同一套移动逻辑
733
+ case 'wheel': {
734
+ if (k.dy === 1) {
735
+ if (cursor > 0)
736
+ cursor--;
737
+ else
738
+ cursor = maxIdx;
739
+ }
740
+ else {
741
+ if (cursor < maxIdx)
742
+ cursor++;
743
+ else
744
+ cursor = 0;
745
+ }
746
+ numBuf = '';
747
+ note = '';
748
+ break;
749
+ }
661
750
  case 'pageup': {
662
751
  cursor = Math.max(0, cursor - Math.max(1, Math.floor(viewportHeight(termSize(out, opts).rows) / 2)));
663
752
  numBuf = '';
package/dist/cli-entry.js CHANGED
@@ -636,18 +636,10 @@ async function handleModelCommand(modelArgs) {
636
636
  console.log(` ${formatEffectiveModel(eff)}`);
637
637
  console.log('─'.repeat(58));
638
638
  // 非终端里没有"固定高度视窗 + 折叠"那套交互 (那是全屏 TUI 的事), 所以这里**整份列全**:
639
- // 内置 13 + 自定义 + 目录全部 逐行出, 分组标题带家数。只给"分组计数"是这条线要修的病 ——
639
+ // 内置 13 + 自定义 + 目录全部 逐行出, 不再带分组分隔条。只给"分组计数"是这条线要修的病 ——
640
640
  // 脚本要能 grep 到任何一家 (含"需专用鉴权 (未支持)""无基址"的那些)。
641
641
  const ordered = mc.orderProvidersForMenu(summaries);
642
- let lastGroup = '';
643
642
  for (const s of ordered) {
644
- const tier = mc.providerTierOf(s);
645
- const g = mc.PROVIDER_GROUPS[tier];
646
- if (g !== lastGroup) {
647
- lastGroup = g;
648
- const n = ordered.filter((x) => mc.providerTierOf(x) === tier).length;
649
- console.log(` ── ${g} (${n} 家)${mc.providerTierCollapsedByDefault(tier) ? ' · 交互界面里默认收起, 空格/→ 展开' : ''}`);
650
- }
651
643
  console.log(` ${mc.formatProviderMenuRow(s)}`);
652
644
  }
653
645
  console.log(` ${mc.providerGroupSummary(summaries)}`);