thincoder 0.12.66 → 0.12.68

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 (69) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +23 -5
  3. package/bin/thincoder.mjs +43 -331
  4. package/package.json +2 -2
  5. package/src/acp/bridge.mjs +13 -12
  6. package/src/acp/handlers-session.mjs +12 -1
  7. package/src/cli/make-agent.mjs +44 -119
  8. package/src/cli/memory-command.mjs +45 -3
  9. package/src/cli/permission.mjs +1 -1
  10. package/src/command-interactive.mjs +186 -0
  11. package/src/command-table.mjs +185 -0
  12. package/src/completions.mjs +36 -12
  13. package/src/crash-reports.mjs +42 -20
  14. package/src/distill.mjs +3 -3
  15. package/src/heap-watch.mjs +5 -14
  16. package/src/prompt-injections.mjs +1 -1
  17. package/src/tui/agent-turn.mjs +114 -69
  18. package/src/tui/clipboard.mjs +74 -30
  19. package/src/tui/cmd-advisor.mjs +54 -13
  20. package/src/tui/cmd-clear.mjs +2 -2
  21. package/src/tui/cmd-config.mjs +26 -2
  22. package/src/tui/cmd-eng.mjs +1 -1
  23. package/src/tui/cmd-mcp.mjs +1 -1
  24. package/src/tui/cmd-new.mjs +2 -2
  25. package/src/tui/cmd-reindex.mjs +1 -1
  26. package/src/tui/cmd-session.mjs +13 -5
  27. package/src/tui/cmd-think.mjs +35 -17
  28. package/src/tui/cmd-timers.mjs +33 -0
  29. package/src/tui/conversation-writer.mjs +2 -2
  30. package/src/tui/display-budget.mjs +9 -9
  31. package/src/tui/fold-block.mjs +1 -1
  32. package/src/tui/index.mjs +23 -5
  33. package/src/tui/interaction.mjs +5 -3
  34. package/src/tui/key-handler-busy.mjs +17 -12
  35. package/src/tui/key-handler-ctrlc.mjs +5 -5
  36. package/src/tui/key-handler-edit.mjs +12 -8
  37. package/src/tui/key-handler-search.mjs +1 -1
  38. package/src/tui/key-handler.mjs +2 -2
  39. package/src/tui/key-modes.mjs +1 -1
  40. package/src/tui/layout.mjs +5 -5
  41. package/src/tui/ledger-surface.mjs +26 -5
  42. package/src/tui/lifecycle-records.mjs +199 -0
  43. package/src/tui/model-picker.mjs +16 -199
  44. package/src/tui/mouse.mjs +5 -5
  45. package/src/tui/pickers.mjs +6 -2
  46. package/src/tui/provider-admin.mjs +213 -0
  47. package/src/tui/queued-merge.mjs +12 -0
  48. package/src/tui/queued-pickup.mjs +34 -0
  49. package/src/tui/render-conversation.mjs +49 -4
  50. package/src/tui/render-frame.mjs +54 -11
  51. package/src/tui/render-loop.mjs +2 -3
  52. package/src/tui/render-segments.mjs +4 -4
  53. package/src/tui/render.mjs +1 -1
  54. package/src/tui/slash-commands.mjs +3 -0
  55. package/src/tui/startup.mjs +28 -3
  56. package/src/tui/subagent-blocks.mjs +37 -29
  57. package/src/tui/subagent-children.mjs +5 -5
  58. package/src/tui/subagent-freeze.mjs +43 -41
  59. package/src/tui/subagent-panel.mjs +11 -11
  60. package/src/tui/suspension-drive.mjs +173 -253
  61. package/src/tui/timer-watch.mjs +64 -0
  62. package/src/tui/tool-args.mjs +10 -71
  63. package/src/tui/tool-display.mjs +7 -7
  64. package/src/tui/tool-events.mjs +40 -35
  65. package/src/tui/tui-lifecycle.mjs +13 -4
  66. package/src/tui/tui-state.mjs +3 -2
  67. package/src/tui/turn-face.mjs +6 -3
  68. package/src/tui/wizard.mjs +7 -2
  69. package/src/tui/wrapped-spawn.mjs +3 -3
@@ -7,20 +7,24 @@
7
7
  * 拉到的行直接可选);显式 `provider:model` 一律放行(M4——仅[空值/裸值/未知 provider]无效)。
8
8
  * 切换成功回显规格来源(M6);渠道默认模型 = `providers[].model` 单值(M3——显示回退读取)。
9
9
  * 配置写入面(加渠道 / 设 key)探一次 `/models`(M9——探不通标「不可用」+ 明示原因,不阻断保存)。
10
- * 渠道管理流(add/remove/key/context)是 provider 级 config 写——语义不变。F-4 (ISSUE-FIX-BATCH):
11
- * removeProviderFlow 级联清理悬挂引用(cascadeRemoveProvider——文件尾导出)。
10
+ * 渠道管理流(add/remove/key/context)是 provider 级 config 写——语义不变;2026-09-29 结构拆分起
11
+ * 四流居 provider-admin.mjs(本档装配 ∥ 尾同名再出口)。F-4 (ISSUE-FIX-BATCH):removeProviderFlow
12
+ * 级联清理悬挂引用(cascadeRemoveProvider——定义同在 provider-admin.mjs)。
12
13
  * ctx: { agent, state, render, ansi, C, pushLine, persistRaw, askQuestion, maskKey, showPicker,
13
14
  * closePicker, renderPickerLines, confirmDelete }(后四者为 pickers.mjs 通用 picker 绑定——闭包入参,环安全)。
14
15
  */
15
16
  import { sliceByWidth } from "./render.mjs"
