thincoder 0.2.0 → 0.3.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
@@ -1,16 +1,17 @@
1
1
  /**
2
2
  * tui.mjs — 裸 ANSI 终端 UI
3
3
  * 零依赖:raw mode 键盘输入、ANSI 转义渲染、自研宽字符换行。
4
- * 布局:header / 对话区(可滚动)/ 输入框 / 状态栏。
4
+ * 布局:header / 对话区(可滚动)/ todo 面板(有任务时)/ 输入框 / 状态栏。
5
5
  */
6
6
 
7
7
  import { emitKeypressEvents } from "node:readline"
8
8
  import { PassThrough } from "node:stream"
9
9
  import { basename } from "node:path"
10
10
  import { existsSync, readFileSync } from "node:fs"
11
- import { runAgent } from "./agent.mjs"
11
+ import { runAgent, ContinueError } from "./agent.mjs"
12
12
  import { saveSession, clearSession } from "./session.mjs"
13
13
  import { PROVIDER_PRESETS as PRESETS } from "./config.mjs"
14
+ import { closeAllMcp } from "./mcp.mjs"
14
15
 
15
16
  // ---------------------------------------------------------------- ANSI 工具
16
17
 
@@ -228,13 +229,15 @@ export async function startTUI(agent, opts = {}) {
228
229
  historyIndex: -1,
229
230
  scroll: 0, // 从底部向上的滚动行数
230
231
  processing: false,
232
+ controller: null, // AbortController for current agent run
231
233
  permission: null, // { name, args, resolve }
234
+ question: null, // { text, options, resolve } — agent 的 question 工具回调
232
235
  picker: null, // 模型选择器 { entries, lines, index, scroll, selectedLine }
233
236
  wizard: null, // 首次配置向导 { step, index, scroll, selectedLine, fields, error, lines }
234
237
  tasks: [], // task 工具的任务列表(状态栏显示进度)
235
238
  reasoning: "", // 思考流缓冲(暗色展示)
236
239
  completion: null, // Tab 补全状态 { candidates, index }
237
- toolStream: "", // 当前工具的实时输出(暗色展示,bash 流式)
240
+ toolStreams: {}, // 各工具的实时输出(按工具名隔离,并行工具互不串扰)
238
241
  currentTool: null, // 正在执行的工具名(状态栏显示)
239
242
  processingStarted: 0, // 本轮处理开始时间(状态栏计时)
240
243
  status: "Ready",
@@ -284,6 +287,12 @@ export async function startTUI(agent, opts = {}) {
284
287
  } catch {
285
288
  // 存失败不耽误退出
286
289
  }
290
+ // 关闭 MCP stdio 子进程,不留孤儿
291
+ try {
292
+ closeAllMcp(agent)
293
+ } catch {
294
+ // 关不掉就算了,进程马上退出
295
+ }
287
296
  process.stdin.setRawMode(false)
288
297
  process.stdout.write(ansi.mouseOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
289
298
  }
@@ -345,7 +354,15 @@ export async function startTUI(agent, opts = {}) {
345
354
  inputOffset = Math.min(layout.cursorLine, layout.lines.length - MAX_INPUT_LINES)
346
355
  }
347
356
  const inputLines = layout.lines.slice(inputOffset, inputOffset + MAX_INPUT_LINES)
348
- const inputBoxH = inputLines.length + 2
357
+ // question 模式下输入框显示选项/答案草稿,而不是普通输入(高度也要跟着走)
358
+ let boxLines = inputLines
359
+ if (state.question) {
360
+ const q = state.question
361
+ boxLines = q.options.length > 0
362
+ ? q.options.map((opt, i) => (i === (q.selected ?? 0) ? "▸ " : " ") + opt)
363
+ : ["▸ " + (q.answer ?? "")]
364
+ }
365
+ const inputBoxH = boxLines.length + 2
349
366
 
350
367
  const headerH = 1
351
368
  const statusH = 1
@@ -354,7 +371,20 @@ export async function startTUI(agent, opts = {}) {
354
371
  const pickerH = overlay
355
372
  ? Math.min(overlay.lines.length + 1, Math.max(6, rows - 12))
356
373
  : 0
357
- const convH = Math.max(1, rows - headerH - inputBoxH - statusH - pickerH)
374
+ // todo 面板:有任务列表时占对话区与输入框之间最多 5
375
+ // 折叠时优先 in_progress,兼顾最早的 pending 和最近的 done
376
+ const MAX_TASK_LINES = 5
377
+ let visibleTasks = []
378
+ if (state.tasks.length <= MAX_TASK_LINES) {
379
+ visibleTasks = state.tasks
380
+ } else {
381
+ const inProgress = state.tasks.filter((t) => t.status === "in_progress")
382
+ const pending = state.tasks.filter((t) => t.status === "pending")
383
+ const done = state.tasks.filter((t) => t.status === "done")
384
+ visibleTasks = [...inProgress, ...pending, ...done].slice(0, MAX_TASK_LINES)
385
+ }
386
+ const taskPanelH = visibleTasks.length
387
+ const convH = Math.max(1, rows - headerH - inputBoxH - statusH - pickerH - taskPanelH)
358
388
 
359
389
  // 对话区内容行(含流式缓冲);markdown 表格先按显示宽度重排
360
390
  const convLines = []
@@ -378,9 +408,10 @@ export async function startTUI(agent, opts = {}) {
378
408
  }
379
409
  }
380
410
  }
