thincoder 0.7.1 → 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
@@ -513,7 +513,7 @@ export async function startTUI(agent, opts = {}) {
513
513
  if (overlay.selectedLine >= overlay.scroll + winH) overlay.scroll = overlay.selectedLine - winH + 1
514
514
  const start = Math.max(0, Math.min(overlay.scroll, Math.max(0, overlay.lines.length - winH)))
515
515
  const shown = overlay.lines.slice(start, start + winH)
516
- const overlayTitle = state.picker ? "选择模型 " : " ❯ 初始配置 "
516
+ const overlayTitle = state.picker ? `${state.picker.title} ` : " ❯ 初始配置 "
517
517
  out.push(`${ansi.bold}${C.tool}${overlayTitle}${ansi.reset}${ansi.dim}${state.picker ? "(↑↓ 移动, Enter 确认, Esc 取消)" : ""}${ansi.reset}${ansi.clearLine}`)
518
518
  for (const l of shown) {
519
519
  out.push(`${l.color}${sliceByWidth(l.text, cols - 1)}${ansi.reset}${ansi.clearLine}`)
@@ -559,7 +559,7 @@ export async function startTUI(agent, opts = {}) {
559
559
  title = ` Allow ${state.permission.name}? (y/n/a) `
560
560
  }
561
561
  } else if (state.picker) {
562
- title = " Model "
562
+ title = " Select "
563
563
  } else if (state.wizard) {
564
564
  title = " Setup "
565
565
  } else if (state.processing) {
@@ -600,13 +600,21 @@ export async function startTUI(agent, opts = {}) {
600
600
  const cmds = SLASH_COMMANDS.filter((c) => c.name.startsWith(cmd))
601
601
  const match = cmds.length === 1 ? cmds[0] : null
602
602
  if (match?.name === "/config" && cmd === "/config") {
603
- statusLine = " /config 查看 │ embedkey 配 embedding │ set 改参数"
603
+ statusLine = " /config 打开配置菜单"
604
604
  } else if (match?.name === "/provider" && cmd === "/provider") {
605
- statusLine = " /provider 列表 │ add / remove / key"
605
+ statusLine = " /provider 打开 Provider 管理菜单"
606
606
  } else if (match?.name === "/model" && cmd === "/model" && !sub) {
607
- statusLine = " /model 打开选择器 │ /model <名称> 直接切换"
607
+ statusLine = " /model 打开模型选择器"
608
608
  } else if (match?.name === "/think" && cmd === "/think") {
609
- 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 选择存档点回滚"
610
618
  } else if (cmds.length > 0) {
611
619
  if (cmds.length <= 4) {
612
620
  statusLine = ` ${cmds.map((c) => `${c.name} ${c.desc}`).join(" │ ")}`
@@ -784,6 +792,11 @@ export async function startTUI(agent, opts = {}) {
784
792
  state.tokens.cacheHit += usage.prompt_cache_hit_tokens ?? 0
785
793
  state.tokens.cacheMiss += usage.prompt_cache_miss_tokens ?? 0
786
794
  },
795
+ // 节流等待(主动闸门 / 429 退避):状态栏明示,防用户以为卡死
796
+ onWait: ({ phase, seconds }) => {
797
+ state.status = phase === "gate" ? `TPM 节流等待 ~${seconds}s` : `限流 429,${seconds}s 后重试`
798
+ render()
799
+ },
787
800
  onTaskUpdate: (items) => {
788
801
  state.tasks = items
789
802
  const done = items.filter((i) => i.status === "done").length
@@ -986,36 +999,35 @@ export async function startTUI(agent, opts = {}) {
986
999
  setTimeout(() => process.exit(0), 100) // 延迟一拍:fetch 后立刻 exit 在 Windows/Node 24 会触发 libuv 断言
987
1000
  return
988
1001
  case "/session": {
989
- const slotNum = Number(rest[0])
990
- if (rest.length > 0 && !isNaN(slotNum)) {
991
- // 切换到指定槽位
992
- const data = switchToSlot(agent.cwd, slotNum)
993
- if (!data) {
994
- pushLine(`槽位 ${slotNum} 不存在`, C.dim)
995
- } else {
996
- applySession(agent, data)
997
- state.lines = data.display.length
998
- ? data.display.map((l) => ({ text: l.text, color: l.color }))
999
- : []
1000
- state.tasks = agent.tasks ?? []
1001
- // 切换过来的会话如果任务全完成,自动收起面板
1002
- if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
1003
- state.tasks = []
1004
- }
1005
- pushLabel(`── 已切换到槽位 ${slotNum}(${data.history.length} 条消息)──`, C.warn)
1006
- render()
1007
- }
1002
+ const slots = listSlots(agent.cwd)
1003
+ if (slots.length === 0) {
1004
+ pushLine("没有归档会话(用 /new 后旧会话会自动归档)", C.dim)
1008
1005
  } else {
1009
- // 列出所有槽位
1010
- const slots = listSlots(agent.cwd)
1011
- if (slots.length === 0) {
1012
- pushLine("没有归档会话(用 /new 后旧会话会自动归档)", C.dim)
1013
- } else {
1014
- pushLabel(`归档会话(/session <n> 切换):`, ansi.bold + C.tool)
1015
- for (const s of slots) {
1016
- pushLine(` 槽位 ${s.slot} — ${s.date}`, C.text)
1017
- }
1018
- }
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
+ })
1019
1031
  }
1020
1032
  return
1021
1033
  }
@@ -1136,27 +1148,33 @@ export async function startTUI(agent, opts = {}) {
1136
1148
  pushLine("[rewind] 当前目录不是 git 仓库,无法使用存档点", C.error)
1137
1149
  return
1138
1150
  }
1139
- const id = rest[0]
1140
- if (!id) {
1141
- const cps = await listCheckpoints(agent.cwd)
1142
- pushLabel(`❯ Checkpoints`, ansi.bold + C.tool)
1143
- if (cps.length === 0) {
1144
- pushLine("(暂无存档点——每次提交任务前自动创建)", C.dim)
1145
- }
1146
- for (const cp of cps.slice(0, 10)) {
1147
- pushLine(` ${cp.id} ${new Date(cp.time).toLocaleString()} (+${cp.untracked} 个未跟踪文件)`, C.dim)
1148
- }
1149
- pushLine("回滚: /rewind <id>(恢复前会先存当前状态,回滚可逆)", C.dim)
1151
+ const cps = await listCheckpoints(agent.cwd)
1152
+ if (cps.length === 0) {
1153
+ pushLine("(暂无存档点——每次提交任务前自动创建)", C.dim)
1150
1154
  return
1151
1155
  }
1152
- try {
1153
- const summary = await rewind(agent.cwd, id)
1154
- pushLabel(`❯ Rewind`, ansi.bold + C.warn)
1155
- pushLine(`已回滚到 ${id}:补丁${summary.patchApplied ? "已应用" : ""},删除新建文件 ${summary.deleted} 个,还原文件 ${summary.restored} 个`, C.tool)
1156
- pushLine("(当前状态已先存为新存档点,可再次 /rewind 回到刚才)", C.dim)
1157
- } catch (error) {
1158
- pushLine(`[rewind] ${error.message}`, C.error)
1159
- }
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
+ })
1160
1178
  return
1161
1179
  }
1162
1180
  case "/plan": {
@@ -1177,37 +1195,46 @@ export async function startTUI(agent, opts = {}) {
1177
1195
  return
1178
1196
  }
1179
1197
  case "/goal": {
1180
- const sub = rest[0]
1181
- if (sub === "set") {
1182
- const text = rest.slice(1).join(" ")
1183
- if (!text) { pushLine("用法: /goal set <目标描述>(; 分隔完成条件,必须是可机器检查的验证手段)", C.error); return }
1184
- const semi = text.indexOf(";") >= 0 ? ";" : text.indexOf(";") >= 0 ? ";" : null
1185
- const objective = semi ? text.slice(0, semi).trim() : text.trim()
1186
- const criteria = semi ? text.slice(semi + 1).trim() : ""
1187
- agent.goal = { objective, criteria, setAt: Date.now(), status: "active", turnsUsed: 0, _blockTally: null }
1188
- pushLabel(`❯ Goal`, ansi.bold + C.warn)
1189
- pushLine(`目标已设置: ${objective}`, C.tool)
1190
- if (criteria) pushLine(` 完成条件: ${criteria}`, C.dim)
1191
- else pushLine(` ⚠ 未完成条件——agent 用 goal set 设立时会被要求补上可验证的完成条件`, C.warn)
1192
- return
1193
- }
1194
- if (sub === "cancel") {
1195
- agent.goal = null
1196
- pushLabel(`❯ Goal`, ansi.bold + C.dim)
1197
- pushLine(`目标已取消。`, C.dim)
1198
- return
1199
- }
1198
+ const entries = [
1199
+ { type: "header", text: agent.goal ? `当前目标: ${agent.goal.objective.slice(0, 60)}` : "操作" },
1200
+ { type: "item", text: "设置新目标", action: "set" },
1201
+ ]
1200
1202
  if (agent.goal) {
1201
- const statusText = { active: "进行中", complete: "已完成", blocked: "已阻塞" }[agent.goal.status] ?? agent.goal.status
1202
- pushLabel(`❯ Goal`, ansi.bold + C.warn)
1203
- pushLine(`目标: ${agent.goal.objective}`, C.tool)
1204
- if (agent.goal.criteria) pushLine(` 完成条件: ${agent.goal.criteria}`, C.dim)
1205
- pushLine(` 状态: ${statusText ?? "进行中"} │ 已用轮数: ${agent.goal.turnsUsed ?? 0} │ 设置于: ${new Date(agent.goal.setAt).toLocaleString()}`, C.dim)
1206
- pushLine("操作: /goal set <描述> 覆盖 | /goal cancel 取消", C.dim)
1207
- } else {
1208
- pushLabel(`❯ Goal`, ansi.bold + C.dim)
1209
- pushLine("(无活跃目标——/goal set <描述> 设置)", C.dim)
1210
- }
1203
+ entries.push({ type: "item", text: "取消目标", action: "cancel" })
1204
+ entries.push({ type: "item", text: "查看详情", action: "view" })
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
+ })
1211
1238
  return
1212
1239
  }
1213
1240
  case "/skills": {
@@ -1224,93 +1251,107 @@ export async function startTUI(agent, opts = {}) {
1224
1251
  return
1225
1252
  }
1226
1253
  case "/mcp": {
1227
- const sub = rest[0]
1228
- // ---- /mcp list — 列出配置的 servers + 连接状态 ----
1229
- if (!sub || sub === "list") {
1230
- const servers = agent.config?.mcp?.servers ?? []
1231
- pushLabel(`❯ MCP Servers`, ansi.bold + C.tool)
1232
- if (servers.length === 0) {
1233
- pushLine("(无 MCP server——使用 /mcp add <name> <url|command> 添加)", C.dim)
1234
- }
1235
- for (const srv of servers) {
1236
- const connected = agent.tools.some((t) => t._mcpName === srv.name)
1237
- const mark = connected ? "●" : "○"
1238
- const color = connected ? C.tool : C.dim
1239
- const toolCount = agent.tools.filter((t) => t._mcpName === srv.name).length
1240
- const desc = srv.wsUrl ? srv.wsUrl : srv.url ? srv.url : `${srv.command} ${(srv.args ?? []).join(" ")}`
1241
- pushLine(` ${mark} ${srv.name}: ${desc} (${toolCount} tools)`, color)
1242
- }
1243
- pushLabel(`❯ 操作`, ansi.bold + C.tool)
1244
- pushLine("/mcp add <name> <url|command> [args|headers...]", C.dim)
1245
- pushLine(" URL 自动识别: https://… → HTTP, ws://… → WebSocket, 其他 → stdio 命令", C.dim)
1246
- pushLine(" 例: /mcp add myapi https://api.example.com/mcp Authorization=\"Bearer x\"", C.dim)
1247
- pushLine(" 例: /mcp add github npx -y @modelcontextprotocol/server-github", C.dim)
1248
- pushLine(`/mcp remove <name> 断开并移除`, C.dim)
1249
- pushLine(`/mcp connect <name> 重连已配置的 server`, C.dim)
1250
- return
1251
- }
1252
- // ---- /mcp add <name> <url|command> [args|headers...] (统一入口,自动识别传输类型) ----
1253
- // url / ws 子命令作为别名保留(兼容旧配置)
1254
- if (sub === "add" || sub === "url" || sub === "ws") {
1255
- const args = rest.slice(1)
1256
- if (args.length < 2) {
1257
- pushLine("用法: /mcp add <name> <url|command> [args|headers...]", C.error)
1258
- pushLine(" URL 自动识别: https://… → HTTP, ws://… → WebSocket, 其他 → stdio 命令", C.dim)
1259
- return
1260
- }
1261
- const name = args[0]
1262
- const second = args[1]
1263
- const extras = args.slice(2)
1264
- const existing = (agent.config?.mcp?.servers ?? []).find((s) => s.name === name)
1265
- if (existing) { pushLine(`[mcp] "${name}" 已存在,用 /mcp remove ${name} 先移除`, C.error); return }
1266
-
1267
- const isWS = /^wss?:\/\//.test(second)
1268
- const isHTTP = /^https?:\/\//.test(second)
1269
- let srv
1270
- if (isWS || sub === "ws") {
1271
- const headers = parseHeaders(extras)
1272
- srv = { name, wsUrl: second, headers: Object.keys(headers).length > 0 ? headers : undefined }
1273
- } else if (isHTTP || sub === "url") {
1274
- const headers = parseHeaders(extras)
1275
- srv = { name, url: second, headers: Object.keys(headers).length > 0 ? headers : undefined }
1276
- } else {
1277
- srv = { name, command: second, args: extras.length > 0 ? extras : undefined }
1278
- }
1279
- await addAndConnect(srv)
1280
- return
1281
- }
1282
- // ---- /mcp remove <name> ----
1283
- if (sub === "remove") {
1284
- const name = rest[1]
1285
- if (!name) { pushLine("用法: /mcp remove <name>", C.error); return }
1286
- const { removeMcpTools } = await import("./mcp.mjs")
1287
- removeMcpTools(agent, name)
1288
- await persistRaw((raw) => { raw.mcp ??= { servers: [] }; raw.mcp.servers = raw.mcp.servers.filter((s) => s.name !== name) })
1289
- if (agent.config?.mcp?.servers) agent.config.mcp.servers = agent.config.mcp.servers.filter((s) => s.name !== name)
1290
- pushLabel(`❯ MCP`, ansi.bold + C.tool)
1291
- pushLine(`${name} 已断开并从配置移除。`, C.tool)
1292
- return
1293
- }
1294
- // ---- /mcp connect <name> — 重连 ----
1295
- if (sub === "connect") {
1296
- const name = rest[1]
1297
- if (!name) { pushLine("用法: /mcp connect <name>", C.error); return }
1298
- const srv = (agent.config?.mcp?.servers ?? []).find((s) => s.name === name)
1299
- if (!srv) { pushLine(`[mcp] "${name}" 未在配置中找到(先用 /mcp add 添加)`, C.error); return }
1300
- const { removeMcpTools, connectMcpServer } = await import("./mcp.mjs")
1301
- removeMcpTools(agent, name)
1302
- try {
1303
- pushLine(`[mcp] 重连 ${name}...`, C.dim)
1304
- const tools = await connectMcpServer(srv)
1305
- agent.tools.push(...tools)
1306
- pushLabel(`❯ MCP`, ansi.bold + C.tool)
1307
- pushLine(`${name} 已重连,${tools.length} 个工具可用。`, C.tool)
1308
- } catch (error) {
1309
- pushLine(`[mcp] ${name}: ${error.message}`, C.error)
1310
- }
1311
- 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
+ )
1312
1265
  }
1313
- 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
+ })
1314
1355
  return
1315
1356
  }
