thincoder 0.7.0 → 0.7.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.
package/src/tui.mjs CHANGED
@@ -206,7 +206,6 @@ export function layoutInput(chars, cursor, width) {
206
206
  return { lines, cursorLine, cursorCol }
207
207
  }
208
208
 
209
- /** 文本按宽度折行(保留 \n),返回行数组 */
210
209
  /**
211
210
  * 显示净化:控制字符会破坏终端网格数学(\r 回车覆盖、\t 宽度误判致整帧错位、ANSI/响铃冲屏)。
212
211
  * 只动显示层——模型看到的工具结果原文不变;session 里已存的脏 display 回放时也经此净化。
@@ -222,6 +221,7 @@ export function sanitizeDisplay(s) {
222
221
  .replace(/\n+$/, "")
223
222
  }
224
223
 
224
+ /** 文本按宽度折行(保留 \n),返回行数组 */
225
225
  export function wrapText(text, width) {
226
226
  const lines = []
227
227
  for (const rawLine of text.split("\n")) {
@@ -406,9 +406,17 @@ export async function startTUI(agent, opts = {}) {
406
406
  let boxLines = inputLines
407
407
  if (state.question) {
408
408
  const q = state.question
409
- boxLines = q.options.length > 0
410
- ? q.options.map((opt, i) => (i === (q.selected ?? 0) ? "▸ " : " ") + opt)
411
- : ["▸ " + (q.answer ?? "")]
409
+ if (q.options.length > 0) {
410
+ // 选项窗口:只显示选中项 ±2,选项过多时防输入框无限增高撑破锚定布局
411
+ const sel = q.selected ?? 0
412
+ const QWIN = 5
413
+ const start = Math.max(0, Math.min(sel - 2, q.options.length - QWIN))
414
+ boxLines = q.options
415
+ .slice(start, start + QWIN)
416
+ .map((opt, i) => (start + i === sel ? "▸ " : " ") + opt)
417
+ } else {
418
+ boxLines = ["▸ " + (q.answer ?? "")]
419
+ }
412
420
  }
413
421
  const inputBoxH = boxLines.length + 2
414
422
 
@@ -434,8 +442,18 @@ export async function startTUI(agent, opts = {}) {
434
442
  const taskPanelH = visibleTasks.length
435
443
  // 子 agent 流式输出占位(显示时占最多 2 行)
436
444
  const subOutLen = (state.subOutput && state.processing) ? wrapText(state.subOutput, W - 8).slice(-2).length : 0
437
- // 权限预览占位
438
- const permPreviewLen = state.permission ? 1 + state.permissionPreview.reduce((s, l) => s + wrapText(` ${l}`, W - 1).length, 0) : 0
445
+ // 权限预览占位:字符数之外再封顶显示行数(rows-8),多行短行也能把帧撑过终端高度,破坏锚定布局
446
+ let permPreviewLines = []
447
+ if (state.permission) {
448
+ const maxLines = Math.max(1, rows - 8)
449
+ outer: for (const l of state.permissionPreview) {
450
+ for (const wrapped of wrapText(` ${l}`, W - 1)) {
451
+ if (permPreviewLines.length >= maxLines) break outer
452
+ permPreviewLines.push(wrapped)
453
+ }
454
+ }
455
+ }
456
+ const permPreviewLen = state.permission ? 1 + permPreviewLines.length : 0
439
457
  const convH = Math.max(1, rows - headerH - inputBoxH - statusH - pickerH - taskPanelH - subOutLen - permPreviewLen)
440
458
 
441
459
  // 对话区内容行(含流式缓冲);markdown 表格先按显示宽度重排
@@ -495,7 +513,7 @@ export async function startTUI(agent, opts = {}) {
495
513
  if (overlay.selectedLine >= overlay.scroll + winH) overlay.scroll = overlay.selectedLine - winH + 1
496
514
  const start = Math.max(0, Math.min(overlay.scroll, Math.max(0, overlay.lines.length - winH)))
497
515
  const shown = overlay.lines.slice(start, start + winH)
498
- const overlayTitle = state.picker ? "选择模型 " : " ❯ 初始配置 "
516
+ const overlayTitle = state.picker ? `${state.picker.title} ` : " ❯ 初始配置 "
499
517
  out.push(`${ansi.bold}${C.tool}${overlayTitle}${ansi.reset}${ansi.dim}${state.picker ? "(↑↓ 移动, Enter 确认, Esc 取消)" : ""}${ansi.reset}${ansi.clearLine}`)
500
518
  for (const l of shown) {
501
519
  out.push(`${l.color}${sliceByWidth(l.text, cols - 1)}${ansi.reset}${ansi.clearLine}`)
@@ -519,13 +537,11 @@ export async function startTUI(agent, opts = {}) {
519
537
  }
520
538
  }
521
539
 
522
- // 权限审批内容预览(黄色,紧挨输入框上方)
540
+ // 权限审批内容预览(黄色,紧挨输入框上方);用上方已封顶的 permPreviewLines,渲染行数与占位一致
523
541
  if (state.permission) {
524
542
  out.push(`${ansi.bold}${C.warn}❯ 权限请求${ansi.reset}${ansi.clearLine}`)
525
- for (const line of state.permissionPreview) {
526
- for (const wrapped of wrapText(` ${line}`, W - 1)) {
527
- out.push(`${C.warn}${wrapped}${ansi.reset}${ansi.clearLine}`)
528
- }
543
+ for (const wrapped of permPreviewLines) {
544
+ out.push(`${C.warn}${wrapped}${ansi.reset}${ansi.clearLine}`)
529
545
  }
530
546
  }
531
547
 
@@ -543,7 +559,7 @@ export async function startTUI(agent, opts = {}) {
543
559
  title = ` Allow ${state.permission.name}? (y/n/a) `
544
560
  }
545
561
  } else if (state.picker) {
546
- title = " Model "
562
+ title = " Select "
547
563
  } else if (state.wizard) {
548
564
  title = " Setup "
549
565
  } else if (state.processing) {
@@ -584,13 +600,21 @@ export async function startTUI(agent, opts = {}) {
584
600
  const cmds = SLASH_COMMANDS.filter((c) => c.name.startsWith(cmd))
585
601
  const match = cmds.length === 1 ? cmds[0] : null
586
602
  if (match?.name === "/config" && cmd === "/config") {
587
- statusLine = " /config 查看 │ embedkey 配 embedding │ set 改参数"
603
+ statusLine = " /config 打开配置菜单"
588
604
  } else if (match?.name === "/provider" && cmd === "/provider") {
589
- statusLine = " /provider 列表 │ add / remove / key"
605
+ statusLine = " /provider 打开 Provider 管理菜单"
590
606
  } else if (match?.name === "/model" && cmd === "/model" && !sub) {
591
- statusLine = " /model 打开选择器 │ /model <名称> 直接切换"
607
+ statusLine = " /model 打开模型选择器"
592
608
  } else if (match?.name === "/think" && cmd === "/think") {
593
- statusLine = " /think 查看 │ on / off 开关 │ effort high / max 强度"
609
+ statusLine = " /think 打开思维模式菜单"
610
+ } else if (match?.name === "/mcp" && cmd === "/mcp") {
611
+ statusLine = " /mcp 打开 MCP 管理菜单"
612
+ } else if (match?.name === "/goal" && cmd === "/goal") {
613
+ statusLine = " /goal 打开目标管理菜单"
614
+ } else if (match?.name === "/session" && cmd === "/session") {
615
+ statusLine = " /session 选择归档会话"
616
+ } else if (match?.name === "/rewind" && cmd === "/rewind") {
617
+ statusLine = " /rewind 选择存档点回滚"
594
618
  } else if (cmds.length > 0) {
595
619
  if (cmds.length <= 4) {
596
620
  statusLine = ` ${cmds.map((c) => `${c.name} ${c.desc}`).join(" │ ")}`
@@ -697,8 +721,8 @@ export async function startTUI(agent, opts = {}) {
697
721
 
698
722
  const callbacks = {
699
723
  onToken: (t) => {
700
- // 子 agent 流式输出:前缀匹配 explore/coder/plan token 进 subOutput
701
- const subMatch = t.match(/^(explore|coder|plan)\//)
724
+ // 子 agent 流式输出:前缀匹配 explore/coder/plan/sub(无角色子 agent 用 sub/)的 token 进 subOutput
725
+ const subMatch = t.match(/^(explore|coder|plan|sub)\//)
702
726
  if (subMatch) {
703
727
  state.currentSub = subMatch[1]
704
728
  state.subOutput = (state.subOutput + t.slice(subMatch[0].length)).slice(-300)
@@ -710,6 +734,14 @@ export async function startTUI(agent, opts = {}) {
710
734
  scheduleRender()
711
735
  },
712
736
  onReasoning: (t) => {
737
+ // 子 agent 的思考 token 同样带 role/ 前缀,进 subOutput 滚动区,不污染主思考流
738
+ const subMatch = t.match(/^(explore|coder|plan|sub)\//)
739
+ if (subMatch) {
740
+ state.currentSub = subMatch[1]
741
+ state.subOutput = (state.subOutput + t.slice(subMatch[0].length)).slice(-300)
742
+ scheduleRender()
743
+ return
744
+ }
713
745
  ensureAssistantLabel()
714
746
  state.reasoning += t
715
747
  scheduleRender()
@@ -722,8 +754,9 @@ export async function startTUI(agent, opts = {}) {
722
754
  },
723
755
  onToolResult: (name, result) => {
724
756
  state.currentTool = null
725
- // 子 agent 结束:清空流式缓冲,报告进对话区
726
- const isSubagent = name.startsWith("explore/") || name.startsWith("coder/") || name.startsWith("plan/")
757
+ // 子 agent 结束(父 agent 侧的 subagent 工具结果带着最终报告):清空流式缓冲,报告进对话区。
758
+ // 注意只能用精确匹配——子 agent 内部工具调用不 relay TUI(刷了满屏的教训)
759
+ const isSubagent = name === "subagent"
727
760
  if (isSubagent) {
728
761
  state.subOutput = ""
729
762
  state.currentSub = null
@@ -759,6 +792,11 @@ export async function startTUI(agent, opts = {}) {
759
792
  state.tokens.cacheHit += usage.prompt_cache_hit_tokens ?? 0
760
793
  state.tokens.cacheMiss += usage.prompt_cache_miss_tokens ?? 0
761
794
  },
795
+ // 节流等待(主动闸门 / 429 退避):状态栏明示,防用户以为卡死
796
+ onWait: ({ phase, seconds }) => {
797
+ state.status = phase === "gate" ? `TPM 节流等待 ~${seconds}s` : `限流 429,${seconds}s 后重试`
798
+ render()
799
+ },
762
800
  onTaskUpdate: (items) => {
763
801
  state.tasks = items
764
802
  const done = items.filter((i) => i.status === "done").length
@@ -877,6 +915,10 @@ export async function startTUI(agent, opts = {}) {
877
915
  ...cap(args.new_string ?? "", 500).split("\n").map((l) => `+ ${l}`),
878
916
  ]
879
917
  }
918
+ if (base === "apply_patch") {
919
+ // 补丁本身就是可读的 diff,直接预览
920
+ return cap(args.patch ?? "", 1500).split("\n")
921
+ }
880
922
  if (base === "delete") return [`${args.path}${args.force ? "(force:跟踪文件也删)" : ""}`]
881
923
  if (base === "subagent") return cap(args.task ?? "", 500).split("\n")
882
924
  if (base === "memory_put") return [`[${args.type ?? ""}] ${args.title ?? ""}`, ...cap(args.content ?? "", 500).split("\n")]
@@ -957,36 +999,35 @@ export async function startTUI(agent, opts = {}) {
957
999
  setTimeout(() => process.exit(0), 100) // 延迟一拍:fetch 后立刻 exit 在 Windows/Node 24 会触发 libuv 断言
958
1000
  return
959
1001
  case "/session": {
960
- const slotNum = Number(rest[0])
961
- if (rest.length > 0 && !isNaN(slotNum)) {
962
- // 切换到指定槽位
963
- const data = switchToSlot(agent.cwd, slotNum)
964
- if (!data) {
965
- pushLine(`槽位 ${slotNum} 不存在`, C.dim)
966
- } else {
967
- applySession(agent, data)
968
- state.lines = data.display.length
969
- ? data.display.map((l) => ({ text: l.text, color: l.color }))
970
- : []
971
- state.tasks = agent.tasks ?? []
972
- // 切换过来的会话如果任务全完成,自动收起面板
973
- if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
974
- state.tasks = []
975
- }
976
- pushLabel(`── 已切换到槽位 ${slotNum}(${data.history.length} 条消息)──`, C.warn)
977
- render()
978
- }
1002
+ const slots = listSlots(agent.cwd)
1003
+ if (slots.length === 0) {
1004
+ pushLine("没有归档会话(用 /new 后旧会话会自动归档)", C.dim)
979
1005
  } else {
980
- // 列出所有槽位
981
- const slots = listSlots(agent.cwd)
982
- if (slots.length === 0) {
983
- pushLine("没有归档会话(用 /new 后旧会话会自动归档)", C.dim)
984
- } else {
985
- pushLabel(`归档会话(/session <n> 切换):`, ansi.bold + C.tool)
986
- for (const s of slots) {
987
- pushLine(` 槽位 ${s.slot} — ${s.date}`, C.text)
988
- }
989
- }
1006
+ const entries = [
1007
+ { type: "header", text: "归档会话(↑↓ 选择, Enter 切换, Esc 取消)" },
1008
+ ...slots.map((s) => ({ type: "item", text: `槽位 ${s.slot} ${s.date}`, slot: s.slot })),
1009
+ ]
1010
+ openPicker({
1011
+ title: "切换会话",
1012
+ entries,
1013
+ onSelect: (e) => {
1014
+ const data = switchToSlot(agent.cwd, e.slot)
1015
+ if (!data) {
1016
+ pushLine(`槽位 ${e.slot} 不存在`, C.dim)
1017
+ return
1018
+ }
1019
+ applySession(agent, data)
1020
+ state.lines = data.display.length
1021
+ ? data.display.map((l) => ({ text: l.text, color: l.color }))
1022
+ : []
1023
+ state.tasks = agent.tasks ?? []
1024
+ if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
1025
+ state.tasks = []
1026
+ }
1027
+ pushLabel(`── 已切换到槽位 ${e.slot}(${data.history.length} 条消息)──`, C.warn)
1028
+ render()
1029
+ },
1030
+ })
990
1031
  }
991
1032
  return
992
1033
  }
@@ -1107,27 +1148,33 @@ export async function startTUI(agent, opts = {}) {
1107
1148
  pushLine("[rewind] 当前目录不是 git 仓库,无法使用存档点", C.error)
1108
1149
  return
1109
1150
  }
1110
- const id = rest[0]
1111
- if (!id) {
1112
- const cps = await listCheckpoints(agent.cwd)
1113
- pushLabel(`❯ Checkpoints`, ansi.bold + C.tool)
1114
- if (cps.length === 0) {
1115
- pushLine("(暂无存档点——每次提交任务前自动创建)", C.dim)
1116
- }
1117
- for (const cp of cps.slice(0, 10)) {
1118
- pushLine(` ${cp.id} ${new Date(cp.time).toLocaleString()} (+${cp.untracked} 个未跟踪文件)`, C.dim)
1119
- }
1120
- pushLine("回滚: /rewind <id>(恢复前会先存当前状态,回滚可逆)", C.dim)
1151
+ const cps = await listCheckpoints(agent.cwd)
1152
+ if (cps.length === 0) {
1153
+ pushLine("(暂无存档点——每次提交任务前自动创建)", C.dim)
1121
1154
  return
1122
1155
  }
1123
- try {
1124
- const summary = await rewind(agent.cwd, id)
1125
- pushLabel(`❯ Rewind`, ansi.bold + C.warn)
1126
- pushLine(`已回滚到 ${id}:补丁${summary.patchApplied ? "已应用" : ""},删除新建文件 ${summary.deleted} 个,还原文件 ${summary.restored} 个`, C.tool)
1127
- pushLine("(当前状态已先存为新存档点,可再次 /rewind 回到刚才)", C.dim)
1128
- } catch (error) {
1129
- pushLine(`[rewind] ${error.message}`, C.error)
1130
- }
1156
+ const entries = [
1157
+ { type: "header", text: "存档点(↑↓ 选择, Enter 回滚, Esc 取消)" },
1158
+ ...cps.slice(0, 12).map((cp) => ({
1159
+ type: "item",
1160
+ text: `${cp.id} ${new Date(cp.time).toLocaleString()} (+${cp.untracked} 个未跟踪文件)`,
1161
+ id: cp.id,
1162
+ })),
1163
+ ]
1164
+ openPicker({
1165
+ title: "回滚存档点",
1166
+ entries,
1167
+ onSelect: async (e) => {
1168
+ try {
1169
+ const summary = await rewind(agent.cwd, e.id)
1170
+ pushLabel(`❯ Rewind`, ansi.bold + C.warn)
1171
+ pushLine(`已回滚到 ${e.id}:补丁${summary.patchApplied ? "已应用" : "无"},删除新建文件 ${summary.deleted} 个,还原文件 ${summary.restored} 个`, C.tool)
1172
+ pushLine("(当前状态已先存为新存档点,可再次 /rewind 回到刚才)", C.dim)
1173
+ } catch (error) {
1174
+ pushLine(`[rewind] ${error.message}`, C.error)
1175
+ }
1176
+ },
1177
+ })
1131
1178
  return
1132
1179
  }
1133
1180
  case "/plan": {
@@ -1148,37 +1195,46 @@ export async function startTUI(agent, opts = {}) {
1148
1195
  return
1149
1196
  }
1150
1197
  case "/goal": {
1151
- const sub = rest[0]
1152
- if (sub === "set") {
1153
- const text = rest.slice(1).join(" ")
1154
- if (!text) { pushLine("用法: /goal set <目标描述>(; 分隔完成条件,必须是可机器检查的验证手段)", C.error); return }
1155
- const semi = text.indexOf(";") >= 0 ? ";" : text.indexOf(";") >= 0 ? ";" : null
1156
- const objective = semi ? text.slice(0, semi).trim() : text.trim()
1157
- const criteria = semi ? text.slice(semi + 1).trim() : ""
1158
- agent.goal = { objective, criteria, setAt: Date.now(), status: "active", turnsUsed: 0, _blockTally: null }
1159
- pushLabel(`❯ Goal`, ansi.bold + C.warn)
1160
- pushLine(`目标已设置: ${objective}`, C.tool)
1161
- if (criteria) pushLine(` 完成条件: ${criteria}`, C.dim)
1162
- else pushLine(` ⚠ 未完成条件——agent 用 goal set 设立时会被要求补上可验证的完成条件`, C.warn)
1163
- return
1164
- }
1165
- if (sub === "cancel") {
1166
- agent.goal = null
1167
- pushLabel(`❯ Goal`, ansi.bold + C.dim)
1168
- pushLine(`目标已取消。`, C.dim)
1169
- return
1170
- }
1198
+ const entries = [
1199
+ { type: "header", text: agent.goal ? `当前目标: ${agent.goal.objective.slice(0, 60)}` : "操作" },
1200
+ { type: "item", text: "设置新目标", action: "set" },
1201
+ ]
1171
1202
  if (agent.goal) {
1172
- const statusText = { active: "进行中", complete: "已完成", blocked: "已阻塞" }[agent.goal.status] ?? agent.goal.status
1173
- pushLabel(`❯ Goal`, ansi.bold + C.warn)
1174
- pushLine(`目标: ${agent.goal.objective}`, C.tool)
1175
- if (agent.goal.criteria) pushLine(` 完成条件: ${agent.goal.criteria}`, C.dim)
1176
- pushLine(` 状态: ${statusText ?? "进行中"} │ 已用轮数: ${agent.goal.turnsUsed ?? 0} │ 设置于: ${new Date(agent.goal.setAt).toLocaleString()}`, C.dim)
1177
- pushLine("操作: /goal set <描述> 覆盖 | /goal cancel 取消", C.dim)
1178
- } else {
1179
- pushLabel(`❯ Goal`, ansi.bold + C.dim)
1180
- pushLine("(无活跃目标——/goal set <描述> 设置)", C.dim)
1203
+ entries.push({ type: "item", text: "取消目标", action: "cancel" })
1204
+ entries.push({ type: "item", text: "查看详情", action: "view" })
1181
1205
  }
1206
+ openPicker({
1207
+ title: "目标管理",
1208
+ entries,
1209
+ onSelect: (e) => {
1210
+ if (e.action === "view") {
1211
+ const statusText = { active: "进行中", complete: "已完成", blocked: "已阻塞" }[agent.goal.status] ?? agent.goal.status
1212
+ pushLabel(`❯ Goal`, ansi.bold + C.warn)
1213
+ pushLine(`目标: ${agent.goal.objective}`, C.tool)
1214
+ if (agent.goal.criteria) pushLine(` 完成条件: ${agent.goal.criteria}`, C.dim)
1215
+ pushLine(` 状态: ${statusText} │ 已用轮数: ${agent.goal.turnsUsed ?? 0} │ 设置于: ${new Date(agent.goal.setAt).toLocaleString()}`, C.dim)
1216
+ return
1217
+ }
1218
+ if (e.action === "cancel") {
1219
+ agent.goal = null
1220
+ pushLabel(`❯ Goal`, ansi.bold + C.dim)
1221
+ pushLine(`目标已取消。`, C.dim)
1222
+ return
1223
+ }
1224
+ // set — 需要输入目标文本
1225
+ askQuestion("请输入目标描述(; 分隔完成条件)").then((text) => {
1226
+ if (!text) return
1227
+ const semi = text.indexOf(";") >= 0 ? ";" : text.indexOf(";") >= 0 ? ";" : null
1228
+ const objective = semi ? text.slice(0, semi).trim() : text.trim()
1229
+ const criteria = semi ? text.slice(semi + 1).trim() : ""
1230
+ agent.goal = { objective, criteria, setAt: Date.now(), status: "active", turnsUsed: 0, _blockTally: null }
1231
+ pushLabel(`❯ Goal`, ansi.bold + C.warn)
1232
+ pushLine(`目标已设置: ${objective}`, C.tool)
1233
+ if (criteria) pushLine(` 完成条件: ${criteria}`, C.dim)
1234
+ else pushLine(` ⚠ 未完成条件——agent 用 goal set 设立时会被要求补上可验证的完成条件`, C.warn)
1235
+ })
1236
+ },
1237
+ })
1182
1238
  return
1183
1239
  }
1184
1240
  case "/skills": {
@@ -1195,93 +1251,107 @@ export async function startTUI(agent, opts = {}) {
1195
1251
  return
1196
1252
  }
1197
1253
  case "/mcp": {
1198
- const sub = rest[0]
1199
- // ---- /mcp list — 列出配置的 servers + 连接状态 ----
1200
- if (!sub || sub === "list") {
1201
- const servers = agent.config?.mcp?.servers ?? []
1202
- pushLabel(`❯ MCP Servers`, ansi.bold + C.tool)
1203
- if (servers.length === 0) {
1204
- pushLine("(无 MCP server——使用 /mcp add <name> <url|command> 添加)", C.dim)
1205
- }
1206
- for (const srv of servers) {
1207
- const connected = agent.tools.some((t) => t._mcpName === srv.name)
1208
- const mark = connected ? "●" : "○"
1209
- const color = connected ? C.tool : C.dim
1210
- const toolCount = agent.tools.filter((t) => t._mcpName === srv.name).length
1211
- const desc = srv.wsUrl ? srv.wsUrl : srv.url ? srv.url : `${srv.command} ${(srv.args ?? []).join(" ")}`
1212
- pushLine(` ${mark} ${srv.name}: ${desc} (${toolCount} tools)`, color)
1213
- }
1214
- pushLabel(`❯ 操作`, ansi.bold + C.tool)
1215
- pushLine("/mcp add <name> <url|command> [args|headers...]", C.dim)
1216
- pushLine(" URL 自动识别: https://… → HTTP, ws://… → WebSocket, 其他 → stdio 命令", C.dim)
1217
- pushLine(" 例: /mcp add myapi https://api.example.com/mcp Authorization=\"Bearer x\"", C.dim)
1218
- pushLine(" 例: /mcp add github npx -y @modelcontextprotocol/server-github", C.dim)
1219
- pushLine(`/mcp remove <name> 断开并移除`, C.dim)
1220
- pushLine(`/mcp connect <name> 重连已配置的 server`, C.dim)
1221
- return
1222
- }
1223
- // ---- /mcp add <name> <url|command> [args|headers...] (统一入口,自动识别传输类型) ----
1224
- // url / ws 子命令作为别名保留(兼容旧配置)
1225
- if (sub === "add" || sub === "url" || sub === "ws") {
1226
- const args = rest.slice(1)
1227
- if (args.length < 2) {
1228
- pushLine("用法: /mcp add <name> <url|command> [args|headers...]", C.error)
1229
- pushLine(" URL 自动识别: https://… → HTTP, ws://… → WebSocket, 其他 → stdio 命令", C.dim)
1230
- return
1231
- }
1232
- const name = args[0]
1233
- const second = args[1]
1234
- const extras = args.slice(2)
1235
- const existing = (agent.config?.mcp?.servers ?? []).find((s) => s.name === name)
1236
- if (existing) { pushLine(`[mcp] "${name}" 已存在,用 /mcp remove ${name} 先移除`, C.error); return }
1237
-
1238
- const isWS = /^wss?:\/\//.test(second)
1239
- const isHTTP = /^https?:\/\//.test(second)
1240
- let srv
1241
- if (isWS || sub === "ws") {
1242
- const headers = parseHeaders(extras)
1243
- srv = { name, wsUrl: second, headers: Object.keys(headers).length > 0 ? headers : undefined }
1244
- } else if (isHTTP || sub === "url") {
1245
- const headers = parseHeaders(extras)
1246
- srv = { name, url: second, headers: Object.keys(headers).length > 0 ? headers : undefined }
1247
- } else {
1248
- srv = { name, command: second, args: extras.length > 0 ? extras : undefined }
1249
- }
1250
- await addAndConnect(srv)
1251
- return
1252
- }
1253
- // ---- /mcp remove <name> ----
1254
- if (sub === "remove") {
1255
- const name = rest[1]
1256
- if (!name) { pushLine("用法: /mcp remove <name>", C.error); return }
1257
- const { removeMcpTools } = await import("./mcp.mjs")
1258
- removeMcpTools(agent, name)
1259
- await persistRaw((raw) => { raw.mcp ??= { servers: [] }; raw.mcp.servers = raw.mcp.servers.filter((s) => s.name !== name) })
1260
- if (agent.config?.mcp?.servers) agent.config.mcp.servers = agent.config.mcp.servers.filter((s) => s.name !== name)
1261
- pushLabel(`❯ MCP`, ansi.bold + C.tool)
1262
- pushLine(`${name} 已断开并从配置移除。`, C.tool)
1263
- return
1264
- }
1265
- // ---- /mcp connect <name> — 重连 ----
1266
- if (sub === "connect") {
1267
- const name = rest[1]
1268
- if (!name) { pushLine("用法: /mcp connect <name>", C.error); return }
1269
- const srv = (agent.config?.mcp?.servers ?? []).find((s) => s.name === name)
1270
- if (!srv) { pushLine(`[mcp] "${name}" 未在配置中找到(先用 /mcp add 添加)`, C.error); return }
1271
- const { removeMcpTools, connectMcpServer } = await import("./mcp.mjs")
1272
- removeMcpTools(agent, name)
1273
- try {
1274
- pushLine(`[mcp] 重连 ${name}...`, C.dim)
1275
- const tools = await connectMcpServer(srv)
1276
- agent.tools.push(...tools)
1277
- pushLabel(`❯ MCP`, ansi.bold + C.tool)
1278
- pushLine(`${name} 已重连,${tools.length} 个工具可用。`, C.tool)
1279
- } catch (error) {
1280
- pushLine(`[mcp] ${name}: ${error.message}`, C.error)
1281
- }
1282
- return
1254
+ const servers = agent.config?.mcp?.servers ?? []
1255
+ const entries = [
1256
+ { type: "header", text: `已配置 ${servers.length} 个 MCP server` },
1257
+ { type: "item", text: "查看列表", action: "list" },
1258
+ { type: "item", text: "添加服务器", action: "add" },
1259
+ ]
1260
+ if (servers.length > 0) {
1261
+ entries.push(
1262
+ { type: "item", text: "移除服务器", action: "remove" },
1263
+ { type: "item", text: "重连服务器", action: "connect" },
1264
+ )
1283
1265
  }
1284
- pushLine(`未知子命令: ${sub}(/mcp list | add | remove | connect)`, C.error)
1266
+ openPicker({
1267
+ title: "MCP 管理",
1268
+ entries,
1269
+ onSelect: async (e) => {
1270
+ if (e.action === "list") {
1271
+ pushLabel(`❯ MCP Servers`, ansi.bold + C.tool)
1272
+ if (servers.length === 0) {
1273
+ pushLine("(无 MCP server)", C.dim)
1274
+ }
1275
+ for (const srv of servers) {
1276
+ const connected = agent.tools.some((t) => t._mcpName === srv.name)
1277
+ const mark = connected ? "●" : "○"
1278
+ const color = connected ? C.tool : C.dim
1279
+ const toolCount = agent.tools.filter((t) => t._mcpName === srv.name).length
1280
+ const desc = srv.wsUrl ? srv.wsUrl : srv.url ? srv.url : `${srv.command} ${(srv.args ?? []).join(" ")}`
1281
+ pushLine(` ${mark} ${srv.name}: ${desc} (${toolCount} tools)`, color)
1282
+ }
1283
+ return
1284
+ }
1285
+ if (e.action === "remove") {
1286
+ const removeEntries = [
1287
+ { type: "header", text: "选择要移除的服务器" },
1288
+ ...servers.map((s) => ({ type: "item", text: s.name, name: s.name })),
1289
+ ]
1290
+ openPicker({
1291
+ title: "移除 MCP",
1292
+ entries: removeEntries,
1293
+ onSelect: async (se) => {
1294
+ const { removeMcpTools } = await import("./mcp.mjs")
1295
+ removeMcpTools(agent, se.name)
1296
+ await persistRaw((raw) => { raw.mcp ??= { servers: [] }; raw.mcp.servers = raw.mcp.servers.filter((s) => s.name !== se.name) })
1297
+ if (agent.config?.mcp?.servers) agent.config.mcp.servers = agent.config.mcp.servers.filter((s) => s.name !== se.name)
1298
+ pushLabel(`❯ MCP`, ansi.bold + C.tool)
1299
+ pushLine(`${se.name} 已断开并从配置移除。`, C.tool)
1300
+ },
1301
+ })
1302
+ return
1303
+ }
1304
+ if (e.action === "connect") {
1305
+ const connEntries = [
1306
+ { type: "header", text: "选择要重连的服务器" },
1307
+ ...servers.map((s) => ({ type: "item", text: s.name, name: s.name })),
1308
+ ]
1309
+ openPicker({
1310
+ title: "重连 MCP",
1311
+ entries: connEntries,
1312
+ onSelect: async (se) => {
1313
+ const srv = servers.find((s) => s.name === se.name)
1314
+ if (!srv) return
1315
+ const { removeMcpTools, connectMcpServer } = await import("./mcp.mjs")
1316
+ removeMcpTools(agent, se.name)
1317
+ try {
1318
+ pushLine(`[mcp] 重连 ${se.name}...`, C.dim)
1319
+ const tools = await connectMcpServer(srv)
1320
+ agent.tools.push(...tools)
1321
+ pushLabel(`❯ MCP`, ansi.bold + C.tool)
1322
+ pushLine(`${se.name} 已重连,${tools.length} 个工具可用。`, C.tool)
1323
+ } catch (error) {
1324
+ pushLine(`[mcp] ${se.name}: ${error.message}`, C.error)
1325
+ }
1326
+ },
1327
+ })
1328
+ return
1329
+ }
1330
+ if (e.action === "add") {
1331
+ askQuestion("输入: <名称> <URL|命令> [参数...]\nURL 自动识别: https://… → HTTP, ws://… → WebSocket, 其他 → stdio 命令").then(async (text) => {
1332
+ if (!text) return
1333
+ const parts = text.split(/\s+/)
1334
+ if (parts.length < 2) { pushLine("用法: <名称> <URL|命令> [参数...]", C.error); return }
1335
+ const [name, second, ...extras] = parts
1336
+ const existing = (agent.config?.mcp?.servers ?? []).find((s) => s.name === name)
1337
+ if (existing) { pushLine(`[mcp] "${name}" 已存在`, C.error); return }
1338
+ const isWS = /^wss?:\/\//.test(second)
1339
+ const isHTTP = /^https?:\/\//.test(second)
1340
+ let srv
1341
+ if (isWS) {
1342
+ const headers = parseHeaders(extras)
1343
+ srv = { name, wsUrl: second, headers: Object.keys(headers).length > 0 ? headers : undefined }
1344
+ } else if (isHTTP) {
1345
+ const headers = parseHeaders(extras)
1346
+ srv = { name, url: second, headers: Object.keys(headers).length > 0 ? headers : undefined }
1347
+ } else {
1348
+ srv = { name, command: second, args: extras.length > 0 ? extras : undefined }
1349
+ }
1350
+ await addAndConnect(srv)
1351
+ })
1352
+ }
1353
+ },
1354
+ })
1285
1355
  return
1286
1356
  }
1287
1357
 
@@ -1338,235 +1408,235 @@ export async function startTUI(agent, opts = {}) {
1338
1408
  )
1339
1409
  return
1340
1410
  case "/think": {
1341
- const sub = rest[0]
1342
1411
  const cur = agent.provider
1343
1412
  const thinkingEnabled = cur.thinking?.type === "enabled" || cur.thinking?.type === undefined
1344
- // ---- /think(无参): 查看状态 ----
1345
- if (!sub) {
1346
- pushLabel(`❯ Think`, ansi.bold + C.tool)
1347
- pushLine(`思维模式: ${thinkingEnabled ? "🟢 开启" : "⚫ 关闭"}`, C.dim)
1348
- pushLine(`推理强度: ${cur.reasoningEffort ?? "(未设置)"}`, C.dim)
1349
- pushLine(`切换: /think on | off | effort high | effort max`, C.dim)
1350
- return
1351
- }
1352
- // ---- /think on / off ----
1353
- if (sub === "on" || sub === "off") {
1354
- const enable = sub === "on"
1355
- // 仅用 reasoning_effort 的模型(K3):不碰 thinking 字段,只设/删 reasoningEffort
1356
- const { specForModel } = await import("./config.mjs")
1357
- const spec = specForModel(cur.model)
1358
- if (spec.thinkApi === "effort") {
1359
- // 仅用 reasoning_effort 的模型(K3 / Qwen):不碰 thinking 字段
1360
- if (!enable) delete cur.reasoningEffort
1361
- else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
1362
- if (!enable) await syncProviderField("reasoningEffort", undefined)
1363
- else await syncProviderField("reasoningEffort", cur.reasoningEffort)
1364
- } else {
1365
- cur.thinking = enable ? { type: "enabled" } : { type: "disabled" }
1366
- if (!enable) delete cur.reasoningEffort
1367
- else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
1368
- await syncProviderField("thinking", cur.thinking)
1369
- if (!enable) await syncProviderField("reasoningEffort", undefined)
1370
- else await syncProviderField("reasoningEffort", cur.reasoningEffort)
1371
- }
1372
- pushLabel(`❯ Think`, ansi.bold + C.tool)
1373
- pushLine(`思维模式已${enable ? "开启" : "关闭"}`, C.tool)
1374
- if (enable) pushLine(`推理强度: ${cur.reasoningEffort}`, C.dim)
1375
- return
1376
- }
1377
- // ---- /think effort <level> ----
1378
- if (sub === "effort") {
1379
- const level = rest[1]
1380
- if (!level || !["low", "high", "max"].includes(level)) {
1381
- pushLine("用法: /think effort low | high | max", C.error)
1382
- return
1383
- }
1384
- cur.reasoningEffort = level
1385
- await syncProviderField("reasoningEffort", level)
1386
- pushLabel(`❯ Think`, ansi.bold + C.tool)
1387
- pushLine(`推理强度已设为 ${level}`, C.tool)
1388
- return
1389
- }
1390
- pushLine(`未知参数: ${sub}(可用: on / off / effort / effort high|max)`, C.error)
1413
+ const { specForModel } = await import("./config.mjs")
1414
+ const spec = specForModel(cur.model)
1415
+ const isEffortOnly = spec.thinkApi === "effort"
1416
+
1417
+ const entries = [
1418
+ { type: "header", text: "思维模式" },
1419
+ { type: "item", text: `开启${thinkingEnabled ? " ← 当前" : ""}`, action: "on" },
1420
+ { type: "item", text: `关闭${!thinkingEnabled ? " ← 当前" : ""}`, action: "off" },
1421
+ { type: "header", text: "推理强度" },
1422
+ ...["low", "high", "max"].map((l) => ({
1423
+ type: "item",
1424
+ text: `${l}${cur.reasoningEffort === l ? " ← 当前" : ""}`,
1425
+ action: "effort",
1426
+ level: l,
1427
+ })),
1428
+ ]
1429
+ openPicker({
1430
+ title: "思维模式",
1431
+ entries,
1432
+ defaultIndex: thinkingEnabled ? 0 : 1,
1433
+ onSelect: async (e) => {
1434
+ if (e.action === "effort") {
1435
+ cur.reasoningEffort = e.level
1436
+ await syncProviderField("reasoningEffort", e.level)
1437
+ pushLabel(`❯ Think`, ansi.bold + C.tool)
1438
+ pushLine(`推理强度已设为 ${e.level}`, C.tool)
1439
+ } else {
1440
+ const enable = e.action === "on"
1441
+ if (isEffortOnly) {
1442
+ if (!enable) delete cur.reasoningEffort
1443
+ else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
1444
+ if (!enable) await syncProviderField("reasoningEffort", undefined)
1445
+ else await syncProviderField("reasoningEffort", cur.reasoningEffort)
1446
+ } else {
1447
+ cur.thinking = enable ? { type: "enabled" } : { type: "disabled" }
1448
+ if (!enable) delete cur.reasoningEffort
1449
+ else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
1450
+ await syncProviderField("thinking", cur.thinking)
1451
+ if (!enable) await syncProviderField("reasoningEffort", undefined)
1452
+ else await syncProviderField("reasoningEffort", cur.reasoningEffort)
1453
+ }
1454
+ pushLabel(`❯ Think`, ansi.bold + C.tool)
1455
+ pushLine(`思维模式已${enable ? "开启" : "关闭"}`, C.tool)
1456
+ if (enable) pushLine(`推理强度: ${cur.reasoningEffort}`, C.dim)
1457
+ }
1458
+ },
1459
+ })
1391
1460
  return
1392
1461
  }
1393
1462
  case "/model": {
1394
- const arg = rest[0]
1395
- if (!arg) {
1396
- // 打开交互选择器:全部 provider 的全部模型,方向键选择
1397
- pushLabel(`❯ Model`, ansi.bold + C.tool)
1398
- pushLine(`/model <名称> 直接切换 provider 或模型(如 /model deepseek-v4-pro)`, C.dim)
1399
- pushLine(`/provider 管理 provider(添加/删除/配 key)`, C.dim)
1400
- openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
1401
- return
1402
- }
1403
- if (arg === "add" || arg === "--add") {
1404
- pushLine(`添加 provider 已移到 /provider add(/provider 查看全部管理命令)`, C.warn)
1405
- return
1406
- }
1407
- // 一个参数两种含义:先按 provider 名匹配,匹配不到就当模型名改当前 provider
1408
- const { resolveCompactThreshold } = await import("./config.mjs")
1409
- const p = agent.providers.find((pp) => pp.name === arg)
1410
- const newModel = p ? p.model : arg
1411
- let thresholdNote = ""
1412
- if (agent.config?.agent?.compactThresholdAuto) {
1413
- const { value } = resolveCompactThreshold(null, newModel)
1414
- agent.config.agent.compactThreshold = value
1415
- thresholdNote = `,压缩阈值随模型调整为 ${value}`
1416
- }
1417
- if (p) {
1418
- agent.activeProvider = arg
1419
- agent.provider = { ...p }
1420
- // key 的环境变量兜底和 loadConfig 保持一致(提供商专用变量只对同名生效)
1421
- if (!agent.provider.apiKey) {
1422
- const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[arg]
1423
- if (envKey && process.env[envKey]) agent.provider.apiKey = process.env[envKey]
1424
- }
1425
- if (!agent.provider.apiKey) agent.provider.apiKey = process.env.THINCODER_API_KEY
1426
- await persistRaw((raw) => { raw.activeProvider = arg })
1427
- agent.config.activeProvider = arg
1428
- pushLabel(`❯ Model`, ansi.bold + C.tool)
1429
- pushLine(`已切换到 ${arg} / ${p.model}${thresholdNote}(已持久化)`, C.tool)
1430
- if (!agent.provider.apiKey) pushLine(`该 provider 还没配 key: /provider key <apikey>`, C.warn)
1431
- } else {
1432
- const target = agent.providers.find((pp) => pp.name === agent.activeProvider) ?? agent.providers[0]
1433
- if (target) target.model = arg
1434
- agent.provider.model = arg
1435
- await persistRaw((raw) => { raw.providers = agent.providers })
1436
- pushLabel(`❯ Model`, ansi.bold + C.tool)
1437
- pushLine(`已将 ${target?.name ?? agent.activeProvider} 的模型改为 ${arg}${thresholdNote}(已持久化)`, C.tool)
1438
- }
1463
+ openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
1439
1464
  return
1440
1465
  }
1441
1466
  case "/provider": {
1442
- const sub = rest[0]
1443
- // ---- /provider add <名称> <baseURL> <模型>,或 /provider add <预设> ----
1444
- if (sub === "add") {
1445
- const name = rest[1]
1446
- if (!name) {
1447
- pushLine(`用法: /provider add <名称> <baseURL> <模型>,或 /provider add <预设>(${Object.keys(PRESETS).join(", ")})`, C.error)
1448
- return
1449
- }
1450
- if (agent.providers.some((p) => p.name === name)) {
1451
- pushLine(`"${name}" 已存在;要重建可先 /provider remove ${name}`, C.warn)
1452
- return
1453
- }
1454
- const preset = PRESETS[name]
1455
- const baseURL = (rest[2] ?? preset?.baseURL)?.replace(/\/+$/, "")
1456
- const model = rest[3] ?? preset?.model
1457
- if (!baseURL || !model) {
1458
- pushLine(`缺少参数: /provider add ${name} <baseURL> <模型>`, C.error)
1459
- if (!preset) pushLine(`("${name}" 不是预设;预设: ${Object.keys(PRESETS).join(", ")})`, C.dim)
1460
- return
1461
- }
1462
- if (!/^https?:\/\//.test(baseURL)) { pushLine(`baseURL 应以 http(s):// 开头`, C.error); return }
1463
- agent.providers.push({ name, baseURL, model, ...(preset?.desc ? { desc: preset.desc } : {}) })
1464
- await persistRaw((raw) => { raw.providers = agent.providers })
1465
- pushLabel(`❯ Provider`, ansi.bold + C.tool)
1466
- pushLine(`已添加 ${name}(${baseURL} / ${model})`, C.tool)
1467
- pushLine(`下一步: /provider key ${name} <apikey> 配 key,/model ${name} 切换`, C.dim)
1468
- return
1469
- }
1470
- // ---- /provider remove <名称> ----
1471
- if (sub === "remove" || sub === "rm") {
1472
- const name = rest[1]
1473
- if (!name) { pushLine("用法: /provider remove <名称>", C.error); return }
1474
- const at = agent.providers.findIndex((p) => p.name === name)
1475
- if (at < 0) { pushLine(`未找到 provider "${name}"`, C.error); return }
1476
- if (name === agent.activeProvider) { pushLine(`"${name}" 正在使用中,先 /model 切换到别的 provider 再删`, C.warn); return }
1477
- agent.providers.splice(at, 1)
1478
- await persistRaw((raw) => { raw.providers = agent.providers })
1479
- pushLabel(`❯ Provider`, ansi.bold + C.tool)
1480
- pushLine(`已删除 ${name}`, C.tool)
1481
- return
1482
- }
1483
- // ---- /provider key [名称] <apikey> ----
1484
- if (sub === "key") {
1485
- let name = agent.activeProvider
1486
- let keyParts = rest.slice(1)
1487
- if (rest[1] && agent.providers.some((p) => p.name === rest[1])) {
1488
- name = rest[1]
1489
- keyParts = rest.slice(2)
1490
- }
1491
- const key = keyParts.join(" ")
1492
- if (!key) { pushLine("用法: /provider key [名称] <apikey>(不填名称配当前 provider)", C.error); return }
1493
- await setProviderKey(name, key)
1494
- return
1495
- }
1496
- if (sub) { pushLine(`未知: ${sub}(/provider add | remove | key)`, C.error); return }
1497
- // ---- /provider(无参): 列表 ----
1498
- pushLabel(`❯ Providers (${agent.providers.length})`, ansi.bold + C.tool)
1499
- for (const p of agent.providers) {
1500
- const active = p.name === agent.activeProvider
1501
- pushLine(
1502
- `${active ? " ▸" : " "} ${p.name.padEnd(12)} ${p.model.padEnd(20)} ${p.baseURL}${p.apiKey ? " ●key" : " ○无key"}${active ? " ← 当前" : ""}`,
1503
- active ? C.tool : C.dim,
1467
+ const entries = [
1468
+ { type: "header", text: `已配置 ${agent.providers.length} provider` },
1469
+ { type: "item", text: "查看列表", action: "list" },
1470
+ { type: "item", text: "添加 provider", action: "add" },
1471
+ ]
1472
+ if (agent.providers.length > 0) {
1473
+ entries.push(
1474
+ { type: "item", text: "移除 provider", action: "remove" },
1504
1475
  )
1505
1476
  }
1506
- pushLabel(`❯ 操作`, ansi.bold + C.tool)
1507
- pushLine(`/provider add <名称|预设> <url> <模型> 添加(预设: ${Object.keys(PRESETS).join(" ")})`, C.dim)
1508
- pushLine(`/provider remove <名称> 删除`, C.dim)
1509
- if (!agent.provider.apiKey) pushLine(" /provider key <apikey> ← 当前 provider 还没配 key", C.warn)
1510
- else pushLine("/provider key [名称] <apikey> 设置/更换 key", C.dim)
1477
+ if (!agent.provider.apiKey) {
1478
+ entries.push({ type: "item", text: "设置 API Key", action: "key" })
1479
+ } else {
1480
+ entries.push({ type: "item", text: "更换 API Key", action: "key" })
1481
+ }
1482
+ openPicker({
1483
+ title: "Provider 管理",
1484
+ entries,
1485
+ onSelect: async (e) => {
1486
+ if (e.action === "list") {
1487
+ pushLabel(`❯ Providers (${agent.providers.length})`, ansi.bold + C.tool)
1488
+ for (const p of agent.providers) {
1489
+ const active = p.name === agent.activeProvider
1490
+ pushLine(
1491
+ `${active ? " ▸" : " "} ${p.name.padEnd(12)} ${p.model.padEnd(20)} ${p.baseURL}${p.apiKey ? " ●key" : " ○无key"}${active ? " ← 当前" : ""}`,
1492
+ active ? C.tool : C.dim,
1493
+ )
1494
+ }
1495
+ return
1496
+ }
1497
+ if (e.action === "remove") {
1498
+ const candidates = agent.providers.filter((p) => p.name !== agent.activeProvider)
1499
+ if (candidates.length === 0) {
1500
+ pushLine("只有当前 provider,无法移除(先用 /model 切换到别的 provider)", C.warn)
1501
+ return
1502
+ }
1503
+ const removeEntries = [
1504
+ { type: "header", text: "选择要移除的 provider(当前使用的不可移除)" },
1505
+ ...candidates.map((p) => ({ type: "item", text: `${p.name} (${p.model})`, name: p.name })),
1506
+ ]
1507
+ openPicker({
1508
+ title: "移除 Provider",
1509
+ entries: removeEntries,
1510
+ onSelect: async (se) => {
1511
+ const at = agent.providers.findIndex((p) => p.name === se.name)
1512
+ agent.providers.splice(at, 1)
1513
+ await persistRaw((raw) => { raw.providers = agent.providers })
1514
+ pushLabel(`❯ Provider`, ansi.bold + C.tool)
1515
+ pushLine(`已删除 ${se.name}`, C.tool)
1516
+ },
1517
+ })
1518
+ return
1519
+ }
1520
+ if (e.action === "add") {
1521
+ // Add needs text input: name baseURL model
1522
+ askQuestion(
1523
+ `输入: <名称> <baseURL> <模型>\n预设可用: ${Object.keys(PRESETS).join(", ")}\n或只输预设名(如 deepseek)自动补全`,
1524
+ ).then(async (text) => {
1525
+ if (!text) return
1526
+ const parts = text.split(/\s+/)
1527
+ const name = parts[0]
1528
+ if (!name) return
1529
+ if (agent.providers.some((p) => p.name === name)) {
1530
+ pushLine(`"${name}" 已存在;先 /provider → 移除`, C.warn)
1531
+ return
1532
+ }
1533
+ const preset = PRESETS[name]
1534
+ const baseURL = (parts[1] ?? preset?.baseURL)?.replace(/\/+$/, "")
1535
+ const model = parts[2] ?? preset?.model
1536
+ if (!baseURL || !model) {
1537
+ pushLine(`缺少参数: ${name} <baseURL> <模型>`, C.error)
1538
+ return
1539
+ }
1540
+ if (!/^https?:\/\//.test(baseURL)) { pushLine(`baseURL 应以 http(s):// 开头`, C.error); return }
1541
+ agent.providers.push({ name, baseURL, model, ...(preset?.desc ? { desc: preset.desc } : {}) })
1542
+ await persistRaw((raw) => { raw.providers = agent.providers })
1543
+ pushLabel(`❯ Provider`, ansi.bold + C.tool)
1544
+ pushLine(`已添加 ${name}(${baseURL} / ${model})`, C.tool)
1545
+ pushLine(`下一步: /provider → 设置 Key`, C.dim)
1546
+ })
1547
+ return
1548
+ }
1549
+ if (e.action === "key") {
1550
+ // Key: pick which provider, then prompt for key
1551
+ const keyEntries = [
1552
+ { type: "header", text: "选择要配 key 的 provider" },
1553
+ ...agent.providers.map((p) => ({
1554
+ type: "item",
1555
+ text: `${p.name}${p.name === agent.activeProvider ? " ← 当前" : ""}${p.apiKey ? " ●已有key" : " ○无key"}`,
1556
+ name: p.name,
1557
+ })),
1558
+ ]
1559
+ openPicker({
1560
+ title: "配置 API Key",
1561
+ entries: keyEntries,
1562
+ onSelect: (se) => {
1563
+ askQuestion(`为 ${se.name} 输入 API Key:`).then(async (key) => {
1564
+ if (!key) return
1565
+ await setProviderKey(se.name, key)
1566
+ })
1567
+ },
1568
+ })
1569
+ }
1570
+ },
1571
+ })
1511
1572
  return
1512
1573
  }
1513
1574
  case "/config": {
1514
- const sub = rest[0]
1515
- // ---- /config embedkey <apikey>:embedding 服务的 key ----
1516
- if (sub === "embedkey") {
1517
- const key = rest.slice(1).join(" ")
1518
- if (!key) { pushLine("用法: /config embedkey <apikey>(embedding 服务,默认 SiliconFlow bge-m3)", C.error); return }
1519
- agent.config.embedding ??= {}
1520
- agent.config.embedding.apiKey = key
1521
- await persistRaw((raw) => { raw.embedding = { ...(raw.embedding ?? {}), apiKey: key } })
1522
- if (agent.memory) {
1523
- const { createEmbedder } = await import("./embedding.mjs")
1524
- agent.memory.embedder = createEmbedder(agent.config.embedding)
1525
- }
1526
- pushLabel(`❯ Config`, ansi.bold + C.tool)
1527
- pushLine(`embedding key 已保存,向量检索已启用`, C.tool)
1528
- return
1529
- }
1530
- // ---- /config set <path> <value> (高级) ----
1531
- if (sub === "set") {
1532
- const [path, value] = [rest[1], rest.slice(2).join(" ")]
1533
- if (!path || !value) { pushLine("用法: /config set <path> <value> 如 /config set agent.maxTurns 80", C.error); return }
1534
- try {
1535
- const { configPath, loadConfig, saveConfig } = await import("./config.mjs")
1536
- const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
1537
- // 支持 a.b 形式的嵌套 key
1538
- const keys = path.split(".")
1539
- let obj = raw
1540
- for (let i = 0; i < keys.length - 1; i++) { obj[keys[i]] ??= {}; obj = obj[keys[i]] }
1541
- obj[keys[keys.length - 1]] = isNaN(value) ? value : Number(value)
1542
- saveConfig(raw)
1543
- const cfg = loadConfig()
1544
- agent.provider = cfg.provider
1545
- agent.providers = cfg.providersList
1546
- agent.activeProvider = cfg.activeProvider
1547
- agent.config = cfg
1548
- pushLabel(`❯ Config`, ansi.bold + C.tool)
1549
- pushLine(`已保存: ${path} = ${value}`, C.tool)
1550
- } catch (error) {
1551
- pushLine(`保存失败: ${error.message}`, C.error)
1552
- }
1553
- return
1554
- }
1555
- if (sub) { pushLine(`未知: ${sub}(可用: embedkey / set)`, C.error); return }
1556
- // ---- /config(无参): 查看 ----
1557
- const { configPath: cp } = await import("./config.mjs")
1558
- pushLabel(`❯ 配置`, ansi.bold + C.tool)
1559
- pushLine(`激活: ${agent.activeProvider} / ${agent.provider.model}`, C.dim)
1560
- pushLine(`Key: ${maskKey(agent.provider.apiKey)}`, C.dim)
1561
- const ac = agent.config?.agent ?? {}
1562
- const tn = `${ac.compactThreshold ?? 100000}${ac.compactThresholdAuto ? " (auto)" : ""}`
1563
- pushLine(`agent: maxTurns=${ac.maxTurns ?? 100} | compactThreshold=${tn}`, C.dim)
1564
- pushLine(`embedding: ${agent.memory?.embedder ? `enabled (${agent.config?.embedding?.model ?? ""})` : "disabled(纯 FTS 检索)"}`, C.dim)
1565
- pushLabel(`❯ 管理`, ansi.bold + C.tool)
1566
- pushLine(`/provider 管理 provider(添加/删除/配 key)`, C.dim)
1567
- if (!agent.memory?.embedder) pushLine(`/config embedkey <k> 开启向量检索`, C.dim)
1568
- pushLine(`/config set <k> <v> 修改任意配置项`, C.dim)
1569
- pushLine(`配置文件: ${cp}`, C.dim)
1575
+ const entries = [
1576
+ { type: "header", text: "配置管理" },
1577
+ { type: "item", text: "查看当前配置", action: "view" },
1578
+ { type: "item", text: "设置 embedding key(向量检索)", action: "embedkey" },
1579
+ { type: "item", text: "高级设置(set path value)", action: "set" },
1580
+ ]
1581
+ openPicker({
1582
+ title: "配置管理",
1583
+ entries,
1584
+ onSelect: async (e) => {
1585
+ if (e.action === "view") {
1586
+ const { configPath: cp } = await import("./config.mjs")
1587
+ pushLabel(`❯ 配置`, ansi.bold + C.tool)
1588
+ pushLine(`激活: ${agent.activeProvider} / ${agent.provider.model}`, C.dim)
1589
+ pushLine(`Key: ${maskKey(agent.provider.apiKey)}`, C.dim)
1590
+ const ac = agent.config?.agent ?? {}
1591
+ const tn = `${ac.compactThreshold ?? 100000}${ac.compactThresholdAuto ? " (auto)" : ""}`
1592
+ pushLine(`agent: maxTurns=${ac.maxTurns ?? 100} | compactThreshold=${tn}`, C.dim)
1593
+ pushLine(`embedding: ${agent.memory?.embedder ? `enabled (${agent.config?.embedding?.model ?? ""})` : "disabled(纯 FTS 检索)"}`, C.dim)
1594
+ pushLine(`配置文件: ${cp}`, C.dim)
1595
+ return
1596
+ }
1597
+ if (e.action === "embedkey") {
1598
+ askQuestion("输入 embedding 服务的 API Key(默认 SiliconFlow bge-m3):").then(async (key) => {
1599
+ if (!key) return
1600
+ agent.config.embedding ??= {}
1601
+ agent.config.embedding.apiKey = key
1602
+ await persistRaw((raw) => { raw.embedding = { ...(raw.embedding ?? {}), apiKey: key } })
1603
+ if (agent.memory) {
1604
+ const { createEmbedder } = await import("./embedding.mjs")
1605
+ agent.memory.embedder = createEmbedder(agent.config.embedding)
1606
+ }
1607
+ pushLabel(`❯ Config`, ansi.bold + C.tool)
1608
+ pushLine(`embedding key 已保存,向量检索已启用`, C.tool)
1609
+ })
1610
+ return
1611
+ }
1612
+ if (e.action === "set") {
1613
+ askQuestion("输入: <path> <value>(如 agent.maxTurns 80,支持 a.b 嵌套):").then(async (text) => {
1614
+ if (!text) return
1615
+ const parts = text.split(/\s+/)
1616
+ const [path, value] = [parts[0], parts.slice(1).join(" ")]
1617
+ if (!path || !value) { pushLine("用法: <path> <value> 如 agent.maxTurns 80", C.error); return }
1618
+ try {
1619
+ const { configPath, loadConfig, saveConfig } = await import("./config.mjs")
1620
+ const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
1621
+ const keys = path.split(".")
1622
+ let obj = raw
1623
+ for (let i = 0; i < keys.length - 1; i++) { obj[keys[i]] ??= {}; obj = obj[keys[i]] }
1624
+ obj[keys[keys.length - 1]] = isNaN(value) ? value : Number(value)
1625
+ saveConfig(raw)
1626
+ const cfg = loadConfig()
1627
+ agent.provider = cfg.provider
1628
+ agent.providers = cfg.providersList
1629
+ agent.activeProvider = cfg.activeProvider
1630
+ agent.config = cfg
1631
+ pushLabel(`❯ Config`, ansi.bold + C.tool)
1632
+ pushLine(`已保存: ${path} = ${value}`, C.tool)
1633
+ } catch (error) {
1634
+ pushLine(`保存失败: ${error.message}`, C.error)
1635
+ }
1636
+ })
1637
+ }
1638
+ },
1639
+ })
1570
1640
  return
1571
1641
  }
1572
1642
  case "/help": {
@@ -1674,9 +1744,22 @@ export async function startTUI(agent, opts = {}) {
1674
1744
 
1675
1745
  // ---------------------------------------------------------- 模型选择器(/model)
1676
1746
 
1677
- const pickerItems = () => state.picker.entries.filter((e) => e.type === "item")
1747
+ const pickerItems = () => state.picker?.entries.filter((e) => e.type === "item") ?? []
1748
+
1749
+ /** 打开通用列表选择器。entries 含 { type: "header"|"item", text, note?, ...extra },
1750
+ * onSelect 拿到选中条目(含 extra 字段透传),onCancel 在 Esc 时调。 */
1751
+ function openPicker({ title, entries, onSelect, onCancel, defaultIndex = 0 }) {
1752
+ state.picker = { title, entries, lines: [], index: defaultIndex, scroll: 0, selectedLine: 0, onSelect, onCancel }
1753
+ renderPickerLines()
1754
+ }
1755
+
1756
+ function closePicker() {
1757
+ state.picker?.onCancel?.()
1758
+ state.picker = null
1759
+ render()
1760
+ }
1678
1761
 
1679
- /** 按 entries 重建显示行并刷新;高亮选中项、标注当前模型 */
1762
+ /** 按 entries 重建显示行并刷新 */
1680
1763
  function renderPickerLines() {
1681
1764
  const p = state.picker
1682
1765
  if (!p) return
@@ -1685,13 +1768,13 @@ export async function startTUI(agent, opts = {}) {
1685
1768
  let selectedLine = 0
1686
1769
  for (const e of p.entries) {
1687
1770
  if (e.type === "header") {
1688
- lines.push({ text: ` ${e.name}${e.note ? ` ${e.note}` : ""}`, color: ansi.bold + C.tool })
1771
+ lines.push({ text: ` ${e.text}${e.note ? ` ${e.note}` : ""}`, color: ansi.bold + C.tool })
1689
1772
  } else {
1690
1773
  const selected = row === p.index
1691
1774
  if (selected) selectedLine = lines.length
1692
- const current = e.provider === agent.activeProvider && e.model === agent.provider.model
1775
+ const marker = e.marker ? ` ${e.marker}` : ""
1693
1776
  lines.push({
1694
- text: `${selected ? " ▸ " : " "}${e.model}${current ? " ← 当前" : ""}`,
1777
+ text: `${selected ? " ▸ " : " "}${e.text}${marker}`,
1695
1778
  color: selected ? ansi.bold + C.text : C.dim,
1696
1779
  })
1697
1780
  row++
@@ -1702,14 +1785,16 @@ export async function startTUI(agent, opts = {}) {
1702
1785
  render()
1703
1786
  }
1704
1787
 
1705
- /** 打开选择器:先列出各 provider 已配置的模型,再并发拉取各端点的全部模型展开进去 */
1788
+ // ========== 模型选择器(基于通用 picker,异步拉取远端模型列表) ==========
1789
+
1706
1790
  async function openModelPicker() {
1707
1791
  const entries = []
1708
1792
  for (const p of agent.providers) {
1709
- entries.push({ type: "header", name: p.name, note: `${p.baseURL}${p.apiKey ? "" : "(未配 key)"} 加载中...` })
1710
- entries.push({ type: "item", provider: p.name, model: p.model })
1793
+ entries.push({ type: "header", text: p.name, note: `${p.baseURL}${p.apiKey ? "" : "(未配 key)"} 加载中...` })
1794
+ entries.push({ type: "item", text: p.model, provider: p.name, model: p.model })
1711
1795
  }
1712
- state.picker = { entries, lines: [], index: 0, scroll: 0, selectedLine: 0 }
1796
+ const onSelect = (e) => selectModel(e).catch((err) => pushLine(`[error] ${err.message}`, C.error))
1797
+ openPicker({ title: "选择模型", entries, onSelect })
1713
1798
  // 默认选中当前在用的模型
1714
1799
  const current = pickerItems().findIndex(
1715
1800
  (e) => e.provider === agent.activeProvider && e.model === agent.provider.model,
@@ -1720,10 +1805,9 @@ export async function startTUI(agent, opts = {}) {
1720
1805
  const { listModels } = await import("./provider.mjs")
1721
1806
  await Promise.all(
1722
1807
  agent.providers.map(async (p) => {
1723
- const header = entries.find((e) => e.type === "header" && e.name === p.name)
1808
+ const header = entries.find((e) => e.type === "header" && e.provider === undefined && e.text === p.name)
1724
1809
  const noteBase = `${p.baseURL}${p.apiKey ? "" : "(未配 key)"}`
1725
1810
  try {
1726
- // key 的环境变量兜底和 loadConfig 保持一致(提供商专用变量只对同名生效)
1727
1811
  const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[p.name]
1728
1812
  let apiKey = p.apiKey
1729
1813
  if (!apiKey && envKey && process.env[envKey]) apiKey = process.env[envKey]
@@ -1732,27 +1816,21 @@ export async function startTUI(agent, opts = {}) {
1732
1816
  { baseURL: p.baseURL, apiKey: apiKey ?? "" },
1733
1817
  { signal: AbortSignal.timeout(10000) },
1734
1818
  )
1735
- // 展开到该 provider 已配置模型的后面(去重)
1736
1819
  const at = entries.findIndex((e) => e.type === "item" && e.provider === p.name && e.model === p.model)
1737
1820
  entries.splice(
1738
1821
  at + 1,
1739
1822
  0,
1740
- ...models.filter((m) => m !== p.model).map((m) => ({ type: "item", provider: p.name, model: m })),
1823
+ ...models.filter((m) => m !== p.model).map((m) => ({ type: "item", text: m, provider: p.name, model: m })),
1741
1824
  )
1742
- header.note = noteBase
1825
+ if (header) header.note = noteBase
1743
1826
  } catch (error) {
1744
- header.note = `${noteBase} (拉取失败: ${sliceByWidth(error.message, 60)})`
1827
+ if (header) header.note = `${noteBase} (拉取失败: ${sliceByWidth(error.message, 60)})`
1745
1828
  }
1746
- if (state.picker?.entries === entries) renderPickerLines() // 已关闭就不再刷新
1829
+ if (state.picker?.entries === entries) renderPickerLines()
1747
1830
  }),
1748
1831
  )
1749
1832
  }
1750
1833
 
1751
- function closeModelPicker() {
1752
- state.picker = null
1753
- render()
1754
- }
1755
-
1756
1834
  /** 给指定 provider 写 key(内存 + 配置文件);若它是当前激活的,同步运行时 */
1757
1835
  async function setProviderKey(name, key) {
1758
1836
  const target = agent.providers.find((p) => p.name === name)
@@ -1920,7 +1998,7 @@ export async function startTUI(agent, opts = {}) {
1920
1998
 
1921
1999
  /** 选中:切换 provider + 模型,持久化,阈值随模型走 */
1922
2000
  async function selectModel(item) {
1923
- closeModelPicker()
2001
+ closePicker()
1924
2002
  const target = agent.providers.find((pp) => pp.name === item.provider)
1925
2003
  if (!target) return
1926
2004
  target.model = item.model
@@ -2080,11 +2158,11 @@ export async function startTUI(agent, opts = {}) {
2080
2158
  setTimeout(() => process.exit(0), 100)
2081
2159
  }
2082
2160
 
2083
- // 模型选择器:↑↓ 移动,Enter 确认,Esc 取消,其余按键吞掉
2161
+ // 通用列表选择器:↑↓ 移动,Enter 确认,Esc 取消
2084
2162
  if (state.picker) {
2085
2163
  const items = pickerItems()
2086
2164
  if (key.name === "escape") {
2087
- closeModelPicker()
2165
+ closePicker()
2088
2166
  } else if (key.name === "up" && items.length) {
2089
2167
  state.picker.index = (state.picker.index - 1 + items.length) % items.length
2090
2168
  renderPickerLines()
@@ -2092,7 +2170,9 @@ export async function startTUI(agent, opts = {}) {
2092
2170
  state.picker.index = (state.picker.index + 1) % items.length
2093
2171
  renderPickerLines()
2094
2172
  } else if (key.name === "return" && items.length) {
2095
- selectModel(items[state.picker.index]).catch((e) => pushLine(`[error] ${e.message}`, C.error))
2173
+ const selected = items[state.picker.index]
2174
+ state.picker.onSelect?.(selected)
2175
+ closePicker()
2096
2176
  }
2097
2177
  return
2098
2178
  }
@@ -2209,7 +2289,7 @@ export async function startTUI(agent, opts = {}) {
2209
2289
  return
2210
2290
  }
2211
2291
  if (key.name === "return") {
2212
- submit()
2292
+ submit().catch((e) => pushLine(`[error] ${e.message}`, C.error))
2213
2293
  return
2214
2294
  }
2215
2295