16
- import { PROVIDER_PRESETS as PRESETS, providerSpec, specMatch } from "@thincoder/core/config.mjs"
17
+ import { providerSpec, specMatch } from "@thincoder/core/config.mjs"
17
18
  import { saveSession } from "@thincoder/core/session.mjs"
18
- import { getProviderModels, probeChannelModels, modelListFailureText, dedupeModels } from "./model-catalog.mjs"
19
+ import { getProviderModels, modelListFailureText, dedupeModels } from "./model-catalog.mjs"
20
+ import { createProviderAdmin } from "./provider-admin.mjs"
19
21
 
20
22
  /** createModelPicker(ctx) → { openModelPicker, selectModel, setProviderKey, setContextFlow, pickModelForSlot } */
21
23
  export function createModelPicker(ctx) {
22
24
  const { agent, state, pushLine, persistRaw, askQuestion, maskKey, showPicker, closePicker, renderPickerLines, confirmDelete = () => { throw new Error("ctx.confirmDelete missing — deletion refused (fail-closed)") } } = ctx
23
25
  const { ansi, C } = ctx
26
+ // 渠道管理四流 + 级联清理(provider-admin.mjs——structure-split-2 迁出:本档装配 ∥ 尾同名再出口)
27
+ const admin = createProviderAdmin({ agent, pushLine, persistRaw, askQuestion, maskKey, showPicker, confirmDelete, C, fmtContextK, defaultModelLabel })
24
28
 
25
29
  /** entry 唯一标识:异步更新 entries 后按它恢复选中项 */
26
30
  function entryKey(e) {
@@ -68,13 +72,13 @@ export function createModelPicker(ctx) {
68
72
  if (modelSelected) return // model selected, close picker
69
73
  // Esc from model list → return to provider list
70
74
  } else if (e.action === "add") {
71
- await addProviderFlow()
75
+ await admin.addProviderFlow()
72
76
  } else if (e.action === "remove") {
73
- await removeProviderFlow()
77
+ await admin.removeProviderFlow()
74
78
  } else if (e.action === "key") {
75
- await setKeyFlow()
79
+ await admin.setKeyFlow()
76
80
  } else if (e.action === "context") {
77
- await setContextFlow()
81
+ await admin.setContextFlow()
78
82
  }
79
83
  }
80
84
  }
@@ -113,7 +117,7 @@ export function createModelPicker(ctx) {
113
117
  const shown = active ? (agent.activeModel ?? defaultModelLabel(p)) : defaultModelLabel(p)
114
118
  const marker = active ? "●" : ""
115
119
  // A1(第 20 批 §12.5):渠道警示((no key) / (不可用))上移 text——与既有状态标
116
- // ((ctx …) / ← session)同簇(先例 = 本文件 setKeyFlow / wizard 的 (added, no key)):
120
+ // ((ctx …) / ← session)同簇(先例 = provider-admin.mjs setKeyFlow / wizard 的 (added, no key)):
117
121
  // 警示不再位于最先牺牲段。note 收窄为 baseURL(补充信息——超宽随行右截断可接受)。
118
122
  const sessionNote = active ? " ← session" : ""
119
123
  const keyStatus = p.apiKey ? "" : " (no key)"
@@ -247,7 +251,7 @@ export function createModelPicker(ctx) {
247
251
  )
248
252
  if (!agent.provider.apiKey) {
249
253
  const selKey = await askQuestion(`Enter API key for ${item.provider} (leave empty to skip):`)
250
- if (selKey) await setProviderKey(item.provider, selKey)
254
+ if (selKey) await admin.setProviderKey(item.provider, selKey)
251
255
  }
252
256
  }
253
257
 
@@ -306,194 +310,7 @@ export function createModelPicker(ctx) {
306
310
  renderPickerLines()
307
311
  }
308
312
 