1316
1357
 
@@ -1367,235 +1408,235 @@ export async function startTUI(agent, opts = {}) {
1367
1408
  )
1368
1409
  return
1369
1410
  case "/think": {
1370
- const sub = rest[0]
1371
1411
  const cur = agent.provider
1372
1412
  const thinkingEnabled = cur.thinking?.type === "enabled" || cur.thinking?.type === undefined
1373
- // ---- /think(无参): 查看状态 ----
1374
- if (!sub) {
1375
- pushLabel(`❯ Think`, ansi.bold + C.tool)
1376
- pushLine(`思维模式: ${thinkingEnabled ? "🟢 开启" : "⚫ 关闭"}`, C.dim)
1377
- pushLine(`推理强度: ${cur.reasoningEffort ?? "(未设置)"}`, C.dim)
1378
- pushLine(`切换: /think on | off | effort high | effort max`, C.dim)
1379
- return
1380
- }
1381
- // ---- /think on / off ----
1382
- if (sub === "on" || sub === "off") {
1383
- const enable = sub === "on"
1384
- // 仅用 reasoning_effort 的模型(K3):不碰 thinking 字段,只设/删 reasoningEffort
1385
- const { specForModel } = await import("./config.mjs")
1386
- const spec = specForModel(cur.model)
1387
- if (spec.thinkApi === "effort") {
1388
- // 仅用 reasoning_effort 的模型(K3 / Qwen):不碰 thinking 字段
1389
- if (!enable) delete cur.reasoningEffort
1390
- else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
1391
- if (!enable) await syncProviderField("reasoningEffort", undefined)
1392
- else await syncProviderField("reasoningEffort", cur.reasoningEffort)
1393
- } else {
1394
- cur.thinking = enable ? { type: "enabled" } : { type: "disabled" }
1395
- if (!enable) delete cur.reasoningEffort
1396
- else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
1397
- await syncProviderField("thinking", cur.thinking)
1398
- if (!enable) await syncProviderField("reasoningEffort", undefined)
1399
- else await syncProviderField("reasoningEffort", cur.reasoningEffort)
1400
- }
1401
- pushLabel(`❯ Think`, ansi.bold + C.tool)
1402
- pushLine(`思维模式已${enable ? "开启" : "关闭"}`, C.tool)
1403
- if (enable) pushLine(`推理强度: ${cur.reasoningEffort}`, C.dim)
1404
- return
1405
- }
1406
- // ---- /think effort <level> ----
1407
- if (sub === "effort") {
1408
- const level = rest[1]
1409
- if (!level || !["low", "high", "max"].includes(level)) {
1410
- pushLine("用法: /think effort low | high | max", C.error)
1411
- return
1412
- }
1413
- cur.reasoningEffort = level
1414
- await syncProviderField("reasoningEffort", level)
1415
- pushLabel(`❯ Think`, ansi.bold + C.tool)
1416
- pushLine(`推理强度已设为 ${level}`, C.tool)
1417
- return
1418
- }
1419
- 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
+ })
1420
1460
  return
