thincoder 0.1.0 → 0.2.0

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
@@ -7,8 +7,10 @@
7
7
  import { emitKeypressEvents } from "node:readline"
8
8
  import { PassThrough } from "node:stream"
9
9
  import { basename } from "node:path"
10
+ import { existsSync, readFileSync } from "node:fs"
10
11
  import { runAgent } from "./agent.mjs"
11
12
  import { saveSession, clearSession } from "./session.mjs"
13
+ import { PROVIDER_PRESETS as PRESETS } from "./config.mjs"
12
14
 
13
15
  // ---------------------------------------------------------------- ANSI 工具
14
16
 
@@ -227,8 +229,11 @@ export async function startTUI(agent, opts = {}) {
227
229
  scroll: 0, // 从底部向上的滚动行数
228
230
  processing: false,
229
231
  permission: null, // { name, args, resolve }
232
+ picker: null, // 模型选择器 { entries, lines, index, scroll, selectedLine }
233
+ wizard: null, // 首次配置向导 { step, index, scroll, selectedLine, fields, error, lines }
230
234
  tasks: [], // task 工具的任务列表(状态栏显示进度)
231
235
  reasoning: "", // 思考流缓冲(暗色展示)
236
+ completion: null, // Tab 补全状态 { candidates, index }
232
237
  toolStream: "", // 当前工具的实时输出(暗色展示,bash 流式)
233
238
  currentTool: null, // 正在执行的工具名(状态栏显示)
234
239
  processingStarted: 0, // 本轮处理开始时间(状态栏计时)
@@ -325,6 +330,10 @@ export async function startTUI(agent, opts = {}) {
325
330
  const cols = process.stdout.columns || 80
326
331
  const rows = process.stdout.rows || 24
327
332
  const model = agent.provider.model
333
+ const thinking = agent.provider.thinking
334
+ const effort = agent.provider.reasoningEffort
335
+ const thinkBadge = thinking?.type === "disabled" ? "│ think: off"
336
+ : effort ? `│ think: ${effort}` : thinking?.type === "enabled" ? "│ think: on" : ""
328
337
 
329
338
  // 输入区:全边框盒,宽度 W(所有输出行严格 ≤ cols-1,防自动折行错位)
330
339
  const W = Math.max(20, cols - 1)
@@ -340,7 +349,12 @@ export async function startTUI(agent, opts = {}) {
340
349
 
341
350
  const headerH = 1
342
351
  const statusH = 1
343
- const convH = Math.max(1, rows - headerH - inputBoxH - statusH)
352
+ // 浮层(模型选择器 / 初始配置向导)打开时,在对话区下方预留一块(标题 + 列表窗口)
353
+ const overlay = state.picker ?? state.wizard
354
+ const pickerH = overlay
355
+ ? Math.min(overlay.lines.length + 1, Math.max(6, rows - 12))
356
+ : 0
357
+ const convH = Math.max(1, rows - headerH - inputBoxH - statusH - pickerH)
344
358
 
345
359
  // 对话区内容行(含流式缓冲);markdown 表格先按显示宽度重排
346
360
  const convLines = []
@@ -381,7 +395,7 @@ export async function startTUI(agent, opts = {}) {
381
395
 
382
396
  // header
383
397
  out.push(
384
- `${ansi.bold}${C.tool} ThinCoder ${ansi.reset}${ansi.dim}│ ${model} │ ${basename(agent.cwd)}${ansi.reset}${ansi.clearLine}`,
398
+ `${ansi.bold}${C.tool} ThinCoder ${ansi.reset}${ansi.dim}│ ${model}${thinkBadge ? " " + thinkBadge : ""} │ ${basename(agent.cwd)}${ansi.reset}${ansi.clearLine}`,
385
399
  )
386
400
 
387
401
  // 对话区(不足部分补空行,把输入框钉在底部)
@@ -391,13 +405,32 @@ export async function startTUI(agent, opts = {}) {
391
405
  out.push(`${l.color}${l.text}${ansi.reset}${ansi.clearLine}`)
392
406
  }
393
407
 
408
+ // 浮层(模型选择器 / 初始配置向导):列表滚动跟随选中行
409
+ if (overlay) {
410
+ const winH = pickerH - 1
411
+ if (overlay.selectedLine < overlay.scroll) overlay.scroll = overlay.selectedLine
412
+ if (overlay.selectedLine >= overlay.scroll + winH) overlay.scroll = overlay.selectedLine - winH + 1
413
+ const start = Math.max(0, Math.min(overlay.scroll, Math.max(0, overlay.lines.length - winH)))
414
+ const shown = overlay.lines.slice(start, start + winH)
415
+ const overlayTitle = state.picker ? " ❯ 选择模型 " : " ❯ 初始配置 "
416
+ out.push(`${ansi.bold}${C.tool}${overlayTitle}${ansi.reset}${ansi.dim}${state.picker ? "(↑↓ 移动, Enter 确认, Esc 取消)" : ""}${ansi.reset}${ansi.clearLine}`)
417
+ for (const l of shown) {
418
+ out.push(`${l.color}${sliceByWidth(l.text, cols - 1)}${ansi.reset}${ansi.clearLine}`)
419
+ }
420
+ for (let i = shown.length; i < winH; i++) out.push(ansi.clearLine)
421
+ }
422
+
394
423
  // 输入框(全边框,宽 W)
395
424
  const borderColor = state.permission ? C.warn : C.tool
396
425
  const title = state.permission
397
- ? ` Allow ${state.permission.name}? (y/n) `
398
- : state.processing
399
- ? " Processing... "
400
- : " Input "
426
+ ? ` Allow ${state.permission.name}? (y/n/a) `
427
+ : state.picker
428
+ ? " Model "
429
+ : state.wizard
430
+ ? " Setup "
431
+ : state.processing
432
+ ? " Processing... "
433
+ : " Input "
401
434
  const topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 3 - stringWidth(title)))}╮`
402
435
  out.push(`${borderColor}${topBorder}${ansi.reset}${ansi.clearLine}`)
403
436
  for (const l of inputLines) {
@@ -411,12 +444,35 @@ export async function startTUI(agent, opts = {}) {
411
444
  const scrollHint = state.scroll > 0 ? ` │ scrolled ${state.scroll}` : ""
412
445
  const rawInput = state.input.join("")
413
446
  let statusLine
414
- if (rawInput.startsWith("/") && !state.processing && !state.permission) {
415
- const prefix = rawInput.split(/\s/)[0]
416
- const matches = SLASH_COMMANDS.filter((c) => c.name.startsWith(prefix))
417
- statusLine = matches.length > 0
418
- ? ` ${matches.map((c) => `${c.name} ${c.desc}`).join(" │ ")}`
419
- : ` 未知命令(/help 查看可用命令)`
447
+ if (state.permission) {
448
+ statusLine = " y: 批准 │ n: 拒绝 │ a: 批准并全部放行(AUTO)"
449
+ } else if (state.picker) {
450
+ statusLine = " ↑↓: 选择 │ Enter: 确认 │ Esc: 取消"
451
+ } else if (state.wizard) {
452
+ statusLine = state.wizard.step === "provider"
453
+ ? " ↑↓: 选择 │ Enter: 确认 │ Esc: 跳过"
454
+ : " 输入后 Enter 确认 │ Esc: 取消"
455
+ } else if (rawInput.startsWith("/") && !state.processing && !state.permission) {
456
+ const [cmd, sub] = rawInput.split(/\s+/)
457
+ const cmds = SLASH_COMMANDS.filter((c) => c.name.startsWith(cmd))
458
+ const match = cmds.length === 1 ? cmds[0] : null
459
+ if (match?.name === "/config" && cmd === "/config") {
460
+ statusLine = " /config 查看 │ key / embedkey 配 key"
461
+ } else if (match?.name === "/model" && cmd === "/model" && !sub) {
462
+ statusLine = " /model 打开选择器 │ /model <名称> 直接切换"
463
+ } else if (match?.name === "/provider" && cmd === "/provider") {
464
+ statusLine = " /provider 列表 │ add / remove / key 管理"
465
+ } else if (match?.name === "/think" && cmd === "/think") {
466
+ statusLine = " /think 查看 │ on / off 开关 │ effort high / max 强度"
467
+ } else if (cmds.length > 0) {
468
+ if (cmds.length <= 4) {
469
+ statusLine = ` ${cmds.map((c) => `${c.name} ${c.desc}`).join(" │ ")}`
470
+ } else {
471
+ statusLine = ` ${cmds.map((c) => c.name).join(" ")} │ Tab 补全`
472
+ }
473
+ } else {
474
+ statusLine = ` 未知命令(/help 查看可用命令)`
475
+ }
420
476
  } else {
421
477
  const taskHint = state.tasks.length > 0
422
478
  ? ` │ ▶${state.tasks.filter((t) => t.status === "done").length}/${state.tasks.length}`
@@ -427,6 +483,8 @@ export async function startTUI(agent, opts = {}) {
427
483
  statusLine = ` ${statusText}${taskHint}${scrollHint} │ Enter: send │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+C: exit`
428
484
  }
429
485
  const autoBanner = agent.autoApprove ? `${C.warn} AUTO${ansi.reset}${ansi.dim}│` : ""
486
+ // 状态栏最多一行,超出终端宽度会被终端折行导致光标偏移
487
+ statusLine = sliceByWidth(statusLine, cols - 1)
430
488
  out.push(`${ansi.dim}${autoBanner}${statusLine}${ansi.reset}${ansi.clearLine}`)
431
489
 
432
490
  const frame = out.join("\r\n")
@@ -435,8 +493,8 @@ export async function startTUI(agent, opts = {}) {
435
493
  process.stdout.write(frame)
436
494
  }
437
495
 
438
- // 光标:输入态定位到输入框内(IME 候选框跟随真实光标);处理中/权限确认时隐藏
439
- if (state.processing || state.permission) {
496
+ // 光标:输入态定位到输入框内(IME 候选框跟随真实光标);处理中/权限确认/菜单态时隐藏
497
+ if (state.processing || state.permission || state.picker || state.wizard?.step === "provider") {
440
498
  process.stdout.write(ansi.hideCursor)
441
499
  } else {
442
500
  const cursorRow = 1 + convH + 2 + (layout.cursorLine - inputOffset) // header + 对话区 + 上边框 + 行偏移
@@ -565,6 +623,9 @@ export async function startTUI(agent, opts = {}) {
565
623
  pushLine(` [auto] ${name} ${summarize(args)}`, C.warn)
566
624
  return Promise.resolve(true)
567
625
  }
626
+ // 把关键参数摆出来:批什么要让人看明白
627
+ pushLabel(`❯ 权限请求`, ansi.bold + C.warn)
628
+ for (const line of formatPermission(name, args)) pushLine(` ${line}`, C.text)
568
629
  return new Promise((resolve) => {
569
630
  state.permission = { name, args, resolve }
570
631
  state.status = `Waiting: ${name}`
@@ -572,12 +633,25 @@ export async function startTUI(agent, opts = {}) {
572
633
  })
573
634
  }
574
635
 
636
+ /** 权限请求的关键信息(按工具定制),返回行数组 */
637
+ function formatPermission(name, args) {
638
+ const cap = (s, n = 1000) => (s.length > n ? `${s.slice(0, n)}…(共 ${s.length} 字符)` : s)
639
+ if (name === "bash") return cap(args.command ?? "").split("\n")
640
+ if (name === "write") return [`${args.path}(写入 ${(args.content ?? "").length} 字符)`]
641
+ if (name === "edit") return [`${args.path}(替换 ${(args.old_string ?? "").length} 字符 → ${(args.new_string ?? "").length} 字符)`]
642
+ if (name === "subagent") return cap(args.task ?? "", 500).split("\n")
643
+ if (name === "memory_put") return [`[${args.type ?? ""}] ${args.title ?? ""}`]
644
+ return [cap(summarize(args), 300)]
645
+ }
646
+
575
647
  // ---------------------------------------------------------- 斜杠命令
576
648
 
577
649
  const SLASH_COMMANDS = [
578
650
  { name: "/help", desc: "命令列表" },
579
- { name: "/model", desc: "查看/切换模型" },
580
- { name: "/config", desc: "查看当前配置" },
651
+ { name: "/model", desc: "模型选择器/切换" },
652
+ { name: "/provider", desc: "管理 provider(增/删/配 key)" },
653
+ { name: "/think", desc: "思维模式与推理强度" },
654
+ { name: "/config", desc: "查看配置、配 key" },
581
655
  { name: "/auto", desc: "自动授权开关" },
582
656
  { name: "/rewind", desc: "回滚到存档点" },
583
657
  { name: "/reindex", desc: "重建记忆索引" },
@@ -606,7 +680,7 @@ export async function startTUI(agent, opts = {}) {
606
680
  return
607
681
  case "/exit":
608
682
  cleanup()
609
- process.exit(0)
683
+ setTimeout(() => process.exit(0), 100) // 延迟一拍:fetch 后立刻 exit 在 Windows/Node 24 会触发 libuv 断言
610
684
  return
611
685
  case "/reindex": {
612
686
  const { syncDir } = await import("./memory.mjs")
@@ -668,48 +742,220 @@ export async function startTUI(agent, opts = {}) {
668
742
  agent.autoApprove ? C.warn : C.dim,
669
743
  )
670
744
  return
745
+ case "/think": {
746
+ const sub = rest[0]
747
+ const cur = agent.provider
748
+ const thinkingEnabled = cur.thinking?.type === "enabled" || cur.thinking?.type === undefined
749
+ // ---- /think(无参): 查看状态 ----
750
+ if (!sub) {
751
+ pushLabel(`❯ Think`, ansi.bold + C.tool)
752
+ pushLine(`思维模式: ${thinkingEnabled ? "🟢 开启" : "⚫ 关闭"}`, C.dim)
753
+ pushLine(`推理强度: ${cur.reasoningEffort ?? "(未设置)"}`, C.dim)
754
+ pushLine(`切换: /think on | off | effort high | effort max`, C.dim)
755
+ return
756
+ }
757
+ // ---- /think on / off ----
758
+ if (sub === "on" || sub === "off") {
759
+ const enable = sub === "on"
760
+ cur.thinking = enable ? { type: "enabled" } : { type: "disabled" }
761
+ if (!enable) delete cur.reasoningEffort
762
+ else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
763
+ await syncProviderField("thinking", cur.thinking)
764
+ if (!enable) await syncProviderField("reasoningEffort", undefined)
765
+ else await syncProviderField("reasoningEffort", cur.reasoningEffort)
766
+ pushLabel(`❯ Think`, ansi.bold + C.tool)
767
+ pushLine(`思维模式已${enable ? "开启" : "关闭"}`, C.tool)
768
+ if (enable) pushLine(`推理强度: ${cur.reasoningEffort}`, C.dim)
769
+ return
770
+ }
771
+ // ---- /think effort <level> ----
772
+ if (sub === "effort") {
773
+ const level = rest[1]
774
+ if (!level || !["low", "high", "max"].includes(level)) {
775
+ pushLine("用法: /think effort low | high | max", C.error)
776
+ return
777
+ }
778
+ cur.reasoningEffort = level
779
+ await syncProviderField("reasoningEffort", level)
780
+ pushLabel(`❯ Think`, ansi.bold + C.tool)
781
+ pushLine(`推理强度已设为 ${level}`, C.tool)
782
+ return
783
+ }
784
+ pushLine(`未知参数: ${sub}(可用: on / off / effort / effort high|max)`, C.error)
785
+ return
786
+ }
671
787
  case "/model": {
672
788
  const arg = rest[0]
673
789
  if (!arg) {
790
+ // 打开交互选择器:全部 provider 的全部模型,方向键选择
674
791
  pushLabel(`❯ Model`, ansi.bold + C.tool)
675
- pushLine(`model: ${agent.provider.model}`, C.dim)
676
- pushLine(`baseURL: ${agent.provider.baseURL}`, C.dim)
677
- pushLine(`切换: /model <名称>(仅本次会话;永久修改请编辑 ~/.thincoder/config.json)`, C.dim)
678
- // 拉取端点可用模型列表
679
- pushLine(`正在拉取可用模型...`, C.dim)
680
- try {
681
- const { listModels } = await import("./provider.mjs")
682
- const models = await listModels(agent.provider)
683
- pushLabel(`❯ Available (${models.length})`, ansi.bold + C.tool)
684
- for (const m of models) {
685
- pushLine(` ${m === agent.provider.model ? "▸ " : " "}${m}`, m === agent.provider.model ? C.tool : C.dim)
686
- }
687
- } catch (error) {
688
- pushLine(` (拉取失败: ${error.message})`, C.error)
792
+ pushLine(`/model <名称> 直接切换 provider 或模型(如 /model deepseek-v4-pro)`, C.dim)
793
+ pushLine(`/provider 管理 provider(添加/删除/配 key)`, C.dim)
794
+ openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
795
+ return
796
+ }
797
+ if (arg === "add" || arg === "--add") {
798
+ pushLine(`添加 provider 已移到 /provider add(/provider 查看全部管理命令)`, C.warn)
799
+ return
800
+ }
801
+ // 一个参数两种含义:先按 provider 名匹配,匹配不到就当模型名改当前 provider
802
+ const { resolveCompactThreshold } = await import("./config.mjs")
803
+ const p = agent.providers.find((pp) => pp.name === arg)
804
+ const newModel = p ? p.model : arg
805
+ let thresholdNote = ""
806
+ if (agent.config?.agent?.compactThresholdAuto) {
807
+ const { value } = resolveCompactThreshold(null, newModel)
808
+ agent.config.agent.compactThreshold = value
809
+ thresholdNote = `,压缩阈值随模型调整为 ${value}`
810
+ }
811
+ if (p) {
812
+ agent.activeProvider = arg
813
+ agent.provider = { ...p }
814
+ // key 的环境变量兜底和 loadConfig 保持一致
815
+ if (!agent.provider.apiKey) {
816
+ agent.provider.apiKey =
817
+ process.env.THINCODER_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY
689
818
  }
819
+ await persistRaw((raw) => { raw.activeProvider = arg })
820
+ agent.config.activeProvider = arg
821
+ pushLabel(`❯ Model`, ansi.bold + C.tool)
822
+ pushLine(`已切换到 ${arg} / ${p.model}${thresholdNote}(已持久化)`, C.tool)
823
+ if (!agent.provider.apiKey) pushLine(`该 provider 还没配 key: /config key <apikey>`, C.warn)
690
824
  } else {
825
+ const target = agent.providers.find((pp) => pp.name === agent.activeProvider) ?? agent.providers[0]
826
+ if (target) target.model = arg
691
827
  agent.provider.model = arg
692
- // 阈值是自动推导的则跟着新模型走;用户显式配置过的不动
693
- let thresholdNote = ""
694
- if (agent.config?.agent?.compactThresholdAuto) {
695
- const { resolveCompactThreshold } = await import("./config.mjs")
696
- const { value } = resolveCompactThreshold(null, arg)
697
- agent.config.agent.compactThreshold = value
698
- thresholdNote = `,压缩阈值随模型调整为 ${value}`
699
- }
828
+ await persistRaw((raw) => { raw.providers = agent.providers })
700
829
  pushLabel(`❯ Model`, ansi.bold + C.tool)
701
- pushLine(`已切换到 ${arg}(仅本次会话)${thresholdNote}`, C.tool)
830
+ pushLine(`已将 ${target?.name ?? agent.activeProvider} 的模型改为 ${arg}${thresholdNote}(已持久化)`, C.tool)
702
831
  }
703
832
  return
704
833
  }
834
+ case "/provider": {
835
+ const sub = rest[0]
836
+ // ---- /provider add <名称> <baseURL> <模型>,或 /provider add <预设> ----
837
+ if (sub === "add") {
838
+ const name = rest[1]
839
+ if (!name) {
840
+ pushLine(`用法: /provider add <名称> <baseURL> <模型>,或 /provider add <预设>(${Object.keys(PRESETS).join(", ")})`, C.error)
841
+ return
842
+ }
843
+ if (agent.providers.some((p) => p.name === name)) {
844
+ pushLine(`"${name}" 已存在;要重建可先 /provider remove ${name}`, C.warn)
845
+ return
846
+ }
847
+ const preset = PRESETS[name]
848
+ const baseURL = (rest[2] ?? preset?.baseURL)?.replace(/\/+$/, "")
849
+ const model = rest[3] ?? preset?.model
850
+ if (!baseURL || !model) {
851
+ pushLine(`缺少参数: /provider add ${name} <baseURL> <模型>`, C.error)
852
+ if (!preset) pushLine(`("${name}" 不是预设;预设: ${Object.keys(PRESETS).join(", ")})`, C.dim)
853
+ return
854
+ }
855
+ if (!/^https?:\/\//.test(baseURL)) {
856
+ pushLine(`baseURL 应以 http(s):// 开头`, C.error)
857
+ return
858
+ }
859
+ agent.providers.push({ name, baseURL, model, ...(preset?.desc ? { desc: preset.desc } : {}) })
860
+ await persistRaw((raw) => { raw.providers = agent.providers })
861
+ pushLabel(`❯ Provider`, ansi.bold + C.tool)
862
+ pushLine(`已添加 ${name}(${baseURL} / ${model})`, C.tool)
863
+ pushLine(`下一步: /provider key ${name} <apikey> 配 key,/model ${name} 切换`, C.dim)
864
+ return
865
+ }
866
+ // ---- /provider remove <名称> ----
867
+ if (sub === "remove" || sub === "rm") {
868
+ const name = rest[1]
869
+ if (!name) { pushLine("用法: /provider remove <名称>", C.error); return }
870
+ const at = agent.providers.findIndex((p) => p.name === name)
871
+ if (at < 0) { pushLine(`未找到 provider "${name}"`, C.error); return }
872
+ if (name === agent.activeProvider) {
873
+ pushLine(`"${name}" 正在使用中,先 /model 切换到别的 provider 再删`, C.warn)
874
+ return
875
+ }
876
+ agent.providers.splice(at, 1)
877
+ await persistRaw((raw) => { raw.providers = agent.providers })
878
+ pushLabel(`❯ Provider`, ansi.bold + C.tool)
879
+ pushLine(`已删除 ${name}`, C.tool)
880
+ return
881
+ }
882
+ // ---- /provider key [名称] <apikey>(不填名称配当前) ----
883
+ if (sub === "key") {
884
+ let name = agent.activeProvider
885
+ let keyParts = rest.slice(1)
886
+ if (rest[1] && agent.providers.some((p) => p.name === rest[1])) {
887
+ name = rest[1]
888
+ keyParts = rest.slice(2)
889
+ }
890
+ const key = keyParts.join(" ")
891
+ if (!key) { pushLine("用法: /provider key [名称] <apikey>(不填名称则配当前 provider)", C.error); return }
892
+ await setProviderKey(name, key)
893
+ return
894
+ }
895
+ if (sub) { pushLine(`未知参数: ${sub}(可用: add / remove / key)`, C.error); return }
896
+ // ---- /provider(无参): 列表 ----
897
+ pushLabel(`❯ Providers (${agent.providers.length})`, ansi.bold + C.tool)
898
+ for (const p of agent.providers) {
899
+ const active = p.name === agent.activeProvider
900
+ pushLine(
901
+ `${active ? " ▸" : " "} ${p.name.padEnd(12)} ${p.model.padEnd(20)} ${p.baseURL}${p.apiKey ? " ●key" : " ○无key"}${active ? " ← 当前" : ""}`,
902
+ active ? C.tool : C.dim,
903
+ )
904
+ }
905
+ pushLabel(`❯ 操作`, ansi.bold + C.tool)
906
+ pushLine(`/provider add <名称> <baseURL> <模型> 添加自定义(或 /provider add <预设>: ${Object.keys(PRESETS).join(" ")})`, C.dim)
907
+ pushLine(`/provider remove <名称> 删除(使用中的不可删)`, C.dim)
908
+ pushLine(`/provider key [名称] <apikey> 配 key(不填名称配当前)`, C.dim)
909
+ return
910
+ }
705
911
  case "/config": {
706
- pushLabel(`❯ Config`, ansi.bold + C.tool)
707
- pushLine(`provider: ${agent.provider.baseURL} | model: ${agent.provider.model}`, C.dim)
708
- pushLine(`apiKey: ${maskKey(agent.provider.apiKey)}`, C.dim)
912
+ const sub = rest[0]
913
+ // ---- /config key <apikey>:写入当前激活 provider ----
914
+ if (sub === "key") {
915
+ const key = rest.slice(1).join(" ")
916
+ if (!key) { pushLine("用法: /config key <apikey>(配当前 provider)", C.error); return }
917
+ const target = agent.providers.find((p) => p.name === agent.activeProvider) ?? agent.providers[0]
918
+ await setProviderKey(target?.name, key)
919
+ return
920
+ }
921
+ // ---- /config embedkey <apikey>:embedding 服务的 key(向量检索) ----
922
+ if (sub === "embedkey") {
923
+ const key = rest.slice(1).join(" ")
924
+ if (!key) { pushLine("用法: /config embedkey <apikey>(embedding 服务,默认 SiliconFlow bge-m3)", C.error); return }
925
+ agent.config.embedding ??= {}
926
+ agent.config.embedding.apiKey = key
927
+ await persistRaw((raw) => { raw.embedding = { ...(raw.embedding ?? {}), apiKey: key } })
928
+ if (agent.memory) {
929
+ const { createEmbedder } = await import("./embedding.mjs")
930
+ agent.memory.embedder = createEmbedder(agent.config.embedding)
931
+ }
932
+ pushLabel(`❯ Config`, ansi.bold + C.tool)
933
+ pushLine(`embedding key 已保存,向量检索已启用`, C.tool)
934
+ return
935
+ }
936
+ if (sub) { pushLine(`未知参数: ${sub}(/config 查看,/config key 配 key)`, C.error); return }
937
+ // ---- /config(无参): 查看 ----
938
+ const { configPath } = await import("./config.mjs")
939
+ pushLabel(`❯ 当前配置`, ansi.bold + C.tool)
940
+ pushLine(`激活: ${agent.activeProvider}`, C.dim)
941
+ pushLine(`模型: ${agent.provider.model}`, C.dim)
942
+ pushLine(`Key: ${maskKey(agent.provider.apiKey)}`, C.dim)
709
943
  const ac = agent.config?.agent ?? {}
710
- const thresholdNote = `${ac.compactThreshold ?? 100000}${ac.compactThresholdAuto ? " (auto,随模型)" : ""}`
711
- pushLine(`agent: maxTurns=${ac.maxTurns ?? 50} | compactThreshold=${thresholdNote}`, C.dim)
712
- pushLine(`memory: ${agent.memory ? "enabled" : "disabled"}${agent.memory?.embedder ? " + vector" : " (FTS only)"}`, C.dim)
944
+ const tn = `${ac.compactThreshold ?? 100000}${ac.compactThresholdAuto ? " (auto)" : ""}`
945
+ pushLine(`agent: maxTurns=${ac.maxTurns ?? 50} | compactThreshold=${tn}`, C.dim)
946
+ pushLine(`embedding: ${agent.memory?.embedder ? `enabled (${agent.config?.embedding?.model ?? ""})` : "disabled(纯 FTS 检索)"}`, C.dim)
947
+ pushLabel(`❯ 所有 providers (${agent.providers.length})`, ansi.bold + C.tool)
948
+ for (const p of agent.providers) {
949
+ const active = p.name === agent.activeProvider
950
+ pushLine(`${active ? " ▸" : " "} ${p.name.padEnd(10)} ${p.model.padEnd(20)} ${p.baseURL}${p.apiKey ? " ●" : " ○"}${active ? " ← 当前" : ""}`, active ? C.tool : C.dim)
951
+ }
952
+ pushLabel(`❯ 操作`, ansi.bold + C.tool)
953
+ pushLine(`/model 方向键选择模型(全部 provider 的全部模型)`, C.dim)
954
+ pushLine(`/provider 管理 provider(添加/删除/配 key)`, C.dim)
955
+ if (!agent.provider.apiKey) pushLine("⚡ /config key <apikey> ← 当前 provider 还没配 key", C.warn)
956
+ else pushLine(`/config key <apikey> 更换当前 provider 的 key`, C.dim)
957
+ if (!agent.memory?.embedder) pushLine(`/config embedkey <key> 开启向量检索(SiliconFlow)`, C.dim)
958
+ pushLine(`配置文件: ${configPath}(自定义 provider 可直接编辑)`, C.dim)
713
959
  return
714
960
  }