381
- // 工具实时输出(暗色,只保留末尾防刷屏)
382
- if (state.toolStream) {
383
- const tail = state.toolStream.slice(-4000)
411
+ // 工具实时输出(暗色,只保留末尾防刷屏;按工具名隔离防止并行工具串扰)
412
+ const allStreams = Object.values(state.toolStreams).join("")
413
+ if (allStreams) {
414
+ const tail = allStreams.slice(-4000)
384
415
  for (const wrapped of wrapText(tail, cols - 1)) {
385
416
  convLines.push({ text: wrapped, color: C.dim })
386
417
  }
@@ -393,9 +424,9 @@ export async function startTUI(agent, opts = {}) {
393
424
 
394
425
  const out = [ansi.home]
395
426
 
396
- // header
427
+ // header(超宽截断,防终端折行)
397
428
  out.push(
398
- `${ansi.bold}${C.tool} ThinCoder ${ansi.reset}${ansi.dim}│ ${model}${thinkBadge ? " " + thinkBadge : ""} │ ${basename(agent.cwd)}${ansi.reset}${ansi.clearLine}`,
429
+ `${ansi.bold}${C.tool} ThinCoder ${ansi.reset}${ansi.dim}│ ${sliceByWidth(model, 30)}${thinkBadge ? " " + thinkBadge : ""} │ ${sliceByWidth(basename(agent.cwd), Math.max(10, cols - 60))}${ansi.reset}${ansi.clearLine}`,
399
430
  )
400
431
 
401
432
  // 对话区(不足部分补空行,把输入框钉在底部)
@@ -420,20 +451,38 @@ export async function startTUI(agent, opts = {}) {
420
451
  for (let i = shown.length; i < winH; i++) out.push(ansi.clearLine)
421
452
  }
422
453
 
454
+ // todo 面板(对话区与输入框之间):▶ in_progress / ✓ done / ○ pending
455
+ for (const t of visibleTasks) {
456
+ const mark = t.status === "done" ? "✓" : t.status === "in_progress" ? "▶" : "○"
457
+ const color = t.status === "done" ? C.dim : t.status === "in_progress" ? C.tool : C.text
458
+ out.push(`${color} ${mark} ${sliceByWidth(t.title, cols - 4)}${ansi.reset}${ansi.clearLine}`)
459
+ }
460
+
423
461
  // 输入框(全边框,宽 W)
424
- const borderColor = state.permission ? C.warn : C.tool
425
- const title = state.permission
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 "
462
+ let borderColor = C.tool
463
+ let title
464
+ if (state.question) {
465
+ borderColor = C.tool
466
+ title = ` ${sliceByWidth(state.question.text, W - 6)} `
467
+ } else if (state.permission) {
468
+ borderColor = C.warn
469
+ if (state.permission.name === "continue") {
470
+ title = " Continue? (y/n) "
471
+ } else {
472
+ title = ` Allow ${state.permission.name}? (y/n/a) `
473
+ }
474
+ } else if (state.picker) {
475
+ title = " Model "
476
+ } else if (state.wizard) {
477
+ title = " Setup "
478
+ } else if (state.processing) {
479
+ title = " Processing... "
480
+ } else {
481
+ title = " Input "
482
+ }
434
483
  const topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 3 - stringWidth(title)))}╮`
435
484
  out.push(`${borderColor}${topBorder}${ansi.reset}${ansi.clearLine}`)
436
- for (const l of inputLines) {
485
+ for (const l of boxLines) {
437
486
  const content = sliceByWidth(l, W - 4)
438
487
  const fill = " ".repeat(Math.max(0, W - 4 - stringWidth(content)))
439
488
  out.push(`${borderColor}│${ansi.reset} ${content}${fill} ${borderColor}│${ansi.reset}${ansi.clearLine}`)
@@ -444,8 +493,15 @@ export async function startTUI(agent, opts = {}) {
444
493
  const scrollHint = state.scroll > 0 ? ` │ scrolled ${state.scroll}` : ""
445
494
  const rawInput = state.input.join("")
446
495
  let statusLine
447
- if (state.permission) {
448
- statusLine = " y: 批准 │ n: 拒绝 │ a: 批准并全部放行(AUTO)"
496
+ if (state.question) {
497
+ const q = state.question
498
+ statusLine = q.options.length > 0
499
+ ? " ↑↓: 选择 │ Enter: 确认 │ Esc: 取消"
500
+ : " 输入回答后 Enter 提交 │ Esc: 取消"
501
+ } else if (state.permission) {
502
+ statusLine = state.permission.name === "continue"
503
+ ? " y: 继续 │ n: 停止"
504
+ : " y: 批准 │ n: 拒绝 │ a: 批准并全部放行(AUTO)"
449
505
  } else if (state.picker) {
450
506
  statusLine = " ↑↓: 选择 │ Enter: 确认 │ Esc: 取消"
451
507
  } else if (state.wizard) {
@@ -457,11 +513,11 @@ export async function startTUI(agent, opts = {}) {
457
513
  const cmds = SLASH_COMMANDS.filter((c) => c.name.startsWith(cmd))
458
514
  const match = cmds.length === 1 ? cmds[0] : null
459
515
  if (match?.name === "/config" && cmd === "/config") {
460
- statusLine = " /config 查看 │ key / embedkey 配 key"
516
+ statusLine = " /config 查看 │ embedkey 配 embedding │ set 改参数"
517
+ } else if (match?.name === "/provider" && cmd === "/provider") {
518
+ statusLine = " /provider 列表 │ add / remove / key"
461
519
  } else if (match?.name === "/model" && cmd === "/model" && !sub) {
462
520
  statusLine = " /model 打开选择器 │ /model <名称> 直接切换"
463
- } else if (match?.name === "/provider" && cmd === "/provider") {
464
- statusLine = " /provider 列表 │ add / remove / key 管理"
465
521
  } else if (match?.name === "/think" && cmd === "/think") {
466
522
  statusLine = " /think 查看 │ on / off 开关 │ effort high / max 强度"
467
523
  } else if (cmds.length > 0) {
@@ -483,9 +539,12 @@ export async function startTUI(agent, opts = {}) {
483
539
  statusLine = ` ${statusText}${taskHint}${scrollHint} │ Enter: send │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+C: exit`
484
540
  }
485
541
  const autoBanner = agent.autoApprove ? `${C.warn} AUTO${ansi.reset}${ansi.dim}│` : ""
486
- // 状态栏最多一行,超出终端宽度会被终端折行导致光标偏移
487
- statusLine = sliceByWidth(statusLine, cols - 1)
488
- out.push(`${ansi.dim}${autoBanner}${statusLine}${ansi.reset}${ansi.clearLine}`)
542
+ const planBanner = agent.planMode ? `${C.tool} PLAN${ansi.reset}${ansi.dim}│` : ""
543
+ // 状态栏最多一行:终端宽度扣掉 banner 前缀的可视列数,防折行
544
+ const bannerPrefix = (agent.planMode ? " PLAN│ " : "") + (agent.autoApprove ? " AUTO│ " : "")
545
+ const statusMax = cols - 1 - (bannerPrefix ? stringWidth(bannerPrefix) : 0)
546
+ statusLine = sliceByWidth(statusLine, Math.max(10, statusMax))
547
+ out.push(`${ansi.dim}${planBanner}${autoBanner}${statusLine}${ansi.reset}${ansi.clearLine}`)
489
548
 
490
549
  const frame = out.join("\r\n")
491
550
  if (frame !== lastFrame) {
@@ -494,10 +553,10 @@ export async function startTUI(agent, opts = {}) {
494
553
  }
495
554
 
496
555
  // 光标:输入态定位到输入框内(IME 候选框跟随真实光标);处理中/权限确认/菜单态时隐藏
497
- if (state.processing || state.permission || state.picker || state.wizard?.step === "provider") {
556
+ if (state.processing || state.permission || state.question || state.picker || state.wizard?.step === "provider") {
498
557
  process.stdout.write(ansi.hideCursor)
499
558
  } else {
500
- const cursorRow = 1 + convH + 2 + (layout.cursorLine - inputOffset) // header + 对话区 + 上边框 + 行偏移
559
+ const cursorRow = 1 + convH + taskPanelH + 2 + (layout.cursorLine - inputOffset) // header + 对话区 + todo 面板 + 上边框 + 行偏移
501
560
  const cursorCol = 3 + layout.cursorCol // 左边框 + 空格 + 文本偏移(1 基)
502
561
  process.stdout.write(`${ESC}[${cursorRow};${cursorCol}H${ansi.showCursor}`)
503
562
  }
@@ -540,63 +599,105 @@ export async function startTUI(agent, opts = {}) {
540
599
  state.reasoning = ""
541
600
  state.currentTool = null
542
601
  state.processingStarted = Date.now()
602
+ state.controller = new AbortController()
543
603
  // 处理中每秒刷新一次状态栏(运行计时)
544
604
  const ticker = setInterval(() => {
545
605
  if (state.processing) render()
546
606
  }, 1000)
547
607
  render()
548
608
 
549
- try {
550
- await runAgent(agent, text, {
551
- onToken: (t) => {
552
- ensureAssistantLabel()
553
- state.streaming += t
554
- scheduleRender() // token 洪流限流,防闪屏
555
- },
556
- onReasoning: (t) => {
557
- ensureAssistantLabel()
558
- state.reasoning += t
559
- scheduleRender()
560
- },
561
- onToolCall: (name, args) => {
562
- flushStream()
563
- ensureAssistantLabel()
564
- state.currentTool = name
565
- pushLine(` [tool] ${name} ${summarize(args)}`, C.tool)
566
- },
567
- onToolResult: (name, result) => {
568
- state.currentTool = null
569
- if (state.toolStream) {
570
- // 实时输出落盘为历史行(保留末尾 4000 字符),并清掉临时缓冲
571
- const tail = state.toolStream.trimEnd().slice(-4000)
572
- if (tail) pushLine(tail, C.dim)
573
- state.toolStream = ""
609
+ const callbacks = {
610
+ onToken: (t) => {
611
+ ensureAssistantLabel()
612
+ state.streaming += t
613
+ scheduleRender()
614
+ },
615
+ onReasoning: (t) => {
616
+ ensureAssistantLabel()
617
+ state.reasoning += t
618
+ scheduleRender()
619
+ },
620
+ onToolCall: (name, args) => {
621
+ flushStream()
622
+ ensureAssistantLabel()
623
+ state.currentTool = name
624
+ pushLine(` [tool] ${name} ${summarize(args)}`, C.tool)
625
+ },
626
+ onToolResult: (name, result) => {
627
+ state.currentTool = null
628
+ const stream = state.toolStreams[name]
629
+ if (stream) {
630
+ const tail = stream.trimEnd().slice(-4000)
631
+ if (tail) pushLine(tail, C.dim)
632
+ delete state.toolStreams[name]
633
+ }
634
+ const first = result.split("\n")[0]
635
+ pushLine(` [done] ${name} → ${sliceByWidth(first, 100)}`, C.dim)
636
+ },
637
+ onToolOutput: (name, chunk) => {
638
+ state.toolStreams[name] = (state.toolStreams[name] ?? "") + chunk
639
+ scheduleRender()
640
+ },
641
+ onPermissionRequest: (name, args) => askPermission(name, args),
642
+ onQuestion: (text, options) => askQuestion(text, options),
643
+ onCompress: () => {
644
+ pushLine(" [context] 上下文过长,已自动压缩(早期对话由 LLM 摘要,任务状态保留)", C.warn)
645
+ },
646
+ onTaskUpdate: (items) => {
647
+ state.tasks = items
648
+ const done = items.filter((i) => i.status === "done").length
649
+ pushLine(` [task] ${done}/${items.length}`, C.dim)
650
+ render()
651
+ },
652
+ }
653
+
654
+ for (let resume = false; ; resume = true) {
655
+ try {
656
+ await runAgent(agent, text, callbacks, { signal: state.controller.signal, resume })
657
+ flushStream()
658
+ break // 正常完成,退出循环
659
+ } catch (error) {
660
+ flushStream()
661
+ if (error.name === "AbortError" || state.controller?.signal.aborted) {
662
+ pushLine("[已中止]", C.warn)
663
+ break
664
+ }
665
+ if (error instanceof ContinueError) {
666
+ pushLabel(`❯ Continue`, ansi.bold + C.warn)
667
+ pushLine(`已执行 ${error.turn} 轮(上限 ${error.turn}),要继续吗?`, C.warn)
668
+ // 暂停询问:复用 permission 机制
669
+ const willContinue = await new Promise((resolve) => {
670
+ state.permission = {
671
+ name: "continue",
672
+ args: { turns: error.turn },
673
+ resolve,
674
+ }
675
+ state.status = `Continue after ${error.turn} turns?`
676
+ render()
677
+ })
678
+ state.permission = null
679
+ if (!willContinue) {
680
+ pushLine("[已取消继续]", C.warn)
681
+ break
574
682
  }
575
- const first = result.split("\n")[0]
576
- pushLine(` [done] ${name} ${sliceByWidth(first, 100)}`, C.dim)
577
- },
578
- onToolOutput: (name, chunk) => {
579
- state.toolStream += chunk
580
- scheduleRender()
581
- },
582
- onPermissionRequest: (name, args) => askPermission(name, args),
583
- onTaskUpdate: (items) => {
584
- state.tasks = items
585
- const done = items.filter((i) => i.status === "done").length
586
- pushLine(` [task] ${done}/${items.length}`, C.dim)
587
- render()
588
- },
589
- })
590
- flushStream()
591
- } catch (error) {
592
- flushStream()
593
- pushLine(`[error] ${error.message}`, C.error)
594
- } finally {
595
- clearInterval(ticker)
683
+ pushLine("[继续执行…]", C.tool)
684
+ // 重创新 AbortController:旧 signal 一旦 abort 过,resume 会立即失败(防御性,当前路径不可达但耦合紧)
685
+ state.controller = new AbortController()
686
+ continue
687
+ }
688
+ pushLine(`[error] ${error.message}`, C.error)
689
+ break
690
+ }
596
691
  }
597
692
 
693
+ clearInterval(ticker)
598
694
  state.processing = false
695
+ state.controller = null
599
696
  state.status = "Ready"
697
+ // 全部完成时自动收起 todo 面板(对齐 kimi-code TUI;agent.tasks 本身保留)
698
+ if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
699
+ state.tasks = []
700
+ }
600
701
  // 每轮结束后保存会话(崩溃也不丢)
601
702
  try {
602
703
  saveSession(agent)
@@ -644,21 +745,51 @@ export async function startTUI(agent, opts = {}) {
644
745
  return [cap(summarize(args), 300)]
645
746
  }
646
747
 
748
+ function askQuestion(text, options = []) {
749
+ // 一次只能问一个:question 是只读工具走并行通道,同批第二个直接驳回,
750
+ // 否则后到的会覆盖 state.question,先到的 Promise 永远悬挂(agent 死等)
751
+ if (state.question) {
752
+ return Promise.resolve("(error: 已有问题在等待回答;请一次只问一个,得到答复后再问下一个)")
753
+ }
754
+ if (!options.length) {
755
+ // 自由文本:打开输入态让用户打字,Enter 提交
756
+ pushLabel(`❯ Question`, ansi.bold + C.tool)
757
+ for (const line of text.split("\n")) pushLine(` ${line}`, C.text)
758
+ return new Promise((resolve) => {
759
+ state.question = { text, options: [], resolve }
760
+ state.status = "Waiting for answer..."
761
+ render()
762
+ })
763
+ }
764
+ // 选项模式:输入框内显示列表,方向键选,Enter 确认
765
+ pushLabel(`❯ Question`, ansi.bold + C.tool)
766
+ for (const line of text.split("\n")) pushLine(` ${line}`, C.text)
767
+ return new Promise((resolve) => {
768
+ state.question = { text, options, selected: 0, resolve }
769
+ state.status = "Waiting for choice..."
770
+ render()
771
+ })
772
+ }
773
+
647
774
  // ---------------------------------------------------------- 斜杠命令
648
775
 
649
776
  const SLASH_COMMANDS = [
650
- { name: "/help", desc: "命令列表" },
651
- { name: "/model", desc: "模型选择器/切换" },
652
- { name: "/provider", desc: "管理 provider(增/删/配 key)" },
653
- { name: "/think", desc: "思维模式与推理强度" },
654
- { name: "/config", desc: "查看配置、配 key" },
655
- { name: "/auto", desc: "自动授权开关" },
656
- { name: "/rewind", desc: "回滚到存档点" },
657
- { name: "/reindex", desc: "重建记忆索引" },
658
- { name: "/distill", desc: "从会话提取知识" },
659
- { name: "/new", desc: "开始新会话" },
660
- { name: "/clear", desc: "清屏" },
661
- { name: "/exit", desc: "退出" },
777
+ { name: "/plan", group: "Agent", desc: "规划模式(先设计、再实现)" },
778
+ { name: "/auto", group: "Agent", desc: "自动授权开关" },
779
+ { name: "/model", group: "Agent", desc: "选择模型" },
780
+ { name: "/goal", group: "Agent", desc: "设置/查看/取消长期目标" },
781
+ { name: "/think", group: "Agent", desc: "思维模式与推理强度" },
782
+ { name: "/skills", group: "Tools", desc: "列出项目技能" },
783
+ { name: "/mcp", group: "Tools", desc: "管理 MCP server" },
784
+ { name: "/provider", group: "Config", desc: "管理 provider(增/删/配 key)" },
785
+ { name: "/config", group: "Config", desc: "配置管理(embedding / agent)" },
786
+ { name: "/reindex", group: "Config", desc: "重建记忆索引" },
787
+ { name: "/new", group: "Session", desc: "开始新会话" },
788
+ { name: "/clear", group: "Session", desc: "清屏" },
789
+ { name: "/distill", group: "Session", desc: "从会话提取知识" },
790
+ { name: "/rewind", group: "Session", desc: "回滚到存档点" },
791
+ { name: "/exit", group: "Session", desc: "退出" },
792
+ { name: "/help", group: "", desc: "此列表" },
662
793
  ]
663
794
 
664
795
  async function handleSlash(text) {
@@ -672,6 +803,9 @@ export async function startTUI(agent, opts = {}) {
672
803
  case "/new":
673
804
  agent.history = []
674
805
  agent.tasks = []
806
+ agent.planMode = false
807
+ agent.goal = null
808
+ agent._pendingReminders = []
675
809
  state.tasks = []
676
810
  state.lines = []
677
811
  state.streaming = ""
@@ -732,8 +866,200 @@ export async function startTUI(agent, opts = {}) {
732
866
  }
733
867
  return
734
868
  }
869
+ case "/plan": {
870
+ agent.planMode = !agent.planMode
871
+ agent._pendingReminders = agent._pendingReminders ?? []
872
+ if (agent.planMode) {
873
+ agent._pendingReminders.push("[System reminder: plan mode is now ON. You are restricted to READ-ONLY tools — explore, search, read, analyze. DO NOT write, edit, or run mutation commands. Present your design to the user first.]")
874
+ } else {
875
+ agent._pendingReminders.push("[System reminder: plan mode is now OFF. You may edit files, run commands, and implement changes.]")
876
+ }
877
+ pushLabel(`❯ Plan`, ansi.bold + (agent.planMode ? C.tool : C.dim))
878
+ pushLine(
879
+ agent.planMode
880
+ ? `规划模式已开启:只读工具受限,先设计方案再实现。再次 /plan 退出。`
881
+ : `规划模式已关闭:可以编辑文件和执行命令了。`,
882
+ agent.planMode ? C.tool : C.dim,
883
+ )
884
+ return
885
+ }
886
+ case "/goal": {
887
+ const sub = rest[0]
888
+ if (sub === "set") {
889
+ const text = rest.slice(1).join(" ")
890
+ if (!text) { pushLine("用法: /goal set <目标描述>(; 分隔完成条件)", C.error); return }
891
+ const semi = text.indexOf(";") >= 0 ? ";" : text.indexOf(";") >= 0 ? ";" : null
892
+ const objective = semi ? text.slice(0, semi).trim() : text.trim()
893
+ const criteria = semi ? text.slice(semi + 1).trim() : ""
894
+ agent.goal = { objective, criteria, setAt: Date.now() }
895
+ pushLabel(`❯ Goal`, ansi.bold + C.warn)
896
+ pushLine(`目标已设置: ${objective}`, C.tool)
897
+ if (criteria) pushLine(` 完成条件: ${criteria}`, C.dim)
898
+ return
899
+ }
900
+ if (sub === "cancel") {
901
+ agent.goal = null
902
+ pushLabel(`❯ Goal`, ansi.bold + C.dim)
903
+ pushLine(`目标已取消。`, C.dim)
904
+ return
905
+ }
906
+ if (agent.goal) {
907
+ pushLabel(`❯ Goal`, ansi.bold + C.warn)
908
+ pushLine(`目标: ${agent.goal.objective}`, C.tool)
909
+ if (agent.goal.criteria) pushLine(` 完成条件: ${agent.goal.criteria}`, C.dim)
910
+ pushLine(` 设置于: ${new Date(agent.goal.setAt).toLocaleString()}`, C.dim)
911
+ pushLine("操作: /goal set <描述> 覆盖 | /goal cancel 取消", C.dim)
912
+ } else {
913
+ pushLabel(`❯ Goal`, ansi.bold + C.dim)
914
+ pushLine("(无活跃目标——/goal set <描述> 设置)", C.dim)
915
+ }
916
+ return
917
+ }
918
+ case "/skills": {
919
+ const { loadSkills } = await import("./skills.mjs")
920
+ const skills = await loadSkills(agent.cwd)
921
+ pushLabel(`❯ Skills`, ansi.bold + C.tool)
922
+ if (skills.length === 0) {
923
+ pushLine("(无项目技能——在 .thincoder/skills/ 下创建 .md 文件即可添加)", C.dim)
924
+ }
925
+ for (const s of skills) {
926
+ pushLine(` ${s.name}: ${s.description.slice(0, 100)}`, C.dim)
927
+ }
928
+ pushLine("激活: 告诉 agent \"load the <name> skill\"", C.dim)
929
+ return
930
+ }
931
+ case "/mcp": {
932
+ const sub = rest[0]
933
+ // ---- /mcp list — 列出配置的 servers + 连接状态 ----
934
+ if (!sub || sub === "list") {
935
+ const servers = agent.config?.mcp?.servers ?? []
936
+ pushLabel(`❯ MCP Servers`, ansi.bold + C.tool)
937
+ if (servers.length === 0) {
938
+ pushLine("(无 MCP server——使用 /mcp add <name> <command> [args] 添加)", C.dim)
939
+ }
940
+ for (const srv of servers) {
941
+ const connected = agent.tools.some((t) => t._mcpName === srv.name)
942
+ const mark = connected ? "●" : "○"
943
+ const color = connected ? C.tool : C.dim
944
+ const toolCount = agent.tools.filter((t) => t._mcpName === srv.name).length
945
+ const desc = srv.url ? srv.url : `${srv.command} ${(srv.args ?? []).join(" ")}`
946
+ pushLine(` ${mark} ${srv.name}: ${desc} (${toolCount} tools)`, color)
947
+ }
948
+ pushLabel(`❯ 操作`, ansi.bold + C.tool)
949
+ pushLine(`/mcp add <name> <command> [args...] 添加 stdio server`, C.dim)
950
+ pushLine(`/mcp url <name> <url> [headers...] 添加 HTTP server`, C.dim)
951
+ pushLine(`/mcp remove <name> 断开并移除 server`, C.dim)
952
+ pushLine(`/mcp connect <name> 重连已配置的 server`, C.dim)
953
+ pushLine("配置持久化到 config.json 的 mcp.servers[]", C.dim)
954
+ return
955
+ }
956
+ // ---- /mcp add <name> <command> [args...] (stdio) ----
957
+ if (sub === "add") {
958
+ const args = rest.slice(1)
959
+ if (args.length < 2) {
960
+ pushLine("用法: /mcp add <name> <command> [args...]", C.error)
961
+ pushLine(" 例: /mcp add github npx -y @modelcontextprotocol/server-github", C.dim)
962
+ return
963
+ }
964
+ const name = args[0]
965
+ const command = args[1]
966
+ const cmdArgs = args.slice(2)
967
+ const existing = (agent.config?.mcp?.servers ?? []).find((s) => s.name === name)
968
+ if (existing) { pushLine(`[mcp] "${name}" 已存在,用 /mcp remove ${name} 先移除`, C.error); return }
969
+ const srv = { name, command, args: cmdArgs.length > 0 ? cmdArgs : undefined }
970
+ await addAndConnect(srv)
971
+ return
972
+ }
973
+ // ---- /mcp url <name> <url> [key=value...] (HTTP) ----
974
+ if (sub === "url") {
975
+ const args = rest.slice(1)
976
+ if (args.length < 2) {
977
+ pushLine("用法: /mcp url <name> <url> [header=value...]", C.error)
978
+ pushLine(" 例: /mcp url myapi https://api.example.com/mcp Authorization=\"Bearer token123\"", C.dim)
979
+ return
980
+ }
981
+ const name = args[0]
982
+ const url = args[1]
983
+ const headerPairs = args.slice(2)
984
+ const existing = (agent.config?.mcp?.servers ?? []).find((s) => s.name === name)
985
+ if (existing) { pushLine(`[mcp] "${name}" 已存在,用 /mcp remove ${name} 先移除`, C.error); return }
986
+ const headers = {}
987
+ for (const pair of headerPairs) {
988
+ const eq = pair.indexOf("=")
989
+ if (eq > 0) headers[pair.slice(0, eq)] = pair.slice(eq + 1).replace(/^["']|["']$/g, "")
990
+ }
991
+ const srv = { name, url, headers: Object.keys(headers).length > 0 ? headers : undefined }
992
+ await addAndConnect(srv)
993
+ return
994
+ }
995
+ // ---- /mcp remove <name> ----
996
+ if (sub === "remove") {
997
+ const name = rest[1]
998
+ if (!name) { pushLine("用法: /mcp remove <name>", C.error); return }
999
+ const { removeMcpTools } = await import("./mcp.mjs")
1000
+ removeMcpTools(agent, name)
1001
+ await persistRaw((raw) => { raw.mcp ??= { servers: [] }; raw.mcp.servers = raw.mcp.servers.filter((s) => s.name !== name) })
1002
+ if (agent.config?.mcp?.servers) agent.config.mcp.servers = agent.config.mcp.servers.filter((s) => s.name !== name)
1003
+ pushLabel(`❯ MCP`, ansi.bold + C.tool)
1004
+ pushLine(`${name} 已断开并从配置移除。`, C.tool)
1005
+ return
1006
+ }
1007
+ // ---- /mcp connect <name> — 重连 ----
1008
+ if (sub === "connect") {
1009
+ const name = rest[1]
1010
+ if (!name) { pushLine("用法: /mcp connect <name>", C.error); return }
1011
+ const srv = (agent.config?.mcp?.servers ?? []).find((s) => s.name === name)
1012
+ if (!srv) { pushLine(`[mcp] "${name}" 未在配置中找到(先用 /mcp add 或 /mcp url)`, C.error); return }
1013
+ const { removeMcpTools, connectMcpServer } = await import("./mcp.mjs")
1014
+ removeMcpTools(agent, name)
1015
+ try {
1016
+ pushLine(`[mcp] 重连 ${name}...`, C.dim)
1017
+ const tools = await connectMcpServer(srv)
1018
+ agent.tools.push(...tools)
1019
+ pushLabel(`❯ MCP`, ansi.bold + C.tool)
1020
+ pushLine(`${name} 已重连,${tools.length} 个工具可用。`, C.tool)
1021
+ } catch (error) {
1022
+ pushLine(`[mcp] ${name}: ${error.message}`, C.error)
1023
+ }
1024
+ return
1025
+ }
1026
+ pushLine(`未知子命令: ${sub}(/mcp list | add | url | remove | connect)`, C.error)
1027
+ return
1028
+ }
1029
+
1030
+ // ---- /mcp 共享 helper: 保存配置 + 连接 ----
1031
+ async function addAndConnect(srv) {
1032
+ await persistRaw((raw) => {
1033
+ raw.mcp ??= { servers: [] }
1034
+ const entry = { name: srv.name }
1035
+ if (srv.url) { entry.url = srv.url; if (srv.headers) entry.headers = srv.headers }
1036
+ else { entry.command = srv.command; if (srv.args) entry.args = srv.args }
1037
+ raw.mcp.servers.push(entry)
1038
+ })
1039
+ agent.config ??= {}
1040
+ agent.config.mcp ??= { servers: [] }
1041
+ agent.config.mcp.servers.push(srv)
1042
+ try {
1043
+ pushLine(`[mcp] 连接 ${srv.name}...`, C.dim)
1044
+ const { connectMcpServer } = await import("./mcp.mjs")
1045
+ const tools = await connectMcpServer(srv)
1046
+ agent.tools.push(...tools)
1047
+ pushLabel(`❯ MCP`, ansi.bold + C.tool)
1048
+ const desc = srv.url ? srv.url : `${srv.command} ${(srv.args ?? []).join(" ")}`
1049
+ pushLine(`${srv.name} (${desc}) 已连接,${tools.length} 个工具:`, C.tool)
1050
+ for (const t of tools) pushLine(` ${t.name}: ${t.description.slice(0, 100)}`, C.dim)
1051
+ } catch (error) {
1052
+ pushLine(`[mcp] ${srv.name}: ${error.message}(配置已保存,重启后重试)`, C.error)
1053
+ }
1054
+ }
735
1055
  case "/auto":
736
1056
  agent.autoApprove = !agent.autoApprove
1057
+ agent._pendingReminders = agent._pendingReminders ?? []
1058
+ if (agent.autoApprove) {
1059
+ agent._pendingReminders.push("[System reminder: AUTO mode is now ON. All tool calls are automatically approved — you may write, edit, and run commands without asking. Use this for long autonomous tasks. The user can still interrupt.]")
1060
+ } else {
1061
+ agent._pendingReminders.push("[System reminder: AUTO mode is now OFF. Destructive tool calls now require user approval again. Confirm before writing files, running commands, or spawning subagents.]")
1062
+ }
737
1063
  pushLabel(`❯ Auto`, ansi.bold + (agent.autoApprove ? C.warn : C.tool))
738
1064
  pushLine(
739
1065
  agent.autoApprove
@@ -811,16 +1137,17 @@ export async function startTUI(agent, opts = {}) {
811
1137
  if (p) {
812
1138
  agent.activeProvider = arg
813
1139
  agent.provider = { ...p }
814
- // key 的环境变量兜底和 loadConfig 保持一致
1140
+ // key 的环境变量兜底和 loadConfig 保持一致(提供商专用变量只对同名生效)
815
1141
  if (!agent.provider.apiKey) {
816
- agent.provider.apiKey =
817
- process.env.THINCODER_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY
1142
+ const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[arg]
1143
+ if (envKey && process.env[envKey]) agent.provider.apiKey = process.env[envKey]
818
1144
  }
1145
+ if (!agent.provider.apiKey) agent.provider.apiKey = process.env.THINCODER_API_KEY
819
1146
  await persistRaw((raw) => { raw.activeProvider = arg })
820
1147
  agent.config.activeProvider = arg
821
1148
  pushLabel(`❯ Model`, ansi.bold + C.tool)
822
1149
  pushLine(`已切换到 ${arg} / ${p.model}${thresholdNote}(已持久化)`, C.tool)
823
- if (!agent.provider.apiKey) pushLine(`该 provider 还没配 key: /config key <apikey>`, C.warn)
1150
+ if (!agent.provider.apiKey) pushLine(`该 provider 还没配 key: /provider key <apikey>`, C.warn)
824
1151
  } else {
825
1152
  const target = agent.providers.find((pp) => pp.name === agent.activeProvider) ?? agent.providers[0]
826
1153
  if (target) target.model = arg
@@ -852,10 +1179,7 @@ export async function startTUI(agent, opts = {}) {
852
1179
  if (!preset) pushLine(`("${name}" 不是预设;预设: ${Object.keys(PRESETS).join(", ")})`, C.dim)
853
1180
  return
854
1181
  }
855
- if (!/^https?:\/\//.test(baseURL)) {
856
- pushLine(`baseURL 应以 http(s):// 开头`, C.error)
857
- return
858
- }
1182
+ if (!/^https?:\/\//.test(baseURL)) { pushLine(`baseURL 应以 http(s):// 开头`, C.error); return }
859
1183
  agent.providers.push({ name, baseURL, model, ...(preset?.desc ? { desc: preset.desc } : {}) })
860
1184
  await persistRaw((raw) => { raw.providers = agent.providers })
861
1185
  pushLabel(`❯ Provider`, ansi.bold + C.tool)
@@ -869,17 +1193,14 @@ export async function startTUI(agent, opts = {}) {
869
1193
  if (!name) { pushLine("用法: /provider remove <名称>", C.error); return }
870
1194
  const at = agent.providers.findIndex((p) => p.name === name)
871
1195
  if (at < 0) { pushLine(`未找到 provider "${name}"`, C.error); return }
872
- if (name === agent.activeProvider) {
873
- pushLine(`"${name}" 正在使用中,先 /model 切换到别的 provider 再删`, C.warn)
874
- return
875
- }
1196
+ if (name === agent.activeProvider) { pushLine(`"${name}" 正在使用中,先 /model 切换到别的 provider 再删`, C.warn); return }
876
1197
  agent.providers.splice(at, 1)
877
1198
  await persistRaw((raw) => { raw.providers = agent.providers })
878
1199
  pushLabel(`❯ Provider`, ansi.bold + C.tool)
879
1200
  pushLine(`已删除 ${name}`, C.tool)
880
1201
  return
881
1202
  }
882
- // ---- /provider key [名称] <apikey>(不填名称配当前) ----
1203
+ // ---- /provider key [名称] <apikey> ----
883
1204
  if (sub === "key") {
884
1205
  let name = agent.activeProvider
885
1206
  let keyParts = rest.slice(1)
@@ -888,11 +1209,11 @@ export async function startTUI(agent, opts = {}) {
888
1209
  keyParts = rest.slice(2)
889
1210
  }
890
1211
  const key = keyParts.join(" ")
891
- if (!key) { pushLine("用法: /provider key [名称] <apikey>(不填名称则配当前 provider)", C.error); return }
1212
+ if (!key) { pushLine("用法: /provider key [名称] <apikey>(不填名称配当前 provider)", C.error); return }
892
1213
  await setProviderKey(name, key)
893
1214
  return
894
1215
  }
895
- if (sub) { pushLine(`未知参数: ${sub}(可用: add / remove / key)`, C.error); return }
1216
+ if (sub) { pushLine(`未知: ${sub}(/provider add | remove | key)`, C.error); return }
896
1217
  // ---- /provider(无参): 列表 ----
897
1218
  pushLabel(`❯ Providers (${agent.providers.length})`, ansi.bold + C.tool)
898
1219
  for (const p of agent.providers) {
@@ -903,22 +1224,15 @@ export async function startTUI(agent, opts = {}) {
903
1224
  )
904
1225
  }
905
1226
  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)