1421
1461
  }
1422
1462
  case "/model": {
1423
- const arg = rest[0]
1424
- if (!arg) {
1425
- // 打开交互选择器:全部 provider 的全部模型,方向键选择
1426
- pushLabel(`❯ Model`, ansi.bold + C.tool)
1427
- pushLine(`/model <名称> 直接切换 provider 或模型(如 /model deepseek-v4-pro)`, C.dim)
1428
- pushLine(`/provider 管理 provider(添加/删除/配 key)`, C.dim)
1429
- openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
1430
- return
1431
- }
1432
- if (arg === "add" || arg === "--add") {
1433
- pushLine(`添加 provider 已移到 /provider add(/provider 查看全部管理命令)`, C.warn)
1434
- return
1435
- }
1436
- // 一个参数两种含义:先按 provider 名匹配,匹配不到就当模型名改当前 provider
1437
- const { resolveCompactThreshold } = await import("./config.mjs")
1438
- const p = agent.providers.find((pp) => pp.name === arg)
1439
- const newModel = p ? p.model : arg
1440
- let thresholdNote = ""
1441
- if (agent.config?.agent?.compactThresholdAuto) {
1442
- const { value } = resolveCompactThreshold(null, newModel)
1443
- agent.config.agent.compactThreshold = value
1444
- thresholdNote = `,压缩阈值随模型调整为 ${value}`
1445
- }
1446
- if (p) {
1447
- agent.activeProvider = arg
1448
- agent.provider = { ...p }
1449
- // key 的环境变量兜底和 loadConfig 保持一致(提供商专用变量只对同名生效)
1450
- if (!agent.provider.apiKey) {
1451
- const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[arg]
1452
- if (envKey && process.env[envKey]) agent.provider.apiKey = process.env[envKey]
1453
- }
1454
- if (!agent.provider.apiKey) agent.provider.apiKey = process.env.THINCODER_API_KEY
1455
- await persistRaw((raw) => { raw.activeProvider = arg })
1456
- agent.config.activeProvider = arg
1457
- pushLabel(`❯ Model`, ansi.bold + C.tool)
1458
- pushLine(`已切换到 ${arg} / ${p.model}${thresholdNote}(已持久化)`, C.tool)
1459
- if (!agent.provider.apiKey) pushLine(`该 provider 还没配 key: /provider key <apikey>`, C.warn)
1460
- } else {
1461
- const target = agent.providers.find((pp) => pp.name === agent.activeProvider) ?? agent.providers[0]
1462
- if (target) target.model = arg
1463
- agent.provider.model = arg
1464
- await persistRaw((raw) => { raw.providers = agent.providers })
1465
- pushLabel(`❯ Model`, ansi.bold + C.tool)
1466
- pushLine(`已将 ${target?.name ?? agent.activeProvider} 的模型改为 ${arg}${thresholdNote}(已持久化)`, C.tool)
1467
- }
1463
+ openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
1468
1464
  return
1469
1465
  }