309
- // ═══ 渠道管理流(provider 级 config 写——语义不变——与 /model 会话选择分离)═══
310
-
311
- /** M9 配置阶段准入探:探通 → 清标记;探不通 → 会话内存标「不可用」+ 明示原因(绝不落 config;
312
- * 不阻断保存——条目已保存,仅标注)。 */
313
- async function probeChannelFlow(cfg, label) {
314
- const r = await probeChannelModels(cfg)
315
- if (r.ok) {
316
- delete cfg._unavailable
317
- pushLine(`${label}: /models 可用(${r.list.length} 个模型可候选)`, C.tool)
318
- } else {
319
- cfg._unavailable = true
320
- pushLine(`${label} 不可用 — ${r.message}`, C.error)
321
- }
322
- return r
323
- }
324
-
325
- async function addProviderFlow() {
326
- const entries = [
327
- { type: "header", text: "Select a preset provider" },
328
- ...Object.entries(PRESETS).filter(([name]) => !agent.providers.some((p) => p.name === name))
329
- .map(([name, p]) => ({ type: "item", text: `${name.padEnd(10)} ${p.desc ?? ""} (${p.model ?? ""})`, name, kind: "preset" })),
330
- { type: "header", text: "Other" },
331
- { type: "item", text: "Custom (manual config)", name: "__custom__", kind: "custom" },
332
- ]
333
- const se = await showPicker("Add Provider", entries)
334
- if (!se) return // Esc → 返回上级(openModelPicker 循环会重开主菜单)
335
- if (se.kind === "custom") {
336
- const name = await askQuestion("Enter provider name:")
337
- if (!name) return
338
- if (agent.providers.some((p) => p.name === name)) return
339
- const baseURL = (await askQuestion("Enter baseURL:")).replace(/\/+$/, "")
340
- if (!baseURL) return
341
- const model = await askQuestion("Enter model name:")
342
- if (!model) return
343
- const format = await showPicker("API format", [
344
- { type: "item", text: "openai", name: "openai" },
345
- { type: "item", text: "anthropic", name: "anthropic" },
346
- { type: "item", text: "google", name: "google" },
347
- ])
348
- if (!format) return // Esc/取消 → 中止流程
349
- const cfg = { name, baseURL, model }
350
- if (format.name === "anthropic" || format.name === "google") cfg.format = format.name
351
- else if (format.name !== "openai") return // 防御:未知格式(理论不可达——picker 枚举)
352
- await persistRaw((raw) => { raw.providers ??= []; raw.providers.push(cfg) }) // D-F5a 先盘后存
353
- agent.providers.push(cfg)
354
- const key = await askQuestion(`Enter API key for ${name} (skip if none):`)
355
- if (key) await setProviderKey(name, key, { probe: false }) // 探统一在流尾(M9——精确一次)
356
- await probeChannelFlow(cfg, name) // M9 准入探(加渠道配置写入面;保存已落——不阻断)
357
- return
358
- }
359
- const preset = PRESETS[se.name]
360
- if (!preset || agent.providers.some((p) => p.name === se.name)) return
361
- const cfg = { name: se.name, baseURL: preset.baseURL, model: preset.model }
362
- if (preset.thinking) cfg.thinking = preset.thinking
363
- if (preset.reasoningEffort) cfg.reasoningEffort = preset.reasoningEffort
364
- if (preset.maxTokens) cfg.maxTokens = preset.maxTokens
365
- if (preset.chatPath) cfg.chatPath = preset.chatPath
366
- if (preset.format) cfg.format = preset.format
367
- await persistRaw((raw) => { raw.providers ??= []; raw.providers.push(cfg) }) // D-F5a 先盘后存
368
- agent.providers.push(cfg)
369
- const key = await askQuestion(`Enter API key for ${se.name} (skip if none):`)
370
- if (key) await setProviderKey(se.name, key, { probe: false }) // 探统一在流尾(M9——精确一次)
371
- await probeChannelFlow(cfg, se.name) // M9 准入探(加渠道配置写入面;保存已落——不阻断)
372
- }
373
-
374
- async function removeProviderFlow() {
375
- const candidates = agent.providers.filter((p) => p.name !== agent.activeProvider)
376
- if (!candidates.length) return
377
- const se = await showPicker("Remove Provider", [
378
- { type: "header", text: "Select provider to remove" },
379
- ...candidates.map((p) => ({ type: "item", text: `${p.name} (${defaultModelLabel(p)})`, name: p.name })),
380
- ])
381
- if (!se) return
382
- if (!(await confirmDelete(`Remove provider ${se.name}?`))) return
383
- await persistRaw((raw) => {
384
- raw.providers ??= []
385
- const idx = raw.providers.findIndex((p) => p?.name === se.name)
386
- if (idx !== -1) raw.providers.splice(idx, 1)
387
- // F-4 级联(IKCDMR——AC-4):同盘清 consultModels/subagentModels/advisor.provider 悬挂引用
388
- cascadeRemoveProvider(raw, se.name)
389
- })
390
- agent.providers.splice(agent.providers.findIndex((p) => p.name === se.name), 1)
391
- // 会话内存镜像同清(agent.config = loadConfig 产物——consult/escalate 同会话读
392
- // agent.config.agent.*;merged.advisor 为 agent.advisor 的提升拷贝——双处清理)
393
- if (agent.config) {
394
- cascadeRemoveProvider(agent.config, se.name)
395
- if (agent.config.advisor?.provider === se.name) delete agent.config.advisor.provider
396
- }
397
- }
398
-
399
- async function setKeyFlow() {
400
- const se = await showPicker("Configure API Key", [
401
- { type: "header", text: "Select provider" },
402
- ...agent.providers.map((p) => ({ type: "item", text: `${p.name} ${p.apiKey ? `(has key: ${maskKey(p.apiKey)})` : "(no key)"}`, name: p.name })),
403
- ])
404
- if (!se) return
405
- const key = await askQuestion(`Enter API key for ${se.name}:`)
406
- if (key) await setProviderKey(se.name, key)
407
- }
408
-
409
- /** 设 API key(可以来自渠道管理流 / 加渠道流内的 key 步):写盘 + 内存镜像;
410
- * M9:设 key 属配置写入面——默认探一次 `/models`(不阻断——key 已保存);
411
- * 加渠道流内调用传 `{ probe: false }`(探由该流尾部统一执行——精确一次)。 */
412
- async function setProviderKey(name, key, { probe = true } = {}) {
413
- const target = agent.providers.find((p) => p.name === name)
414
- if (!target) return
415
- await persistRaw((raw) => {
416
- raw.providers ??= []
417
- const t = raw.providers.find((p) => p?.name === name)
418
- if (t) t.apiKey = key
419
- })
420
- target.apiKey = key
421
- if (name === agent.activeProvider) agent.provider.apiKey = key
422
- if (probe) await probeChannelFlow(target, name)
423
- }
424
-
425
- /** /model provider 管理:context 窗口字段(K 单位)——picker 选 provider + 表单输入。 */
426
- async function setContextFlow() {
427
- const se = await showPicker("Set Context Window", [
428
- { type: "header", text: "Select provider" },
429
- ...agent.providers.map((p) => ({
430
- type: "item",
431
- text: `${p.name} (ctx ${fmtContextK(providerSpec(p).context)})`,
432
- name: p.name,
433
- })),
434
- ])
435
- if (!se?.name) return // Esc
436
- const target = agent.providers.find((p) => p.name === se.name)
437
- if (!target) return
438
- const current = Number.isInteger(target.context) && target.context > 0 ? target.context : null
439
- const val = (await askQuestion(
440
- `Context window for ${se.name} in K units (current: ${current ? `${current}K` : "spec default"} — e.g. 128 = 128K; empty to clear):`
441
- ))?.trim() ?? ""
442
- let newCtx // undefined = 清空(回 spec 值)
443
- if (val !== "") {
444
- const n = Number(val)
445
- if (!Number.isInteger(n) || n <= 0) {
446
- pushLine(`Invalid context: "${val}" — must be a positive integer in K units (e.g. 128 = 128K)`, C.error)
447
- return
448
- }
449
- newCtx = n
450
- }
451
- await persistRaw((raw) => {
452
- raw.providers ??= []
453
- const t = raw.providers.find((p) => p?.name === se.name)
454
- if (!t) return
455
- if (newCtx === undefined) delete t.context
456
- else t.context = newCtx
457
- })
458
- if (newCtx === undefined) delete target.context
459
- else target.context = newCtx
460
- if (se.name === agent.activeProvider) {
461
- if (newCtx === undefined) delete agent.provider.context
462
- else agent.provider.context = newCtx
463
- if (agent.config?.agent?.compactThresholdAuto) {
464
- const { resolveCompactThreshold } = await import("@thincoder/core/config.mjs")
465
- agent.config.agent.compactThreshold = resolveCompactThreshold(null, agent.provider).value
466
- }
467
- }
468
- pushLine(target.context !== undefined
469
- ? `ctx = ${target.context}K (${target.context * 1024} tokens)`
470
- : `context cleared — using model spec (${fmtContextK(providerSpec(target).context)})`, C.tool)
471
- }
472
-
473
- return { openModelPicker, selectModel, setProviderKey, setContextFlow, pickModelForSlot }
313
+ return { openModelPicker, selectModel, setProviderKey: admin.setProviderKey, setContextFlow: admin.setContextFlow, pickModelForSlot }
474
314
  }