1227
+ pushLine(`/provider add <名称|预设> <url> <模型> 添加(预设: ${Object.keys(PRESETS).join(" ")})`, C.dim)
1228
+ pushLine(`/provider remove <名称> 删除`, C.dim)
1229
+ if (!agent.provider.apiKey) pushLine("⚡ /provider key <apikey> 当前 provider 还没配 key", C.warn)
1230
+ else pushLine("/provider key [名称] <apikey> 设置/更换 key", C.dim)
909
1231
  return
910
1232
  }
911
1233
  case "/config": {
912
1234
  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(向量检索) ----
1235
+ // ---- /config embedkey <apikey>:embedding 服务的 key ----
922
1236
  if (sub === "embedkey") {
923
1237
  const key = rest.slice(1).join(" ")
924
1238
  if (!key) { pushLine("用法: /config embedkey <apikey>(embedding 服务,默认 SiliconFlow bge-m3)", C.error); return }
@@ -933,34 +1247,68 @@ export async function startTUI(agent, opts = {}) {
933
1247
  pushLine(`embedding key 已保存,向量检索已启用`, C.tool)
934
1248
  return
935
1249
  }
936
- if (sub) { pushLine(`未知参数: ${sub}(/config 查看,/config key key)`, C.error); return }
1250
+ // ---- /config set <path> <value> (高级) ----
1251
+ if (sub === "set") {
1252
+ const [path, value] = [rest[1], rest.slice(2).join(" ")]
1253
+ if (!path || !value) { pushLine("用法: /config set <path> <value> 如 /config set agent.maxTurns 80", C.error); return }
1254
+ try {
1255
+ const { configPath, loadConfig, saveConfig } = await import("./config.mjs")
1256
+ const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
1257
+ // 支持 a.b 形式的嵌套 key
1258
+ const keys = path.split(".")
1259
+ let obj = raw
1260
+ for (let i = 0; i < keys.length - 1; i++) { obj[keys[i]] ??= {}; obj = obj[keys[i]] }
1261
+ obj[keys[keys.length - 1]] = isNaN(value) ? value : Number(value)
1262
+ saveConfig(raw)
1263
+ const cfg = loadConfig()
1264
+ agent.provider = cfg.provider
1265
+ agent.providers = cfg.providersList
1266
+ agent.activeProvider = cfg.activeProvider
1267
+ agent.config = cfg
1268
+ pushLabel(`❯ Config`, ansi.bold + C.tool)
1269
+ pushLine(`已保存: ${path} = ${value}`, C.tool)
1270
+ } catch (error) {
1271
+ pushLine(`保存失败: ${error.message}`, C.error)
1272
+ }
1273
+ return
1274
+ }
1275
+ if (sub) { pushLine(`未知: ${sub}(可用: embedkey / set)`, C.error); return }
937
1276
  // ---- /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)
1277
+ const { configPath: cp } = await import("./config.mjs")
1278
+ pushLabel(`❯ 配置`, ansi.bold + C.tool)
1279
+ pushLine(`激活: ${agent.activeProvider} / ${agent.provider.model}`, C.dim)
942
1280
  pushLine(`Key: ${maskKey(agent.provider.apiKey)}`, C.dim)
943
1281
  const ac = agent.config?.agent ?? {}
944
1282
  const tn = `${ac.compactThreshold ?? 100000}${ac.compactThresholdAuto ? " (auto)" : ""}`
945
- pushLine(`agent: maxTurns=${ac.maxTurns ?? 50} | compactThreshold=${tn}`, C.dim)
1283
+ pushLine(`agent: maxTurns=${ac.maxTurns ?? 100} | compactThreshold=${tn}`, C.dim)
946
1284
  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)
1285
+ pushLabel(`❯ 管理`, ansi.bold + C.tool)
954
1286
  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)