1470
1466
  case "/provider": {
1471
- const sub = rest[0]
1472
- // ---- /provider add <名称> <baseURL> <模型>,或 /provider add <预设> ----
1473
- if (sub === "add") {
1474
- const name = rest[1]
1475
- if (!name) {
1476
- pushLine(`用法: /provider add <名称> <baseURL> <模型>,或 /provider add <预设>(${Object.keys(PRESETS).join(", ")})`, C.error)
1477
- return
1478
- }
1479
- if (agent.providers.some((p) => p.name === name)) {
1480
- pushLine(`"${name}" 已存在;要重建可先 /provider remove ${name}`, C.warn)
1481
- return
1482
- }
1483
- const preset = PRESETS[name]
1484
- const baseURL = (rest[2] ?? preset?.baseURL)?.replace(/\/+$/, "")
1485
- const model = rest[3] ?? preset?.model
1486
- if (!baseURL || !model) {
1487
- pushLine(`缺少参数: /provider add ${name} <baseURL> <模型>`, C.error)
1488
- if (!preset) pushLine(`("${name}" 不是预设;预设: ${Object.keys(PRESETS).join(", ")})`, C.dim)
1489
- return
1490
- }
1491
- if (!/^https?:\/\//.test(baseURL)) { pushLine(`baseURL 应以 http(s):// 开头`, C.error); return }
1492
- agent.providers.push({ name, baseURL, model, ...(preset?.desc ? { desc: preset.desc } : {}) })
1493
- await persistRaw((raw) => { raw.providers = agent.providers })
1494
- pushLabel(`❯ Provider`, ansi.bold + C.tool)
1495
- pushLine(`已添加 ${name}(${baseURL} / ${model})`, C.tool)
1496
- pushLine(`下一步: /provider key ${name} <apikey> 配 key,/model ${name} 切换`, C.dim)
1497
- return
1498
- }
1499
- // ---- /provider remove <名称> ----
1500
- if (sub === "remove" || sub === "rm") {
1501
- const name = rest[1]
1502
- if (!name) { pushLine("用法: /provider remove <名称>", C.error); return }
1503
- const at = agent.providers.findIndex((p) => p.name === name)
1504
- if (at < 0) { pushLine(`未找到 provider "${name}"`, C.error); return }
1505
- if (name === agent.activeProvider) { pushLine(`"${name}" 正在使用中,先 /model 切换到别的 provider 再删`, C.warn); return }
1506
- agent.providers.splice(at, 1)
1507
- await persistRaw((raw) => { raw.providers = agent.providers })
1508
- pushLabel(`❯ Provider`, ansi.bold + C.tool)
1509
- pushLine(`已删除 ${name}`, C.tool)
1510
- return
1511
- }
1512
- // ---- /provider key [名称] <apikey> ----
1513
- if (sub === "key") {
1514
- let name = agent.activeProvider
1515
- let keyParts = rest.slice(1)
1516
- if (rest[1] && agent.providers.some((p) => p.name === rest[1])) {
1517
- name = rest[1]
1518
- keyParts = rest.slice(2)
1519
- }
1520
- const key = keyParts.join(" ")
1521
- if (!key) { pushLine("用法: /provider key [名称] <apikey>(不填名称配当前 provider)", C.error); return }
1522
- await setProviderKey(name, key)
1523
- return
1524
- }
1525
- if (sub) { pushLine(`未知: ${sub}(/provider add | remove | key)`, C.error); return }
1526
- // ---- /provider(无参): 列表 ----
1527
- pushLabel(`❯ Providers (${agent.providers.length})`, ansi.bold + C.tool)
1528
- for (const p of agent.providers) {
1529
- const active = p.name === agent.activeProvider
1530
- pushLine(
1531
- `${active ? " ▸" : " "} ${p.name.padEnd(12)} ${p.model.padEnd(20)} ${p.baseURL}${p.apiKey ? " ●key" : " ○无key"}${active ? " ← 当前" : ""}`,
1532
- 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" },
1533
1475
  )
1534
1476
  }
1535
- pushLabel(`❯ 操作`, ansi.bold + C.tool)
1536
- pushLine(`/provider add <名称|预设> <url> <模型> 添加(预设: ${Object.keys(PRESETS).join(" ")})`, C.dim)
1537
- pushLine(`/provider remove <名称> 删除`, C.dim)
1538
- if (!agent.provider.apiKey) pushLine(" /provider key <apikey> ← 当前 provider 还没配 key", C.warn)
1539
- 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
+ })
1540
1572
  return