475
315
 
476
- /** F-4 (IKCDMR) 级联清理(删渠道共享写点):raw/merged config 移除 name 渠道后清悬挂引用——
477
- * agent.consultModels 条目 / agent.subagentModels 角色值(=== name 或 "name:…" 前缀——
478
- * 角色值是 "provider:model" | 裸渠道名 | 裸模型名)/ agent.advisor.provider。
479
- * 纯 mutate(removeProviderFlow persistRaw 的 D-F5 新鲜 raw 上调用;agent.config 内存镜像
480
- * 子树与 raw.agent 同构可复用)。空数组/空对象键删除(规范形态)。 */
481
- export function cascadeRemoveProvider(raw, name) {
482
- const a = raw?.agent
483
- if (!a || typeof a !== "object" || Array.isArray(a)) return
484
- if (Array.isArray(a.consultModels)) {
485
- const keep = a.consultModels.filter((m) => m?.provider !== name)
486
- if (keep.length) a.consultModels = keep
487
- else delete a.consultModels
488
- }
489
- if (a.subagentModels && typeof a.subagentModels === "object" && !Array.isArray(a.subagentModels)) {
490
- for (const role of Object.keys(a.subagentModels)) {
491
- const v = a.subagentModels[role]
492
- if (typeof v === "string" && (v === name || v.startsWith(`${name}:`))) delete a.subagentModels[role]
493
- }
494
- if (Object.keys(a.subagentModels).length === 0) delete a.subagentModels
495
- }
496
- if (a.advisor && typeof a.advisor === "object" && !Array.isArray(a.advisor) && a.advisor.provider === name) {
497
- delete a.advisor.provider
498
- }
499
- }
316
+ export { cascadeRemoveProvider } from "./provider-admin.mjs"
package/src/tui/mouse.mjs CHANGED
@@ -36,7 +36,7 @@ export function handleWheel(ctx, button, col, row) {
36
36
  if (mouseOob(col, row, dims)) return false // F-3 sane-gate ②:越界滚轮丢弃——不落面板(> 非 >=——末行列合法)
37
37
  const layout = computeLayout(state, dims)
38
38
  const P = layout.panels
39
- // §7.2.1 D4: 固定子agent 面板(conversation 与 todo 之间)——面板行默认穿出
39
+ // docs/cli/design/TUI.md §6.8 D4: 固定子agent 面板(conversation 与 todo 之间)——面板行默认穿出
40
40
  // 滚会话(F3,与 todo 面板同型);命中展开区块内容行(_foldBlock 标记)→
41
41
  // 块内滚动(现状能力不丢)。
42
42
  if (P.subagent && r >= P.subagent.y && r < P.subagent.y + P.subagent.h) {
@@ -134,13 +134,13 @@ export function handleMouseClick(ctx, col, row) {
134
134
  return true
135
135
  }
136
136
 
137
- // ── §7.2.1 D4: 固定子agent 面板(conversation 与 todo 之间)——折叠/展开/翻窗
137
+ // ── docs/cli/design/TUI.md §6.8 D4: 固定子agent 面板(conversation 与 todo 之间)——折叠/展开/翻窗
138
138
  // 坐标映射到面板行(与 todo 面板同型;layout.subagentLines = 面板渲染行)。
139
139
  if (P.subagent && r >= P.subagent.y && r < P.subagent.y + P.subagent.h) {
140
140
  // 评审 #4:保底截断后可见行 ≠ 前 h 行——命中映射与 render-frame 同一几何契约
141
141
  // (subagentLineIndex:分隔线 + 末尾区块行优先)。
142
142
  const lineEl = layout.subagentLines[subagentLineIndex(layout.subagentLines, P.subagent.h, r - P.subagent.y)]
143
- // §19.5 D-M7/D-M7b ⏹ 停止标记(round1 #6 + 用户裁定 B 形态)+ SYNC-CANCEL F3
143
+ // AGENT-LOOP-SUBAGENT.md §6.7.2 D-M7/D-M7b ⏹ 停止标记(round1 #6 + 用户裁定 B 形态)+ SYNC-CANCEL F3
144
144
  // (2026-09-09):列级命中——⏹ 列点击 = cancel(定向该子代理——ctx.cancelSubagent
145
145
  // 直连池/registry abort 路径,不经模型回合),不触发折叠翻转;**async 区块与 registry
146
146
  // live 的 sync 区块带 _stopSub 元数据**(headless 无 _agent → sync 不钉——零回归);
@@ -192,7 +192,7 @@ export function handleMouseClick(ctx, col, row) {
192
192
 
193
193
  /** 鼠标装配簇(2026-09-03 D-S1a 自 index.mjs 迁入):index.mjs 只留装配调用。
194
194
  * ctx: { agent, state, pushLine, render, popPicker };返回 { onMouseClick, mouseCtx }。
195
- * cancelSubagent(⏹ 停止标记,§19.5 D-M7):UI 停止不经模型回合,直连池 abort——
195
+ * cancelSubagent(⏹ 停止标记,AGENT-LOOP-SUBAGENT.md §6.7.2 D-M7):UI 停止不经模型回合,直连池 abort——
196
196
  * 与 action:"cancel" 同实现路径。block key "role#id" → 池条目 id。 */
197
197
  export function createMouseDispatch({ agent, state, pushLine, render, popPicker }) {
198
198
  const cancelSubagent = (key) => {
@@ -201,7 +201,7 @@ export function createMouseDispatch({ agent, state, pushLine, render, popPicker
201
201
  const isAdvisorBlock = key.startsWith("advisor#")
202
202
  // af 批 #1(发射单源化):`emit` = 本层通道,传进核调用供其单点发射(本层不另发)。
203
203
  const emit = (t) => { if (!routeSubToken(state, t, render)) pushLine(t, C.dim) }
204
- // §11.2 D-24b (②-6b): ⏹ on an advisor block cancels the background review
204
+ // AGENT-LOOP-ASYNC-POOL.md §6.10 D-24b (②-6b): ⏹ on an advisor block cancels the background review
205
205
  // (directed abort → cancelled settle: no pending entry / no token).
206
206
  // SYNC-CANCEL F3: async 池/advisor miss 后查 sync registry(⏹ 门控已放开 sync——
207
207
  // cancelSyncChild 与 async cancel 同模块同形态——subagent-async.mjs)。
@@ -12,7 +12,7 @@ import { createModelPicker } from "./model-picker.mjs"
12
12
  * 删除类二次确认件:confirmDelete(TUI-COMMANDS.md §5.4 单源——形态同 cmd-clear / cmd-new 先例)。
13
13
  * 选中即关闭(Enter = resolve + pop);Esc = pop 当前层并 resolve(null)。菜单循环由调用方 while 重开。 */
14
14
  export function createPickers(ctx) {
15
- const { agent, state, render, ansi, C, pushLine, persistRaw, askQuestion, maskKey } = ctx
15
+ const { agent, state, render, ansi, C, pushLine, persistRaw, askQuestion, maskKey, onModalClose = null } = ctx
16
16
 
17
17
  state.pickerStack ??= []
18
18
 
@@ -22,7 +22,9 @@ export function createPickers(ctx) {
22
22
  return p.entries.filter((e) => e.type === "item" && (!f || e.text.toLowerCase().includes(f)))
23
23
  }
24
24
 
25
- /** 弹出栈顶 picker 并 resolve 其 Promise。返回是否有 picker 被弹出。 */
25
+ /** 弹出栈顶 picker 并 resolve 其 Promise。返回是否有 picker 被弹出。
26
+ * #448①「关闭后补评估」:本弹出使 picker 面退场(栈空 ∧ 无 wizard)⇒ 通知链尾(闩重武装——模态期
27
+ * 被抑制的到期件即达);其余模态仍在场 ⇒ 零动作(交其关闭点)。 */
26
28
  function popPicker(value) {
27
29
  const p = state.pickerStack.pop()
28
30
  if (!p) return false
@@ -30,6 +32,7 @@ export function createPickers(ctx) {
30
32
  if (state.picker) rebuildLines()
31
33
  else render()
32
34
  p.resolve(value)
35
+ if (state.picker === null && state.wizard == null) onModalClose?.()
33
36
  return true
34
37
  }
35
38
 
@@ -119,6 +122,7 @@ export function createPickers(ctx) {
119
122
  }
120
123
 
121
124
  // /model 两级面 + selectModel + 渠道管理 + pickModelForSlot —— 迁 model-picker.mjs
125
+ // (2026-09-29 结构拆分:渠道管理四流再迁 provider-admin.mjs——本层与会话面转发零改。)
122
126
  // (MODEL-MERGE-SESSION——pick 后本文件 ≤450)。createModelPicker 返回同名单函数,
123
127
  // 经本层原样转发(index.mjs/wizard/slash-commands 消费面零变化)。
124
128
  const modelPicker = createModelPicker({
@@ -0,0 +1,213 @@
1
+ /**
2
+ * provider-admin.mjs — 渠道管理四流 + 级联清理出档(structure-split-2 · 2026-09-29):自
3
+ * `model-picker.mjs` 迁出〔原 `:309-471`(probeChannelFlow ∥ addProviderFlow ∥ removeProviderFlow ∥
4
+ * setKeyFlow ∥ setProviderKey ∥ setContextFlow)+ `:476-499`(cascadeRemoveProvider)〕——
5
+ * 结构拆分零语义(面不变 ∕ 判据不变,只换宿主档);切点 ∕ 缝单源 = 批档
6
+ * `docs/batches/2026-09-29-structure-split-2.md` §2.2-A。
7
+ *
8
+ * 装配 = `createProviderAdmin(ctx)`(ctx = `{ agent, showPicker, askQuestion, pushLine, persistRaw,
9
+ * maskKey, confirmDelete, C, fmtContextK, defaultModelLabel }`——后两名 = 主档纯 helper,ctx 注入
10
+ * 同 `maskKey` 先例)→ `{ addProviderFlow, removeProviderFlow, setKeyFlow, setProviderKey,
11
+ * setContextFlow }`;`cascadeRemoveProvider` 模块级导出(宿主 `model-picker.mjs` 尾同名再出口——
12
+ * 历史导出面零改)。零环:本档不 import 主档(宿主 → 本档单向)。
13
+ * 语义(迁出原文照录):四流 = provider 级 config 写——M9 配置阶段准入探一次 `/models`(不阻断保存)
14
+ * ∥ F-4 (IKCDMR):removeProviderFlow 级联清理悬挂引用(consultModels ∕ subagentModels ∕ advisor.provider)。
15
+ */
16
+ import { PROVIDER_PRESETS as PRESETS, providerSpec } from "@thincoder/core/config.mjs"
17
+ import { probeChannelModels } from "./model-catalog.mjs"
18
+
19
+ /** createProviderAdmin(ctx) → { addProviderFlow, removeProviderFlow, setKeyFlow, setProviderKey, setContextFlow } */
20
+ export function createProviderAdmin(ctx) {
21
+ const { agent, showPicker, askQuestion, pushLine, persistRaw, maskKey, confirmDelete, C, fmtContextK, defaultModelLabel } = ctx
22
+
23
+ // ═══ 渠道管理流(provider 级 config 写——语义不变——与 /model 会话选择分离)═══
24
+
25
+ /** M9 配置阶段准入探:探通 → 清标记;探不通 → 会话内存标「不可用」+ 明示原因(绝不落 config;
26
+ * 不阻断保存——条目已保存,仅标注)。 */
27
+ async function probeChannelFlow(cfg, label) {
28
+ const r = await probeChannelModels(cfg)
29
+ if (r.ok) {
30
+ delete cfg._unavailable
31
+ pushLine(`${label}: /models 可用(${r.list.length} 个模型可候选)`, C.tool)
32
+ } else {
33
+ cfg._unavailable = true
34
+ pushLine(`${label} 不可用 — ${r.message}`, C.error)
35
+ }
36
+ return r
37
+ }
38
+
39
+ async function addProviderFlow() {
40
+ const entries = [
41
+ { type: "header", text: "Select a preset provider" },
42
+ ...Object.entries(PRESETS).filter(([name]) => !agent.providers.some((p) => p.name === name))
43
+ .map(([name, p]) => ({ type: "item", text: `${name.padEnd(10)} ${p.desc ?? ""} (${p.model ?? ""})`, name, kind: "preset" })),
44
+ { type: "header", text: "Other" },
45
+ { type: "item", text: "Custom (manual config)", name: "__custom__", kind: "custom" },
46
+ ]
47
+ const se = await showPicker("Add Provider", entries)
48
+ if (!se) return // Esc → 返回上级(openModelPicker 循环会重开主菜单)
49
+ if (se.kind === "custom") {
50
+ const name = await askQuestion("Enter provider name:")
51
+ if (!name) return
52
+ if (agent.providers.some((p) => p.name === name)) return
53
+ const baseURL = (await askQuestion("Enter baseURL:")).replace(/\/+$/, "")
54
+ if (!baseURL) return
55
+ const model = await askQuestion("Enter model name:")
56
+ if (!model) return
57
+ const format = await showPicker("API format", [
58
+ { type: "item", text: "openai", name: "openai" },
59
+ { type: "item", text: "anthropic", name: "anthropic" },
60
+ { type: "item", text: "google", name: "google" },
61
+ ])
62
+ if (!format) return // Esc/取消 → 中止流程
63
+ const cfg = { name, baseURL, model }
64
+ if (format.name === "anthropic" || format.name === "google") cfg.format = format.name
65
+ else if (format.name !== "openai") return // 防御:未知格式(理论不可达——picker 枚举)
66
+ await persistRaw((raw) => { raw.providers ??= []; raw.providers.push(cfg) }) // D-F5a 先盘后存
67
+ agent.providers.push(cfg)
68
+ const key = await askQuestion(`Enter API key for ${name} (skip if none):`)
69
+ if (key) await setProviderKey(name, key, { probe: false }) // 探统一在流尾(M9——精确一次)
70
+ await probeChannelFlow(cfg, name) // M9 准入探(加渠道配置写入面;保存已落——不阻断)
71
+ return
72
+ }
73
+ const preset = PRESETS[se.name]
74
+ if (!preset || agent.providers.some((p) => p.name === se.name)) return
75
+ const cfg = { name: se.name, baseURL: preset.baseURL, model: preset.model }
76
+ if (preset.thinking) cfg.thinking = preset.thinking
77
+ if (preset.reasoningEffort) cfg.reasoningEffort = preset.reasoningEffort
78
+ if (preset.maxTokens) cfg.maxTokens = preset.maxTokens
79
+ if (preset.chatPath) cfg.chatPath = preset.chatPath
80
+ if (preset.format) cfg.format = preset.format
81
+ await persistRaw((raw) => { raw.providers ??= []; raw.providers.push(cfg) }) // D-F5a 先盘后存
82
+ agent.providers.push(cfg)
83
+ const key = await askQuestion(`Enter API key for ${se.name} (skip if none):`)
84
+ if (key) await setProviderKey(se.name, key, { probe: false }) // 探统一在流尾(M9——精确一次)
85
+ await probeChannelFlow(cfg, se.name) // M9 准入探(加渠道配置写入面;保存已落——不阻断)
86
+ }
87
+
88
+ async function removeProviderFlow() {
89
+ const candidates = agent.providers.filter((p) => p.name !== agent.activeProvider)
90
+ if (!candidates.length) return
91
+ const se = await showPicker("Remove Provider", [
92
+ { type: "header", text: "Select provider to remove" },
93
+ ...candidates.map((p) => ({ type: "item", text: `${p.name} (${defaultModelLabel(p)})`, name: p.name })),
94
+ ])
95
+ if (!se) return
96
+ if (!(await confirmDelete(`Remove provider ${se.name}?`))) return
97
+ await persistRaw((raw) => {
98
+ raw.providers ??= []
99
+ const idx = raw.providers.findIndex((p) => p?.name === se.name)
100
+ if (idx !== -1) raw.providers.splice(idx, 1)
101
+ // F-4 级联(IKCDMR——AC-4):同盘清 consultModels/subagentModels/advisor.provider 悬挂引用
102
+ cascadeRemoveProvider(raw, se.name)
103
+ })
104
+ agent.providers.splice(agent.providers.findIndex((p) => p.name === se.name), 1)
105
+ // 会话内存镜像同清(agent.config = loadConfig 产物——consult/escalate 同会话读
106
+ // agent.config.agent.*;merged.advisor 为 agent.advisor 的提升拷贝——双处清理)
107
+ if (agent.config) {
108
+ cascadeRemoveProvider(agent.config, se.name)
109
+ if (agent.config.advisor?.provider === se.name) delete agent.config.advisor.provider
110
+ }
111
+ }
112
+
113
+ async function setKeyFlow() {
114
+ const se = await showPicker("Configure API Key", [
115
+ { type: "header", text: "Select provider" },
116
+ ...agent.providers.map((p) => ({ type: "item", text: `${p.name} ${p.apiKey ? `(has key: ${maskKey(p.apiKey)})` : "(no key)"}`, name: p.name })),
117
+ ])
118
+ if (!se) return
119
+ const key = await askQuestion(`Enter API key for ${se.name}:`)
120
+ if (key) await setProviderKey(se.name, key)
121
+ }
122
+
123
+ /** 设 API key(可以来自渠道管理流 / 加渠道流内的 key 步):写盘 + 内存镜像;
124
+ * M9:设 key 属配置写入面——默认探一次 `/models`(不阻断——key 已保存);
125
+ * 加渠道流内调用传 `{ probe: false }`(探由该流尾部统一执行——精确一次)。 */
126
+ async function setProviderKey(name, key, { probe = true } = {}) {
127
+ const target = agent.providers.find((p) => p.name === name)
128
+ if (!target) return
129
+ await persistRaw((raw) => {
130
+ raw.providers ??= []
131
+ const t = raw.providers.find((p) => p?.name === name)
132
+ if (t) t.apiKey = key
133
+ })
134
+ target.apiKey = key
135
+ if (name === agent.activeProvider) agent.provider.apiKey = key
136
+ if (probe) await probeChannelFlow(target, name)
137
+ }
138
+
139
+ /** /model provider 管理:context 窗口字段(K 单位)——picker 选 provider + 表单输入。 */
140
+ async function setContextFlow() {
141
+ const se = await showPicker("Set Context Window", [
142
+ { type: "header", text: "Select provider" },
143
+ ...agent.providers.map((p) => ({
144
+ type: "item",
145
+ text: `${p.name} (ctx ${fmtContextK(providerSpec(p).context)})`,
146
+ name: p.name,
147
+ })),
148
+ ])
149
+ if (!se?.name) return // Esc
150
+ const target = agent.providers.find((p) => p.name === se.name)
151
+ if (!target) return
152
+ const current = Number.isInteger(target.context) && target.context > 0 ? target.context : null
153
+ const val = (await askQuestion(
154
+ `Context window for ${se.name} in K units (current: ${current ? `${current}K` : "spec default"} — e.g. 128 = 128K; empty to clear):`
155
+ ))?.trim() ?? ""
156
+ let newCtx // undefined = 清空(回 spec 值)
157
+ if (val !== "") {
158
+ const n = Number(val)
159
+ if (!Number.isInteger(n) || n <= 0) {
160
+ pushLine(`Invalid context: "${val}" — must be a positive integer in K units (e.g. 128 = 128K)`, C.error)
161
+ return
162
+ }
163
+ newCtx = n
164
+ }
165
+ await persistRaw((raw) => {
166
+ raw.providers ??= []
167
+ const t = raw.providers.find((p) => p?.name === se.name)
168
+ if (!t) return
169
+ if (newCtx === undefined) delete t.context
170
+ else t.context = newCtx
171
+ })
172
+ if (newCtx === undefined) delete target.context
173
+ else target.context = newCtx
174
+ if (se.name === agent.activeProvider) {
175
+ if (newCtx === undefined) delete agent.provider.context
176
+ else agent.provider.context = newCtx
177
+ if (agent.config?.agent?.compactThresholdAuto) {
178
+ const { resolveCompactThreshold } = await import("@thincoder/core/config.mjs")
179
+ agent.config.agent.compactThreshold = resolveCompactThreshold(null, agent.provider).value
180
+ }
181
+ }
182
+ pushLine(target.context !== undefined
183
+ ? `ctx = ${target.context}K (${target.context * 1024} tokens)`
184
+ : `context cleared — using model spec (${fmtContextK(providerSpec(target).context)})`, C.tool)
185
+ }
186
+
187
+ return { addProviderFlow, removeProviderFlow, setKeyFlow, setProviderKey, setContextFlow }
188
+ }
189
+
190
+ /** F-4 (IKCDMR) 级联清理(删渠道共享写点):raw/merged config 移除 name 渠道后清悬挂引用——
191
+ * agent.consultModels 条目 / agent.subagentModels 角色值(=== name 或 "name:…" 前缀——
192
+ * 角色值是 "provider:model" | 裸渠道名 | 裸模型名)/ agent.advisor.provider。
193
+ * 纯 mutate(removeProviderFlow persistRaw 的 D-F5 新鲜 raw 上调用;agent.config 内存镜像
194
+ * 子树与 raw.agent 同构可复用)。空数组/空对象键删除(规范形态)。 */
195
+ export function cascadeRemoveProvider(raw, name) {
196
+ const a = raw?.agent
197
+ if (!a || typeof a !== "object" || Array.isArray(a)) return
198
+ if (Array.isArray(a.consultModels)) {
199
+ const keep = a.consultModels.filter((m) => m?.provider !== name)
200
+ if (keep.length) a.consultModels = keep
201
+ else delete a.consultModels
202
+ }
203
+ if (a.subagentModels && typeof a.subagentModels === "object" && !Array.isArray(a.subagentModels)) {
204
+ for (const role of Object.keys(a.subagentModels)) {
205
+ const v = a.subagentModels[role]
206
+ if (typeof v === "string" && (v === name || v.startsWith(`${name}:`))) delete a.subagentModels[role]
207
+ }
208
+ if (Object.keys(a.subagentModels).length === 0) delete a.subagentModels
209
+ }
210
+ if (a.advisor && typeof a.advisor === "object" && !Array.isArray(a.advisor) && a.advisor.provider === name) {
211
+ delete a.advisor.provider
212
+ }
213
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * queued-merge.mjs — busy 排队消息的合并消费纯函数族(R15 攒批恢复——queue-visible 批 2026-09-24)。
3
+ *
4
+ * 机制单源 = `docs/cli/design/TUI.md` §7.5「合并消费」+ `docs/cli/design/TUI-INPUT-BOX.md` §4.1「送达链路」。
5
+ * **核单源(转口)**:五名(常量 ∕ 形态 ∕ 计划)自 `@thincoder/core/queued.mjs` 转口(同一绑定——
6
+ * 对拍锁 = `docs/batches/2026-09-29-parity-b2-queued.test.mjs`(批次本地件))。
7
+ * 取批四支共用同一事实源(`planQueuedInput`;队列容器仍归会话层 `state.pendingInput`——核只给缝):
8
+ * ① 步边界 pickup(`queued-pickup.mjs`)· ② 回合尾兜底(`agent-turn.mjs`)
9
+ * · ③ driver 输入优先(`suspension-drive.mjs`)· ④ 中止残余转 queue(`suspension-drive.mjs`)。
10
+ */
11
+
12
+ export { MAX_MERGE_ITEMS, MAX_MERGE_CHARS, QUEUED_MAX_ITEMS, formatMergedMessages, planQueuedInput } from "@thincoder/core/queued.mjs"
@@ -0,0 +1,34 @@
1
+ /**
2
+ * queued-pickup.mjs — 步边界 pickup(用户 → 主会话投送通道的主时机)。
3
+ *
4
+ * 机制单源 = `docs/core/design/AGENT-LOOP-ASYNC-POOL.md` §6.8「步边界 pickup」(核循环头投递回调
5
+ * `consumeQueuedInput`——一步 = 一次 LLM 调用 + 其工具执行段完结)+ `docs/cli/design/TUI.md` §7.5
6
+ * 「消费时机①」(queue-visible 批 fix 轮 2026-09-24 · 用户 03:06 收正)。
7
+ * 语义 = 不中断(在飞工具 / signal 零触碰)· 下一步生效 · 普通 user 消息落历史(参照系 = 子代理
8
+ * `send`——参照非改造);与 Ctrl+I 严格区分(零 abort / 零 `[User interrupt:]`)。
9
+ */
10
+ import { pushReal } from "@thincoder/core/context.mjs"
11
+ import { ansi, C } from "./ansi.mjs"
12
+ import { planQueuedInput } from "./queued-merge.mjs"
13
+
14
+ /**
15
+ * 步边界取批(核循环头回调——空队列 no-op,headless / 直连面零开销)。
16
+ * 取数 = 计划首动作(`planQueuedInput`——2 条短消息 ⇒ 合并批一次消费);`/cmd` 首动作 =
17
+ * 不可达支(斜杠 busy 禁发——`key-handler-busy.mjs:24` ∕ `key-handler-edit.mjs:59` 双门禁;
18
+ * `TUI-INPUT-BOX.md` §4.1 条件 3)⇒ 不消费,留给既有消费点(回合尾 queue / driver)。呈现 =
19
+ * 回执行 + `❯ You:` + 本批文本(与 `agent-turn.mjs` 回合起点同形——下一步 LLM 请求即含该消息)。
20
+ * @param {object} ctx TUI 上下文(agent / state / pushLine / pushLabel / render)
21
+ */
22
+ export function pickupQueuedAtStepBoundary(ctx) {
23
+ const { agent, state, pushLine, pushLabel, render } = ctx
24
+ const items = state.pendingInput
25
+ if (!items || items.length === 0) return
26
+ const action = planQueuedInput(items)[0]
27
+ if (action.kind !== "turn") return
28
+ items.splice(0, action.count)
29
+ pushReal(agent, { role: "user", content: action.text }) // 下一步生效(非中断通道)
30
+ pushLine("[sending queued message]", C.tool) // 消费回执(按批——TUI.md §7.5 消费时行)
31
+ pushLabel(`❯ You:`, ansi.bold + C.user)
32
+ pushLine(action.text, C.text)
33
+ render()
34
+ }