1287
+ if (!agent.memory?.embedder) pushLine(`/config embedkey <k> 开启向量检索`, C.dim)
1288
+ pushLine(`/config set <k> <v> 修改任意配置项`, C.dim)
1289
+ pushLine(`配置文件: ${cp}`, C.dim)
959
1290
  return
960
1291
  }
961
1292
  case "/help": {
962
- pushLabel(`❯ Commands`, ansi.bold + C.tool)
963
- for (const c of SLASH_COMMANDS) pushLine(` ${c.name.padEnd(10)} ${c.desc}`, C.dim)
1293
+ const order = ["Agent", "Session", "Tools", "Config"]
1294
+ const byGroup = new Map()
1295
+ for (const c of SLASH_COMMANDS) {
1296
+ if (!c.group) continue
1297
+ if (!byGroup.has(c.group)) byGroup.set(c.group, [])
1298
+ byGroup.get(c.group).push(c)
1299
+ }
1300
+ const maxW = Math.max(...SLASH_COMMANDS.map((c) => c.name.length))
1301
+ for (const g of order) {
1302
+ const cmds = byGroup.get(g)
1303
+ if (!cmds?.length) continue
1304
+ byGroup.delete(g)
1305
+ pushLabel(`❯ ${g}`, ansi.bold + C.tool)
1306
+ for (const c of cmds) pushLine(` ${c.name.padEnd(maxW + 1)} ${c.desc}`, C.dim)
1307
+ }
1308
+ for (const [g, cmds] of byGroup) {
1309
+ pushLabel(`❯ ${g}`, ansi.bold + C.tool)
1310
+ for (const c of cmds) pushLine(` ${c.name.padEnd(maxW + 1)} ${c.desc}`, C.dim)
1311
+ }
964
1312
  return
965
1313
  }