1541
1573
  }
1542
1574
  case "/config": {
1543
- const sub = rest[0]
1544
- // ---- /config embedkey <apikey>:embedding 服务的 key ----
1545
- if (sub === "embedkey") {
1546
- const key = rest.slice(1).join(" ")
1547
- if (!key) { pushLine("用法: /config embedkey <apikey>(embedding 服务,默认 SiliconFlow bge-m3)", C.error); return }
1548
- agent.config.embedding ??= {}
1549
- agent.config.embedding.apiKey = key
1550
- await persistRaw((raw) => { raw.embedding = { ...(raw.embedding ?? {}), apiKey: key } })
1551
- if (agent.memory) {
1552
- const { createEmbedder } = await import("./embedding.mjs")
1553
- agent.memory.embedder = createEmbedder(agent.config.embedding)
1554
- }
1555
- pushLabel(`❯ Config`, ansi.bold + C.tool)
1556
- pushLine(`embedding key 已保存,向量检索已启用`, C.tool)
1557
- return
1558
- }
1559
- // ---- /config set <path> <value> (高级) ----
1560
- if (sub === "set") {
1561
- const [path, value] = [rest[1], rest.slice(2).join(" ")]
1562
- if (!path || !value) { pushLine("用法: /config set <path> <value> 如 /config set agent.maxTurns 80", C.error); return }
1563
- try {
1564
- const { configPath, loadConfig, saveConfig } = await import("./config.mjs")
1565
- const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
1566
- // 支持 a.b 形式的嵌套 key
1567
- const keys = path.split(".")
1568
- let obj = raw
1569
- for (let i = 0; i < keys.length - 1; i++) { obj[keys[i]] ??= {}; obj = obj[keys[i]] }
1570
- obj[keys[keys.length - 1]] = isNaN(value) ? value : Number(value)
1571
- saveConfig(raw)
1572
- const cfg = loadConfig()
1573
- agent.provider = cfg.provider
1574
- agent.providers = cfg.providersList
1575
- agent.activeProvider = cfg.activeProvider
1576
- agent.config = cfg
1577
- pushLabel(`❯ Config`, ansi.bold + C.tool)
1578
- pushLine(`已保存: ${path} = ${value}`, C.tool)
1579
- } catch (error) {
1580
- pushLine(`保存失败: ${error.message}`, C.error)
1581
- }
1582
- return
1583
- }
1584
- if (sub) { pushLine(`未知: ${sub}(可用: embedkey / set)`, C.error); return }
1585
- // ---- /config(无参): 查看 ----
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
- pushLabel(`❯ 管理`, ansi.bold + C.tool)
1595
- pushLine(`/provider 管理 provider(添加/删除/配 key)`, C.dim)
1596
- if (!agent.memory?.embedder) pushLine(`/config embedkey <k> 开启向量检索`, C.dim)
1597
- pushLine(`/config set <k> <v> 修改任意配置项`, C.dim)
1598
- 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
+ })
1599
1640
  return