715
961
  case "/help": {
@@ -729,6 +975,343 @@ export async function startTUI(agent, opts = {}) {
729
975
  return `${key.slice(0, 5)}…${key.slice(-4)}`
730
976
  }
731
977
 
978
+ /** Tab 补全候选:命令名 / 子命令 / provider 名 / 预设名 / think 参数 */
979
+ function completions(input) {
980
+ if (!input.startsWith("/")) return []
981
+ const parts = input.split(/\s+/)
982
+ // 还在敲第一个 token:补命令名
983
+ if (parts.length === 1) {
984
+ return SLASH_COMMANDS.filter((c) => c.name.startsWith(parts[0])).map((c) => c.name)
985
+ }
986
+ const cmd = parts[0]
987
+ const last = parts.at(-1) // 结尾是空格时为 "",即列出全部候选
988
+ const head = parts.slice(0, -1).join(" ")
989
+ const argIndex = parts.length - 2 // 正在敲第几个参数(0 基)
990
+ const match = (cands) => cands.filter((c) => c.startsWith(last)).map((c) => `${head} ${c}`)
991
+ if (cmd === "/model" && argIndex === 0) return match(agent.providers.map((p) => p.name))
992
+ if (cmd === "/provider") {
993
+ if (argIndex === 0) return match(["add", "remove", "key"])
994
+ if (argIndex === 1 && parts[1] === "add") return match(Object.keys(PRESETS))
995
+ if (argIndex === 1 && (parts[1] === "remove" || parts[1] === "key")) return match(agent.providers.map((p) => p.name))
996
+ }
997
+ if (cmd === "/think") {
998
+ if (argIndex === 0) return match(["on", "off", "effort"])
999
+ if (argIndex === 1 && parts[1] === "effort") return match(["low", "high", "max"])
1000
+ }
1001
+ if (cmd === "/config" && argIndex === 0) return match(["key", "embedkey"])
1002
+ return []
1003
+ }
1004
+
1005
+ /** Tab:计算候选并循环替换输入 */
1006
+ function handleTab() {
1007
+ const input = state.input.join("")
1008
+ if (state.completion && input === state.completion.candidates[state.completion.index]) {
1009
+ // 上一次的候选还在输入框:循环到下一个
1010
+ state.completion.index = (state.completion.index + 1) % state.completion.candidates.length
1011
+ } else {
1012
+ const candidates = completions(input)
1013
+ if (candidates.length === 0) return
1014
+ state.completion = { candidates, index: 0 }
1015
+ }
1016
+ const text = state.completion.candidates[state.completion.index]
1017
+ state.input = [...text]
1018
+ state.cursor = state.input.length
1019
+ render()
1020
+ }
1021
+
1022
+ /** 读配置文件 → 修改 → 写回;文件不存在时从空对象开始 */
1023
+ async function persistRaw(mutate) {
1024
+ const { saveConfig, configPath } = await import("./config.mjs")
1025
+ const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
1026
+ mutate(raw)
1027
+ saveConfig(raw)
1028
+ }
1029
+
1030
+ /** 把当前激活 provider 的某个字段同步到 providers 列表并持久化 */
1031
+ async function syncProviderField(field, value) {
1032
+ const target = agent.providers.find((p) => p.name === agent.activeProvider)
1033
+ if (!target) return
1034
+ if (value === undefined) delete target[field]
1035
+ else target[field] = value
1036
+ // 全量写回:raw 里的 providers 顺序/内容可能与运行时列表不一致,逐字段改容易写错位
1037
+ await persistRaw((raw) => {
1038
+ raw.providers = agent.providers
1039
+ })
1040
+ }
1041
+
1042
+ // ---------------------------------------------------------- 模型选择器(/model)
1043
+
1044
+ const pickerItems = () => state.picker.entries.filter((e) => e.type === "item")
1045
+
1046
+ /** 按 entries 重建显示行并刷新;高亮选中项、标注当前模型 */
1047
+ function renderPickerLines() {
1048
+ const p = state.picker
1049
+ if (!p) return
1050
+ const lines = []
1051
+ let row = 0
1052
+ let selectedLine = 0
1053
+ for (const e of p.entries) {
1054
+ if (e.type === "header") {
1055
+ lines.push({ text: ` ${e.name}${e.note ? ` ${e.note}` : ""}`, color: ansi.bold + C.tool })
1056
+ } else {
1057
+ const selected = row === p.index
1058
+ if (selected) selectedLine = lines.length
1059
+ const current = e.provider === agent.activeProvider && e.model === agent.provider.model
1060
+ lines.push({
1061
+ text: `${selected ? " ▸ " : " "}${e.model}${current ? " ← 当前" : ""}`,
1062
+ color: selected ? ansi.bold + C.text : C.dim,
1063
+ })
1064
+ row++
1065
+ }
1066
+ }
1067
+ p.lines = lines
1068
+ p.selectedLine = selectedLine
1069
+ render()
1070
+ }
1071
+
1072
+ /** 打开选择器:先列出各 provider 已配置的模型,再并发拉取各端点的全部模型展开进去 */
1073
+ async function openModelPicker() {
1074
+ const entries = []
1075
+ for (const p of agent.providers) {
1076
+ entries.push({ type: "header", name: p.name, note: `${p.baseURL}${p.apiKey ? "" : "(未配 key)"} 加载中...` })
1077
+ entries.push({ type: "item", provider: p.name, model: p.model })
1078
+ }
1079
+ state.picker = { entries, lines: [], index: 0, scroll: 0, selectedLine: 0 }
1080
+ // 默认选中当前在用的模型
1081
+ const current = pickerItems().findIndex(
1082
+ (e) => e.provider === agent.activeProvider && e.model === agent.provider.model,
1083
+ )
1084
+ if (current >= 0) state.picker.index = current
1085
+ renderPickerLines()
1086
+
1087
+ const { listModels } = await import("./provider.mjs")
1088
+ await Promise.all(
1089
+ agent.providers.map(async (p) => {
1090
+ const header = entries.find((e) => e.type === "header" && e.name === p.name)
1091
+ const noteBase = `${p.baseURL}${p.apiKey ? "" : "(未配 key)"}`
1092
+ try {
1093
+ // key 的环境变量兜底和 loadConfig 保持一致
1094
+ const apiKey =
1095
+ p.apiKey || process.env.THINCODER_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY
1096
+ const models = await listModels(
1097
+ { baseURL: p.baseURL, apiKey: apiKey ?? "" },
1098
+ { signal: AbortSignal.timeout(10000) },
1099
+ )
1100
+ // 展开到该 provider 已配置模型的后面(去重)
1101
+ const at = entries.findIndex((e) => e.type === "item" && e.provider === p.name && e.model === p.model)
1102
+ entries.splice(
1103
+ at + 1,
1104
+ 0,
1105
+ ...models.filter((m) => m !== p.model).map((m) => ({ type: "item", provider: p.name, model: m })),
1106
+ )
1107
+ header.note = noteBase
1108
+ } catch (error) {
1109
+ header.note = `${noteBase} (拉取失败: ${sliceByWidth(error.message, 60)})`
1110
+ }
1111
+ if (state.picker?.entries === entries) renderPickerLines() // 已关闭就不再刷新
1112
+ }),
1113
+ )
1114
+ }
1115
+
1116
+ function closeModelPicker() {
1117
+ state.picker = null
1118
+ render()
1119
+ }
1120
+
1121
+ /** 给指定 provider 写 key(内存 + 配置文件);若它是当前激活的,同步运行时 */
1122
+ async function setProviderKey(name, key) {
1123
+ const target = agent.providers.find((p) => p.name === name)
1124
+ if (!target) {
1125
+ pushLine(`未找到 provider "${name}"`, C.error)
1126
+ return
1127
+ }
1128
+ target.apiKey = key
1129
+ if (name === agent.activeProvider) agent.provider.apiKey = key
1130
+ await persistRaw((raw) => { raw.providers = agent.providers })
1131
+ pushLabel(`❯ Provider`, ansi.bold + C.tool)
1132
+ pushLine(`apiKey 已保存到 ${name}`, C.tool)
1133
+ }
1134
+
1135
+ // ---------------------------------------------------------- 初始配置向导(首次启动)
1136
+
1137
+ /** 菜单步的候选项:已有 provider(未配 key 的标注)+ 未添加的预设 + 自定义 */
1138
+ function wizardProviderItems() {
1139
+ const items = []
1140
+ for (const p of agent.providers) {
1141
+ items.push({ kind: "existing", name: p.name, baseURL: p.baseURL, model: p.model, label: `${p.name}(已添加${p.apiKey ? "" : ",未配 key"})` })
1142
+ }
1143
+ for (const [name, p] of Object.entries(PRESETS)) {
1144
+ if (!agent.providers.some((x) => x.name === name)) {
1145
+ items.push({ kind: "preset", name, baseURL: p.baseURL, model: p.model, label: `${name}(${p.desc})` })
1146
+ }
1147
+ }
1148
+ items.push({ kind: "custom", name: null, label: "自定义端点…" })
1149
+ return items
1150
+ }
1151
+
1152
+ /** 文本步骤定义:提示语 + 校验(通过返回 true,否则返回错误文案) */
1153
+ const WIZARD_STEPS = {
1154
+ name: {
1155
+ prompt: "给这个 provider 起个名字(字母/数字/-/_,如 my-openai)",
1156
+ validate: (v) =>
1157
+ (/^[\w-]+$/.test(v) && !agent.providers.some((p) => p.name === v)) || "名字需为字母/数字/-/_,且不与已有 provider 重名",
1158
+ },
1159
+ baseURL: {
1160
+ prompt: "输入 baseURL(如 https://api.openai.com/v1)",
1161
+ validate: (v) => /^https?:\/\/.+/.test(v) || "baseURL 应以 http(s):// 开头",
1162
+ },
1163
+ model: {
1164
+ prompt: "输入模型名(如 gpt-4o)",
1165
+ validate: (v) => v.length > 0 || "模型名不能为空",
1166
+ },
1167
+ key: {
1168
+ prompt: "输入 API key",
1169
+ validate: (v) => v.length > 0 || "key 不能为空",
1170
+ },
1171
+ embedkey: {
1172
+ prompt: "可选:embedding API key(SiliconFlow,记忆向量检索用;直接回车跳过)",
1173
+ validate: () => true, // 可跳过
1174
+ },
1175
+ }
1176
+ const WIZARD_NEXT = { name: "baseURL", baseURL: "model", model: "key", key: "embedkey", embedkey: null }
1177
+
1178
+ function startWizard() {
1179
+ state.wizard = { step: "provider", index: 0, scroll: 0, selectedLine: 0, fields: {}, error: null, lines: [] }
1180
+ renderWizard()
1181
+ }
1182
+
1183
+ function renderWizard() {
1184
+ const w = state.wizard
1185
+ if (!w) return
1186
+ const lines = []
1187
+ if (w.step === "provider") {
1188
+ lines.push({ text: " 选择一个模型提供商:", color: C.text })
1189
+ wizardProviderItems().forEach((it, i) => {
1190
+ if (i === w.index) w.selectedLine = lines.length
1191
+ lines.push({
1192
+ text: `${i === w.index ? " ▸ " : " "}${it.label}`,
1193
+ color: i === w.index ? ansi.bold + C.text : C.dim,
1194
+ })
1195
+ })
1196
+ } else {
1197
+ const f = w.fields
1198
+ if (f.name) lines.push({ text: ` 提供商: ${f.name}`, color: C.dim })
1199
+ if (f.baseURL) lines.push({ text: ` baseURL: ${f.baseURL}`, color: C.dim })
1200
+ if (f.model) lines.push({ text: ` 模型: ${f.model}`, color: C.dim })
1201
+ lines.push({ text: ` ❯ ${WIZARD_STEPS[w.step].prompt}`, color: ansi.bold + C.text })
1202
+ lines.push({ text: " (在下方输入框输入)", color: C.dim })
1203
+ w.selectedLine = 0
1204
+ }
1205
+ if (w.error) lines.push({ text: ` ${w.error}`, color: C.error })
1206
+ w.lines = lines
1207
+ render()
1208
+ }
1209
+
1210
+ function wizardChooseProvider(item) {
1211
+ const w = state.wizard
1212
+ if (item.kind === "custom") {
1213
+ w.step = "name"
1214
+ } else {
1215
+ w.fields = { name: item.name, baseURL: item.baseURL, model: item.model }
1216
+ w.step = "key"
1217
+ }
1218
+ renderWizard()
1219
+ }
1220
+
1221
+ function wizardSubmitText() {
1222
+ const w = state.wizard
1223
+ const value = state.input.join("").trim()
1224
+ const ok = WIZARD_STEPS[w.step].validate(value)
1225
+ if (ok !== true) {
1226
+ w.error = ok
1227
+ renderWizard()
1228
+ return
1229
+ }
1230
+ w.error = null
1231
+ state.input = []
1232
+ state.cursor = 0
1233
+ w.fields[w.step === "key" ? "key" : w.step] = w.step === "baseURL" ? value.replace(/\/+$/, "") : value
1234
+ const next = WIZARD_NEXT[w.step]
1235
+ if (next) {
1236
+ w.step = next
1237
+ renderWizard()
1238
+ } else {
1239
+ finishWizard().catch((e) => pushLine(`[error] ${e.message}`, C.error))
1240
+ }
1241
+ }
1242
+
1243
+ function cancelWizard() {
1244
+ state.wizard = null
1245
+ pushLine("已跳过初始配置。之后随时可用 /provider add 添加提供商、/provider key 配 key。", C.dim)
1246
+ render()
1247
+ }
1248
+
1249
+ /** 向导完成:写入 provider(有则更新)、设为激活、持久化,然后接模型选择器 */
1250
+ async function finishWizard() {
1251
+ const f = state.wizard.fields
1252
+ state.wizard = null
1253
+ const existing = agent.providers.find((p) => p.name === f.name)
1254
+ if (existing) Object.assign(existing, { baseURL: f.baseURL, model: f.model, apiKey: f.key })
1255
+ else agent.providers.push({ name: f.name, baseURL: f.baseURL, model: f.model, apiKey: f.key })
1256
+ agent.activeProvider = f.name
1257
+ agent.provider = { ...agent.providers.find((p) => p.name === f.name) }
1258
+ if (agent.config?.agent?.compactThresholdAuto) {
1259
+ const { resolveCompactThreshold } = await import("./config.mjs")
1260
+ agent.config.agent.compactThreshold = resolveCompactThreshold(null, f.model).value
1261
+ }
1262
+ await persistRaw((raw) => {
1263
+ raw.providers = agent.providers
1264
+ raw.activeProvider = f.name
1265
+ })
1266
+ agent.config.activeProvider = f.name
1267
+ pushLabel(`❯ Setup`, ansi.bold + C.tool)
1268
+ pushLine(`配置完成:${f.name} / ${f.model}(已写入配置文件)`, C.tool)
1269
+ // embedding key:配了就启用向量检索,没配提示事后通道
1270
+ if (f.embedkey) {
1271
+ agent.config.embedding ??= {}
1272
+ agent.config.embedding.apiKey = f.embedkey
1273
+ await persistRaw((raw) => { raw.embedding = { ...(raw.embedding ?? {}), apiKey: f.embedkey } })
1274
+ if (agent.memory && !agent.memory.embedder) {
1275
+ const { createEmbedder } = await import("./embedding.mjs")
1276
+ agent.memory.embedder = createEmbedder(agent.config.embedding)
1277
+ }
1278
+ pushLine(`向量检索已启用(${agent.config.embedding.model ?? "BAAI/bge-m3"})`, C.tool)
1279
+ } else {
1280
+ pushLine(`向量检索未启用(记忆退化为纯文本检索);之后可 /config embedkey <key> 开启`, C.dim)
1281
+ }
1282
+ pushLine(`选择要用的模型(Esc 保持 ${f.model})`, C.dim)
1283
+ openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
1284
+ }
1285
+
1286
+ /** 选中:切换 provider + 模型,持久化,阈值随模型走 */
1287
+ async function selectModel(item) {
1288
+ closeModelPicker()
1289
+ const target = agent.providers.find((pp) => pp.name === item.provider)
1290
+ if (!target) return
1291
+ target.model = item.model
1292
+ agent.activeProvider = item.provider
1293
+ agent.provider = { ...target }
1294
+ if (!agent.provider.apiKey) {
1295
+ agent.provider.apiKey =
1296
+ process.env.THINCODER_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY
1297
+ }
1298
+ let thresholdNote = ""
1299
+ if (agent.config?.agent?.compactThresholdAuto) {
1300
+ const { resolveCompactThreshold } = await import("./config.mjs")
1301
+ const { value } = resolveCompactThreshold(null, item.model)
1302
+ agent.config.agent.compactThreshold = value
1303
+ thresholdNote = `,压缩阈值随模型调整为 ${value}`
1304
+ }
1305
+ await persistRaw((raw) => {
1306
+ raw.providers = agent.providers
1307
+ raw.activeProvider = item.provider
1308
+ })
1309
+ agent.config.activeProvider = item.provider
1310
+ pushLabel(`❯ Model`, ansi.bold + C.tool)
1311
+ pushLine(`已切换到 ${item.provider} / ${item.model}${thresholdNote}(已持久化)`, C.tool)
1312
+ if (!agent.provider.apiKey) pushLine(`该 provider 还没配 key: /config key <apikey>`, C.warn)
1313
+ }
1314
+
732
1315
  /** /distill:从当前会话提取候选,逐条 y/n 确认后入库 */
733
1316
  async function runDistill() {
734
1317
  if (agent.history.length === 0) {
@@ -774,14 +1357,18 @@ export async function startTUI(agent, opts = {}) {
774
1357
 
775
1358
  // keypress 挂在过滤后的 keyStream 上:鼠标序列已在上游滤网中处理并剥除
776
1359
  keyStream.on("keypress", (str, key = {}) => {
777
- // 权限确认态:只认 y/n
1360
+ // 权限确认态:y 批准 / n 拒绝 / a 批准并开启 AUTO(后续不再询问)
778
1361
  if (state.permission) {
779
1362
  const answer = (str || "").toLowerCase()
780
- if (answer === "y" || answer === "n" || key.name === "escape") {
1363
+ if (answer === "y" || answer === "n" || answer === "a" || key.name === "escape") {
781
1364
  const { resolve } = state.permission
782
1365
  state.permission = null
783
1366
  state.status = "Processing..."
784
- resolve(answer === "y")
1367
+ if (answer === "a") {
1368
+ agent.autoApprove = true
1369
+ pushLine(` [auto] AUTO 已开启:后续工具调用不再询问(/auto 关闭)`, C.warn)
1370
+ }
1371
+ resolve(answer === "y" || answer === "a")
785
1372
  render()
786
1373
  }
787
1374
  return
@@ -789,7 +1376,52 @@ export async function startTUI(agent, opts = {}) {
789
1376
 
790
1377
  if (key.ctrl && key.name === "c") {
791
1378
  cleanup()
792
- process.exit(0)
1379
+ setTimeout(() => process.exit(0), 100) // 同 /exit:延迟退出避开 libuv 断言
1380
+ }
1381
+
1382
+ // 模型选择器:↑↓ 移动,Enter 确认,Esc 取消,其余按键吞掉
1383
+ if (state.picker) {
1384
+ const items = pickerItems()
1385
+ if (key.name === "escape") {
1386
+ closeModelPicker()
1387
+ } else if (key.name === "up" && items.length) {
1388
+ state.picker.index = (state.picker.index - 1 + items.length) % items.length
1389
+ renderPickerLines()
1390
+ } else if (key.name === "down" && items.length) {
1391
+ state.picker.index = (state.picker.index + 1) % items.length
1392
+ renderPickerLines()
1393
+ } else if (key.name === "return" && items.length) {
1394
+ selectModel(items[state.picker.index]).catch((e) => pushLine(`[error] ${e.message}`, C.error))
1395
+ }
1396
+ return
1397
+ }
1398
+
1399
+ // 初始配置向导:菜单步 ↑↓/Enter/Esc;文本步 Enter 提交、Esc 取消,编辑键落到正常输入
1400
+ if (state.wizard) {
1401
+ const w = state.wizard
1402
+ if (key.name === "escape") {
1403
+ cancelWizard()
1404
+ return
1405
+ }
1406
+ if (w.step === "provider") {
1407
+ const items = wizardProviderItems()
1408
+ if (key.name === "up" && items.length) {
1409
+ w.index = (w.index - 1 + items.length) % items.length
1410
+ renderWizard()
1411
+ } else if (key.name === "down" && items.length) {
1412
+ w.index = (w.index + 1) % items.length
1413
+ renderWizard()
1414
+ } else if (key.name === "return" && items.length) {
1415
+ wizardChooseProvider(items[w.index])
1416
+ }
1417
+ return
1418
+ }
1419
+ if (key.name === "return") {
1420
+ wizardSubmitText()
1421
+ return
1422
+ }
1423
+ // 文本步骤屏蔽翻页/历史,其余编辑键放行到下面的普通输入逻辑
1424
+ if (key.name === "up" || key.name === "down" || key.name === "pageup" || key.name === "pagedown") return
793
1425
  }
794
1426
 
795
1427
  // 翻页
@@ -806,6 +1438,12 @@ export async function startTUI(agent, opts = {}) {
806
1438
 
807
1439
  if (state.processing) return // 处理中锁定输入
808
1440
 
1441
+ // Tab:斜杠命令补全(循环候选);其余输入忽略(\t 会顶破输入框,永不直接插入)
1442
+ if (key.name === "tab") {
1443
+ handleTab()
1444
+ return
1445
+ }
1446
+
809
1447
  // 输入历史
810
1448
  if (key.name === "up") {
811
1449
  if (state.history.length) {
@@ -874,9 +1512,9 @@ export async function startTUI(agent, opts = {}) {
874
1512
  return
875
1513
  }
876
1514
 
877
- // 可打印字符 / 粘贴(str 可能一次多个字符)
1515
+ // 可打印字符 / 粘贴(str 可能一次多个字符);Tab 一律转成两个空格(\t 显示宽度不定,会顶破输入框)
878
1516
  if (str && !key.ctrl && !key.meta) {
879
- const chars = [...str.replace(/\r/g, "")]
1517
+ const chars = [...str.replace(/\r/g, "").replace(/\t/g, " ")]
880
1518
  state.input.splice(state.cursor, 0, ...chars)
881
1519
  state.cursor += chars.length
882
1520
  render()
@@ -884,7 +1522,13 @@ export async function startTUI(agent, opts = {}) {
884
1522
  })
885
1523
 
886
1524
  // 启动画面
887
- pushLine(`Welcome to ThinCoder. Model: ${agent.provider.model}`, C.dim)
1525
+ if (!agent.provider.apiKey) {
1526
+ pushLabel(`欢迎使用 ThinCoder!`, ansi.bold + C.tool)
1527
+ pushLine("检测到还没配置 API key,进入初始配置(Esc 可随时跳过)", C.text)
1528
+ startWizard()
1529
+ } else {
1530
+ pushLine(`Welcome to ThinCoder. Provider: ${agent.activeProvider} / ${agent.provider.model}`, C.dim)
1531
+ }
888
1532
  pushLine(`Tools: ${agent.tools.map((t) => t.name).join(", ")}`, C.dim)
889
1533
  // 恢复上次会话:重建对话区显示(tool 结果行省略,保持清爽)
890
1534
  if (opts.restored?.history?.length) {