966
1314
  default:
@@ -998,7 +1346,12 @@ export async function startTUI(agent, opts = {}) {
998
1346
  if (argIndex === 0) return match(["on", "off", "effort"])
999
1347
  if (argIndex === 1 && parts[1] === "effort") return match(["low", "high", "max"])
1000
1348
  }
1001
- if (cmd === "/config" && argIndex === 0) return match(["key", "embedkey"])
1349
+ if (cmd === "/config" && argIndex === 0) return match(["embedkey", "set"])
1350
+ if (cmd === "/goal" && argIndex === 0) return match(["set", "cancel"])
1351
+ if (cmd === "/mcp") {
1352
+ if (argIndex === 0) return match(["add", "url", "remove", "connect", "list"])
1353
+ if (argIndex === 1 && (parts[1] === "remove" || parts[1] === "connect")) return match((agent.config?.mcp?.servers ?? []).map((s) => s.name))
1354
+ }
1002
1355
  return []
1003
1356
  }
1004
1357
 
@@ -1090,9 +1443,11 @@ export async function startTUI(agent, opts = {}) {
1090
1443
  const header = entries.find((e) => e.type === "header" && e.name === p.name)
1091
1444
  const noteBase = `${p.baseURL}${p.apiKey ? "" : "(未配 key)"}`
1092
1445
  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
1446
+ // key 的环境变量兜底和 loadConfig 保持一致(提供商专用变量只对同名生效)
1447
+ const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[p.name]
1448
+ let apiKey = p.apiKey
1449
+ if (!apiKey && envKey && process.env[envKey]) apiKey = process.env[envKey]
1450
+ if (!apiKey) apiKey = process.env.THINCODER_API_KEY
1096
1451
  const models = await listModels(
1097
1452
  { baseURL: p.baseURL, apiKey: apiKey ?? "" },
1098
1453
  { signal: AbortSignal.timeout(10000) },
@@ -1292,9 +1647,10 @@ export async function startTUI(agent, opts = {}) {
1292
1647
  agent.activeProvider = item.provider
1293
1648
  agent.provider = { ...target }
1294
1649
  if (!agent.provider.apiKey) {
1295
- agent.provider.apiKey =
1296
- process.env.THINCODER_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY
1650
+ const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[item.provider]
1651
+ if (envKey && process.env[envKey]) agent.provider.apiKey = process.env[envKey]
1297
1652
  }
1653
+ if (!agent.provider.apiKey) agent.provider.apiKey = process.env.THINCODER_API_KEY
1298
1654
  let thresholdNote = ""
1299
1655
  if (agent.config?.agent?.compactThresholdAuto) {
1300
1656
  const { resolveCompactThreshold } = await import("./config.mjs")
@@ -1360,23 +1716,82 @@ export async function startTUI(agent, opts = {}) {
1360
1716
  // 权限确认态:y 批准 / n 拒绝 / a 批准并开启 AUTO(后续不再询问)
1361
1717
  if (state.permission) {
1362
1718
  const answer = (str || "").toLowerCase()
1363
- if (answer === "y" || answer === "n" || answer === "a" || key.name === "escape") {
1719
+ const isContinue = state.permission.name === "continue"
1720
+ const validKeys = isContinue ? ["y", "n"] : ["y", "n", "a"]
1721
+ if (validKeys.includes(answer) || key.name === "escape") {
1364
1722
  const { resolve } = state.permission
1365
1723
  state.permission = null
1366
1724
  state.status = "Processing..."
1367
- if (answer === "a") {
1725
+ if (answer === "a" && !isContinue) {
1368
1726
  agent.autoApprove = true
1727
+ agent._pendingReminders = agent._pendingReminders ?? []
1728
+ agent._pendingReminders.push("[System reminder: AUTO mode is now ON. All tool calls are automatically approved. Use /auto to disable.]")
1369
1729
  pushLine(` [auto] AUTO 已开启:后续工具调用不再询问(/auto 关闭)`, C.warn)
1370
1730
  }
1371
- resolve(answer === "y" || answer === "a")
1731
+ resolve(answer === "y" || (answer === "a" && !isContinue))
1372
1732
  render()
1373
1733
  }
1374
1734
  return
1375
1735
  }
1376
1736
 
1737
+ // question 工具回调:自由文本 / 选项选择
1738
+ if (state.question) {
1739
+ const q = state.question
1740
+ if (q.options.length > 0) {
1741
+ // 选项模式:↑↓ 选择,Enter 确认,Esc 取消
1742
+ if (key.name === "escape") {
1743
+ q.resolve("(cancelled)")
1744
+ state.question = null
1745
+ state.status = "Processing..."
1746
+ render()
1747
+ } else if (key.name === "up") {
1748
+ q.selected = Math.max(0, (q.selected ?? 0) - 1)
1749
+ render()
1750
+ } else if (key.name === "down") {
1751
+ q.selected = Math.min(q.options.length - 1, (q.selected ?? 0) + 1)
1752
+ render()
1753
+ } else if (key.name === "return") {
1754
+ const answer = q.options[q.selected ?? 0]
1755
+ q.resolve(answer)
1756
+ state.question = null
1757
+ state.status = "Processing..."
1758
+ pushLine(` → ${answer}`, C.tool)
1759
+ render()
1760
+ }
1761
+ } else {
1762
+ // 自由文本:键入答案,Enter 提交,Esc 取消
1763
+ if (key.name === "escape") {
1764
+ q.resolve("(cancelled)")
1765
+ state.question = null
1766
+ state.status = "Processing..."
1767
+ render()
1768
+ } else if (key.name === "return") {
1769
+ const answer = (q.answer ?? "").trim()
1770
+ q.resolve(answer || "(empty answer)")
1771
+ state.question = null
1772
+ state.status = "Processing..."
1773
+ pushLine(` → ${answer || "(empty)"}`, C.tool)
1774
+ render()
1775
+ } else if (key.name === "backspace") {
1776
+ q.answer = (q.answer ?? "").slice(0, -1)
1777
+ render()
1778
+ } else if (str && !key.ctrl && !key.meta) {
1779
+ q.answer = (q.answer ?? "") + str
1780
+ render()
1781
+ }
1782
+ }
1783
+ return
1784
+ }
1785
+
1377
1786
  if (key.ctrl && key.name === "c") {
1787
+ if (state.processing && state.controller) {
1788
+ state.controller.abort()
1789
+ pushLine("[中止中…]", C.warn)
1790
+ render()
1791
+ return
1792
+ }
1378
1793
  cleanup()
1379
- setTimeout(() => process.exit(0), 100) // 同 /exit:延迟退出避开 libuv 断言
1794
+ setTimeout(() => process.exit(0), 100)
1380
1795
  }
1381
1796
 
1382
1797
  // 模型选择器:↑↓ 移动,Enter 确认,Esc 取消,其余按键吞掉