1600
1641
  }
1601
1642
  case "/help": {
@@ -1703,9 +1744,22 @@ export async function startTUI(agent, opts = {}) {
1703
1744
 
1704
1745
  // ---------------------------------------------------------- 模型选择器(/model)
1705
1746
 
1706
- const pickerItems = () => state.picker.entries.filter((e) => e.type === "item")
1747
+ const pickerItems = () => state.picker?.entries.filter((e) => e.type === "item") ?? []
1707
1748
 
1708
- /** entries 重建显示行并刷新;高亮选中项、标注当前模型 */
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
+ }
1761
+
1762
+ /** 按 entries 重建显示行并刷新 */
1709
1763
  function renderPickerLines() {
1710
1764
  const p = state.picker
1711
1765
  if (!p) return
@@ -1714,13 +1768,13 @@ export async function startTUI(agent, opts = {}) {
1714
1768
  let selectedLine = 0
1715
1769
  for (const e of p.entries) {
1716
1770
  if (e.type === "header") {
1717
- 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 })
1718
1772
  } else {
1719
1773
  const selected = row === p.index
1720
1774
  if (selected) selectedLine = lines.length
1721
- const current = e.provider === agent.activeProvider && e.model === agent.provider.model
1775
+ const marker = e.marker ? ` ${e.marker}` : ""
1722
1776
  lines.push({
1723
- text: `${selected ? " ▸ " : " "}${e.model}${current ? " ← 当前" : ""}`,
1777
+ text: `${selected ? " ▸ " : " "}${e.text}${marker}`,
1724
1778
  color: selected ? ansi.bold + C.text : C.dim,
1725
1779
  })
1726
1780
  row++
@@ -1731,14 +1785,16 @@ export async function startTUI(agent, opts = {}) {
1731
1785
  render()
1732
1786
  }
1733
1787
 
1734
- /** 打开选择器:先列出各 provider 已配置的模型,再并发拉取各端点的全部模型展开进去 */
1788
+ // ========== 模型选择器(基于通用 picker,异步拉取远端模型列表) ==========
1789
+
1735
1790
  async function openModelPicker() {
1736
1791
  const entries = []
1737
1792
  for (const p of agent.providers) {
1738
- entries.push({ type: "header", name: p.name, note: `${p.baseURL}${p.apiKey ? "" : "(未配 key)"} 加载中...` })
1739
- 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 })
1740
1795
  }
1741
- 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 })
1742
1798
  // 默认选中当前在用的模型
1743
1799
  const current = pickerItems().findIndex(
1744
1800
  (e) => e.provider === agent.activeProvider && e.model === agent.provider.model,
@@ -1749,10 +1805,9 @@ export async function startTUI(agent, opts = {}) {
1749
1805
  const { listModels } = await import("./provider.mjs")
1750
1806
  await Promise.all(
1751
1807
  agent.providers.map(async (p) => {
1752
- 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)
1753
1809
  const noteBase = `${p.baseURL}${p.apiKey ? "" : "(未配 key)"}`
1754
1810
  try {
1755
- // key 的环境变量兜底和 loadConfig 保持一致(提供商专用变量只对同名生效)
1756
1811
  const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[p.name]
1757
1812
  let apiKey = p.apiKey
1758
1813
  if (!apiKey && envKey && process.env[envKey]) apiKey = process.env[envKey]
@@ -1761,27 +1816,21 @@ export async function startTUI(agent, opts = {}) {
1761
1816
  { baseURL: p.baseURL, apiKey: apiKey ?? "" },
1762
1817
  { signal: AbortSignal.timeout(10000) },
1763
1818
  )
1764
- // 展开到该 provider 已配置模型的后面(去重)
1765
1819
  const at = entries.findIndex((e) => e.type === "item" && e.provider === p.name && e.model === p.model)
1766
1820
  entries.splice(
1767
1821
  at + 1,
1768
1822
  0,
1769
- ...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 })),
1770
1824
  )
1771
- header.note = noteBase
1825
+ if (header) header.note = noteBase
1772
1826
  } catch (error) {
1773
- header.note = `${noteBase} (拉取失败: ${sliceByWidth(error.message, 60)})`
1827
+ if (header) header.note = `${noteBase} (拉取失败: ${sliceByWidth(error.message, 60)})`
1774
1828
  }
1775
- if (state.picker?.entries === entries) renderPickerLines() // 已关闭就不再刷新
1829
+ if (state.picker?.entries === entries) renderPickerLines()
1776
1830
  }),
1777
1831
  )
1778
1832
  }
1779
1833
 
1780
- function closeModelPicker() {
1781
- state.picker = null
1782
- render()
1783
- }
1784
-
1785
1834
  /** 给指定 provider 写 key(内存 + 配置文件);若它是当前激活的,同步运行时 */
1786
1835
  async function setProviderKey(name, key) {
1787
1836
  const target = agent.providers.find((p) => p.name === name)
@@ -1949,7 +1998,7 @@ export async function startTUI(agent, opts = {}) {
1949
1998
 
1950
1999
  /** 选中:切换 provider + 模型,持久化,阈值随模型走 */
1951
2000
  async function selectModel(item) {
1952
- closeModelPicker()
2001
+ closePicker()
1953
2002
  const target = agent.providers.find((pp) => pp.name === item.provider)
1954
2003
  if (!target) return
1955
2004
  target.model = item.model
@@ -2109,11 +2158,11 @@ export async function startTUI(agent, opts = {}) {
2109
2158
  setTimeout(() => process.exit(0), 100)
2110
2159
  }
2111
2160
 
2112
- // 模型选择器:↑↓ 移动,Enter 确认,Esc 取消,其余按键吞掉
2161
+ // 通用列表选择器:↑↓ 移动,Enter 确认,Esc 取消
2113
2162
  if (state.picker) {
2114
2163
  const items = pickerItems()
2115
2164
  if (key.name === "escape") {
2116
- closeModelPicker()
2165
+ closePicker()
2117
2166
  } else if (key.name === "up" && items.length) {
2118
2167
  state.picker.index = (state.picker.index - 1 + items.length) % items.length
2119
2168
  renderPickerLines()
@@ -2121,7 +2170,9 @@ export async function startTUI(agent, opts = {}) {
2121
2170
  state.picker.index = (state.picker.index + 1) % items.length
2122
2171
  renderPickerLines()
2123
2172
  } else if (key.name === "return" && items.length) {
2124
- 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()
2125
2176
  }
2126
2177
  return
2127
2178
  }
@@ -2238,7 +2289,7 @@ export async function startTUI(agent, opts = {}) {
2238
2289
  return
2239
2290
  }
2240
2291
  if (key.name === "return") {
2241
- submit()
2292
+ submit().catch((e) => pushLine(`[error] ${e.message}`, C.error))
2242
2293
  return
2243
2294
  }
2244
2295