thincoder 0.2.0 → 0.4.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,18 @@
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
+ import { estimateTokens } from "./context.mjs"
12
13
  import { saveSession, clearSession } from "./session.mjs"
13
14
  import { PROVIDER_PRESETS as PRESETS } from "./config.mjs"
15
+ import { closeAllMcp } from "./mcp.mjs"
14
16
 
15
17
  // ---------------------------------------------------------------- ANSI 工具
16
18
 
@@ -143,11 +145,29 @@ function renderTable(block, width) {
143
145
  widths[widest]--
144
146
  }
145
147
 
146
- const fmtRow = (cells) =>
147
- "│ " + cells.map((c, i) => padByWidth(sliceByWidth(c, widths[i]), widths[i])).join(" │ ") + " │"
148
+ // 单元格渲染:sliceByWidth 截断(表头单行),padByWidth 补齐
149
+ const fmtCell = (text, ci) => padByWidth(sliceByWidth(text, widths[ci]), widths[ci])
150
+ const fmtRow = (cells) => "│ " + cells.map((c, i) => fmtCell(c, i)).join(" │ ") + " │"
151
+
152
+ // 分隔线
148
153
  const separator = "├" + widths.map((w) => "─".repeat(w + 2)).join("┼") + "┤"
149
154
 
150
- return [fmtRow(rows[0]), separator, ...rows.slice(2).map(fmtRow)]
155
+ const out = []
156
+ // 表头:单行截断(表头通常是短标签,折行不如截断直观)
157
+ out.push(fmtRow(rows[0]))
158
+ out.push(separator)
159
+
160
+ // 数据行:过长单元格按列宽折行,一个逻辑行可能对应多条显示行
161
+ for (let r = 2; r < rows.length; r++) {
162
+ // wrapText 返回按 width 折行后的行数组,保留内部 \n
163
+ const wrapped = rows[r].map((cell, ci) => wrapText(cell, widths[ci]))
164
+ const height = Math.max(...wrapped.map((lines) => lines.length))
165
+ for (let lineIdx = 0; lineIdx < height; lineIdx++) {
166
+ out.push(fmtRow(wrapped.map((lines) => lines[lineIdx] ?? "")))
167
+ }
168
+ }
169
+
170
+ return out
151
171
  }
152
172
 
153
173
  /** 输入区布局:把输入缓冲折行,同时算出光标的 (行, 列) 位置(显示宽度) */
@@ -228,13 +248,17 @@ export async function startTUI(agent, opts = {}) {
228
248
  historyIndex: -1,
229
249
  scroll: 0, // 从底部向上的滚动行数
230
250
  processing: false,
251
+ controller: null, // AbortController for current agent run
231
252
  permission: null, // { name, args, resolve }
253
+ question: null, // { text, options, resolve } — agent 的 question 工具回调
232
254
  picker: null, // 模型选择器 { entries, lines, index, scroll, selectedLine }
233
255
  wizard: null, // 首次配置向导 { step, index, scroll, selectedLine, fields, error, lines }
234
- tasks: [], // task 工具的任务列表(状态栏显示进度)
256
+ tasks: agent.tasks ?? [], // task 工具的任务列表(状态栏显示进度);会话恢复时直接带上
257
+ tokens: { prompt: 0, completion: 0, cacheHit: 0, cacheMiss: 0 }, // 累计 token 用量(状态栏显示)
258
+ ctxCache: { len: -1, tokens: 0 }, // 上下文占用估算缓存(estimateTokens 是 O(n),history 变长才重算)
235
259
  reasoning: "", // 思考流缓冲(暗色展示)
236
260
  completion: null, // Tab 补全状态 { candidates, index }
237
- toolStream: "", // 当前工具的实时输出(暗色展示,bash 流式)
261
+ toolStreams: {}, // 各工具的实时输出(按工具名隔离,并行工具互不串扰)
238
262
  currentTool: null, // 正在执行的工具名(状态栏显示)
239
263
  processingStarted: 0, // 本轮处理开始时间(状态栏计时)
240
264
  status: "Ready",
@@ -284,6 +308,12 @@ export async function startTUI(agent, opts = {}) {
284
308
  } catch {
285
309
  // 存失败不耽误退出
286
310
  }
311
+ // 关闭 MCP stdio 子进程,不留孤儿
312
+ try {
313
+ closeAllMcp(agent)
314
+ } catch {
315
+ // 关不掉就算了,进程马上退出
316
+ }
287
317
  process.stdin.setRawMode(false)
288
318
  process.stdout.write(ansi.mouseOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
289
319
  }
@@ -345,7 +375,15 @@ export async function startTUI(agent, opts = {}) {
345
375
  inputOffset = Math.min(layout.cursorLine, layout.lines.length - MAX_INPUT_LINES)
346
376
  }
347
377
  const inputLines = layout.lines.slice(inputOffset, inputOffset + MAX_INPUT_LINES)
348
- const inputBoxH = inputLines.length + 2
378
+ // question 模式下输入框显示选项/答案草稿,而不是普通输入(高度也要跟着走)
379
+ let boxLines = inputLines
380
+ if (state.question) {
381
+ const q = state.question
382
+ boxLines = q.options.length > 0
383
+ ? q.options.map((opt, i) => (i === (q.selected ?? 0) ? "▸ " : " ") + opt)
384
+ : ["▸ " + (q.answer ?? "")]
385
+ }
386
+ const inputBoxH = boxLines.length + 2
349
387
 
350
388
  const headerH = 1
351
389
  const statusH = 1
@@ -354,7 +392,20 @@ export async function startTUI(agent, opts = {}) {
354
392
  const pickerH = overlay
355
393
  ? Math.min(overlay.lines.length + 1, Math.max(6, rows - 12))
356
394
  : 0
357
- const convH = Math.max(1, rows - headerH - inputBoxH - statusH - pickerH)
395
+ // todo 面板:有任务列表时占对话区与输入框之间最多 5
396
+ // 折叠时优先 in_progress,兼顾最早的 pending 和最近的 done
397
+ const MAX_TASK_LINES = 5
398
+ let visibleTasks = []
399
+ if (state.tasks.length <= MAX_TASK_LINES) {
400
+ visibleTasks = state.tasks
401
+ } else {
402
+ const inProgress = state.tasks.filter((t) => t.status === "in_progress")
403
+ const pending = state.tasks.filter((t) => t.status === "pending")
404
+ const done = state.tasks.filter((t) => t.status === "done")
405
+ visibleTasks = [...inProgress, ...pending, ...done].slice(0, MAX_TASK_LINES)
406
+ }
407
+ const taskPanelH = visibleTasks.length
408
+ const convH = Math.max(1, rows - headerH - inputBoxH - statusH - pickerH - taskPanelH)
358
409
 
359
410
  // 对话区内容行(含流式缓冲);markdown 表格先按显示宽度重排
360
411
  const convLines = []
@@ -378,9 +429,10 @@ export async function startTUI(agent, opts = {}) {
378
429
  }
379
430
  }
380
431
  }
381
- // 工具实时输出(暗色,只保留末尾防刷屏)
382
- if (state.toolStream) {
383
- const tail = state.toolStream.slice(-4000)
432
+ // 工具实时输出(暗色,只保留末尾防刷屏;按工具名隔离防止并行工具串扰)
433
+ const allStreams = Object.values(state.toolStreams).join("")
434
+ if (allStreams) {
435
+ const tail = allStreams.slice(-4000)
384
436
  for (const wrapped of wrapText(tail, cols - 1)) {
385
437
  convLines.push({ text: wrapped, color: C.dim })
386
438
  }
@@ -393,9 +445,9 @@ export async function startTUI(agent, opts = {}) {
393
445
 
394
446
  const out = [ansi.home]
395
447
 
396
- // header
448
+ // header(超宽截断,防终端折行)
397
449
  out.push(
398
- `${ansi.bold}${C.tool} ThinCoder ${ansi.reset}${ansi.dim}│ ${model}${thinkBadge ? " " + thinkBadge : ""} │ ${basename(agent.cwd)}${ansi.reset}${ansi.clearLine}`,
450
+ `${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
451
  )
400
452
 
401
453
  // 对话区(不足部分补空行,把输入框钉在底部)
@@ -420,20 +472,38 @@ export async function startTUI(agent, opts = {}) {
420
472
  for (let i = shown.length; i < winH; i++) out.push(ansi.clearLine)
421
473
  }
422
474
 
475
+ // todo 面板(对话区与输入框之间):▶ in_progress / ✓ done(删除线) / ○ pending
476
+ for (const t of visibleTasks) {
477
+ const mark = t.status === "done" ? "✓" : t.status === "in_progress" ? "▶" : "○"
478
+ const color = t.status === "done" ? `${C.dim}${ESC}[9m` : t.status === "in_progress" ? C.tool : C.text
479
+ out.push(`${color} ${mark} ${sliceByWidth(t.title, cols - 4)}${ansi.reset}${ansi.clearLine}`)
480
+ }
481
+
423
482
  // 输入框(全边框,宽 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 "
483
+ let borderColor = C.tool
484
+ let title
485
+ if (state.question) {
486
+ borderColor = C.tool
487
+ title = ` ${sliceByWidth(state.question.text, W - 6)} `
488
+ } else if (state.permission) {
489
+ borderColor = C.warn
490
+ if (state.permission.name === "continue") {
491
+ title = " Continue? (y/n) "
492
+ } else {
493
+ title = ` Allow ${state.permission.name}? (y/n/a) `
494
+ }
495
+ } else if (state.picker) {
496
+ title = " Model "
497
+ } else if (state.wizard) {
498
+ title = " Setup "
499
+ } else if (state.processing) {
500
+ title = " Processing... "
501
+ } else {
502
+ title = " Input "
503
+ }
434
504
  const topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 3 - stringWidth(title)))}╮`
435
505
  out.push(`${borderColor}${topBorder}${ansi.reset}${ansi.clearLine}`)
436
- for (const l of inputLines) {
506
+ for (const l of boxLines) {
437
507
  const content = sliceByWidth(l, W - 4)
438
508
  const fill = " ".repeat(Math.max(0, W - 4 - stringWidth(content)))
439
509
  out.push(`${borderColor}│${ansi.reset} ${content}${fill} ${borderColor}│${ansi.reset}${ansi.clearLine}`)
@@ -444,8 +514,15 @@ export async function startTUI(agent, opts = {}) {
444
514
  const scrollHint = state.scroll > 0 ? ` │ scrolled ${state.scroll}` : ""
445
515
  const rawInput = state.input.join("")
446
516
  let statusLine
447
- if (state.permission) {
448
- statusLine = " y: 批准 │ n: 拒绝 │ a: 批准并全部放行(AUTO)"
517
+ if (state.question) {
518
+ const q = state.question
519
+ statusLine = q.options.length > 0
520
+ ? " ↑↓: 选择 │ Enter: 确认 │ Esc: 取消"
521
+ : " 输入回答后 Enter 提交 │ Esc: 取消"
522
+ } else if (state.permission) {
523
+ statusLine = state.permission.name === "continue"
524
+ ? " y: 继续 │ n: 停止"
525
+ : " y: 批准 │ n: 拒绝 │ a: 批准并全部放行(AUTO)"
449
526
  } else if (state.picker) {
450
527
  statusLine = " ↑↓: 选择 │ Enter: 确认 │ Esc: 取消"
451
528
  } else if (state.wizard) {
@@ -457,11 +534,11 @@ export async function startTUI(agent, opts = {}) {
457
534
  const cmds = SLASH_COMMANDS.filter((c) => c.name.startsWith(cmd))
458
535
  const match = cmds.length === 1 ? cmds[0] : null
459
536
  if (match?.name === "/config" && cmd === "/config") {
460
- statusLine = " /config 查看 │ key / embedkey 配 key"
537
+ statusLine = " /config 查看 │ embedkey 配 embedding │ set 改参数"
538
+ } else if (match?.name === "/provider" && cmd === "/provider") {
539
+ statusLine = " /provider 列表 │ add / remove / key"
461
540
  } else if (match?.name === "/model" && cmd === "/model" && !sub) {
462
541
  statusLine = " /model 打开选择器 │ /model <名称> 直接切换"
463
- } else if (match?.name === "/provider" && cmd === "/provider") {
464
- statusLine = " /provider 列表 │ add / remove / key 管理"
465
542
  } else if (match?.name === "/think" && cmd === "/think") {
466
543
  statusLine = " /think 查看 │ on / off 开关 │ effort high / max 强度"
467
544
  } else if (cmds.length > 0) {
@@ -477,15 +554,36 @@ export async function startTUI(agent, opts = {}) {
477
554
  const taskHint = state.tasks.length > 0
478
555
  ? ` │ ▶${state.tasks.filter((t) => t.status === "done").length}/${state.tasks.length}`
479
556
  : ""
557
+ // token 用量:↑输入 ↓输出 + 缓存命中率(DeepSeek usage 带 prompt_cache_hit/miss_tokens)
558
+ const tk = state.tokens
559
+ const fmtK = (n) => (n >= 10000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`)
560
+ const cacheTotal = tk.cacheHit + tk.cacheMiss
561
+ const tokenHint = tk.prompt > 0
562
+ ? ` │ ↑${fmtK(tk.prompt)} ↓${fmtK(tk.completion)}${cacheTotal > 0 ? ` hit${Math.round((tk.cacheHit / cacheTotal) * 100)}%` : ""}`
563
+ : ""
480
564
  const elapsed = state.processing ? ` ${Math.floor((Date.now() - state.processingStarted) / 1000)}s` : ""
481
565
  const toolHint = state.currentTool ? ` ${state.currentTool}…` : ""
482
566
  const statusText = state.processing ? `${state.status}${toolHint}${elapsed}` : state.status
483
- statusLine = ` ${statusText}${taskHint}${scrollHint} Enter: send │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+C: exit`
567
+ // 上下文利用率:占压缩阈值百分比(到 100% 触发压缩;≥80% 变黄提醒该收尾或 /new)
568
+ if (state.ctxCache.len !== agent.history.length) {
569
+ state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
570
+ }
571
+ const ctxThreshold = agent.config?.agent?.compactThreshold ?? 100_000
572
+ const ctxPct = Math.round((state.ctxCache.tokens / ctxThreshold) * 100)
573
+ const ctxHint = ctxPct > 0
574
+ ? ctxPct >= 80
575
+ ? ` │ ${ansi.reset}${C.warn}ctx ${ctxPct}%${ansi.reset}${ansi.dim}`
576
+ : ` │ ctx ${ctxPct}%`
577
+ : ""
578
+ statusLine = ` ${statusText}${taskHint}${tokenHint}${ctxHint}${scrollHint} │ Enter: send │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+C: exit`
484
579
  }
485
580
  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}`)
581
+ const planBanner = agent.planMode ? `${C.tool} PLAN${ansi.reset}${ansi.dim}│` : ""
582
+ // 状态栏最多一行:终端宽度扣掉 banner 前缀的可视列数,防折行
583
+ const bannerPrefix = (agent.planMode ? " PLAN│ " : "") + (agent.autoApprove ? " AUTO│ " : "")
584
+ const statusMax = cols - 1 - (bannerPrefix ? stringWidth(bannerPrefix) : 0)
585
+ statusLine = sliceByWidth(statusLine, Math.max(10, statusMax))
586
+ out.push(`${ansi.dim}${planBanner}${autoBanner}${statusLine}${ansi.reset}${ansi.clearLine}`)
489
587
 
490
588
  const frame = out.join("\r\n")
491
589
  if (frame !== lastFrame) {
@@ -494,10 +592,10 @@ export async function startTUI(agent, opts = {}) {
494
592
  }
495
593
 
496
594
  // 光标:输入态定位到输入框内(IME 候选框跟随真实光标);处理中/权限确认/菜单态时隐藏
497
- if (state.processing || state.permission || state.picker || state.wizard?.step === "provider") {
595
+ if (state.processing || state.permission || state.question || state.picker || state.wizard?.step === "provider") {
498
596
  process.stdout.write(ansi.hideCursor)
499
597
  } else {
500
- const cursorRow = 1 + convH + 2 + (layout.cursorLine - inputOffset) // header + 对话区 + 上边框 + 行偏移
598
+ const cursorRow = 1 + convH + taskPanelH + 2 + (layout.cursorLine - inputOffset) // header + 对话区 + todo 面板 + 上边框 + 行偏移
501
599
  const cursorCol = 3 + layout.cursorCol // 左边框 + 空格 + 文本偏移(1 基)
502
600
  process.stdout.write(`${ESC}[${cursorRow};${cursorCol}H${ansi.showCursor}`)
503
601
  }
@@ -540,63 +638,113 @@ export async function startTUI(agent, opts = {}) {
540
638
  state.reasoning = ""
541
639
  state.currentTool = null
542
640
  state.processingStarted = Date.now()
641
+ state.controller = new AbortController()
543
642
  // 处理中每秒刷新一次状态栏(运行计时)
544
643
  const ticker = setInterval(() => {
545
644
  if (state.processing) render()
546
645
  }, 1000)
547
646
  render()
548
647
 
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 = ""
648
+ const callbacks = {
649
+ onToken: (t) => {
650
+ ensureAssistantLabel()
651
+ state.streaming += t
652
+ scheduleRender()
653
+ },
654
+ onReasoning: (t) => {
655
+ ensureAssistantLabel()
656
+ state.reasoning += t
657
+ scheduleRender()
658
+ },
659
+ onToolCall: (name, args) => {
660
+ flushStream()
661
+ ensureAssistantLabel()
662
+ state.currentTool = name
663
+ pushLine(` [tool] ${name} ${summarize(args)}`, C.tool)
664
+ },
665
+ onToolResult: (name, result) => {
666
+ state.currentTool = null
667
+ const stream = state.toolStreams[name]
668
+ if (stream) {
669
+ const tail = stream.trimEnd().slice(-4000)
670
+ if (tail) pushLine(tail, C.dim)
671
+ delete state.toolStreams[name]
672
+ }
673
+ const first = result.split("\n")[0]
674
+ pushLine(` [done] ${name} → ${sliceByWidth(first, 100)}`, C.dim)
675
+ },
676
+ onToolOutput: (name, chunk) => {
677
+ state.toolStreams[name] = (state.toolStreams[name] ?? "") + chunk
678
+ scheduleRender()
679
+ },
680
+ onPermissionRequest: (name, args) => askPermission(name, args),
681
+ onQuestion: (text, options) => askQuestion(text, options),
682
+ onCompress: () => {
683
+ pushLine(" [context] 上下文过长,已自动压缩(早期对话由 LLM 摘要,任务状态保留)", C.warn)
684
+ },
685
+ onUsage: (usage) => {
686
+ state.tokens.prompt += usage.prompt_tokens ?? 0
687
+ state.tokens.completion += usage.completion_tokens ?? 0
688
+ state.tokens.cacheHit += usage.prompt_cache_hit_tokens ?? 0
689
+ state.tokens.cacheMiss += usage.prompt_cache_miss_tokens ?? 0
690
+ },
691
+ onTaskUpdate: (items) => {
692
+ state.tasks = items
693
+ const done = items.filter((i) => i.status === "done").length
694
+ // 留痕带上当前任务标题:回看历史时知道进行到哪一项
695
+ const current = items.find((i) => i.status === "in_progress")
696
+ pushLine(` [task] ${done}/${items.length}${current ? ` ▶ ${current.title}` : ""}`, C.dim)
697
+ render()
698
+ },
699
+ }
700
+
701
+ for (let resume = false; ; resume = true) {
702
+ try {
703
+ await runAgent(agent, text, callbacks, { signal: state.controller.signal, resume })
704
+ flushStream()
705
+ break // 正常完成,退出循环
706
+ } catch (error) {
707
+ flushStream()
708
+ if (error.name === "AbortError" || state.controller?.signal.aborted) {
709
+ pushLine("[已中止]", C.warn)
710
+ break
711
+ }
712
+ if (error instanceof ContinueError) {
713
+ pushLabel(`❯ Continue`, ansi.bold + C.warn)
714
+ pushLine(`已执行 ${error.turn} 轮(上限 ${error.turn}),要继续吗?`, C.warn)
715
+ // 暂停询问:复用 permission 机制
716
+ const willContinue = await new Promise((resolve) => {
717
+ state.permission = {
718
+ name: "continue",
719
+ args: { turns: error.turn },
720
+ resolve,
721
+ }
722
+ state.status = `Continue after ${error.turn} turns?`
723
+ render()
724
+ })
725
+ state.permission = null
726
+ if (!willContinue) {
727
+ pushLine("[已取消继续]", C.warn)
728
+ break
574
729
  }
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)
730
+ pushLine("[继续执行…]", C.tool)
731
+ // 重创新 AbortController:旧 signal 一旦 abort 过,resume 会立即失败(防御性,当前路径不可达但耦合紧)
732
+ state.controller = new AbortController()
733
+ continue
734
+ }
735
+ pushLine(`[error] ${error.message}`, C.error)
736
+ break
737
+ }
596
738
  }
597
739
 
740
+ clearInterval(ticker)
598
741
  state.processing = false
742
+ state.controller = null
599
743
  state.status = "Ready"
744
+ // 全部完成时自动收起 todo 面板(对齐 kimi-code TUI;agent.tasks 本身保留)
745
+ if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
746
+ state.tasks = []
747
+ }
600
748
  // 每轮结束后保存会话(崩溃也不丢)
601
749
  try {
602
750
  saveSession(agent)
@@ -624,8 +772,9 @@ export async function startTUI(agent, opts = {}) {
624
772
  return Promise.resolve(true)
625
773
  }
626
774
  // 把关键参数摆出来:批什么要让人看明白
775
+ // 内容行用警告色——与正常输出(白)区分,滚动回看也能认出这是待审批内容
627
776
  pushLabel(`❯ 权限请求`, ansi.bold + C.warn)
628
- for (const line of formatPermission(name, args)) pushLine(` ${line}`, C.text)
777
+ for (const line of formatPermission(name, args)) pushLine(` ${line}`, C.warn)
629
778
  return new Promise((resolve) => {
630
779
  state.permission = { name, args, resolve }
631
780
  state.status = `Waiting: ${name}`
@@ -637,28 +786,70 @@ export async function startTUI(agent, opts = {}) {
637
786
  function formatPermission(name, args) {
638
787
  const cap = (s, n = 1000) => (s.length > n ? `${s.slice(0, n)}…(共 ${s.length} 字符)` : s)
639
788
  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} 字符)`]
789
+ if (name === "write") {
790
+ // 批准写文件必须看得到要写什么:路径 + 内容预览
791
+ return [`${args.path}(写入 ${(args.content ?? "").length} 字符)`, ...cap(args.content ?? "", 1000).split("\n")]
792
+ }
793
+ if (name === "edit") {
794
+ // 简易 diff:- 旧内容 / + 新内容
795
+ return [
796
+ `${args.path}`,
797
+ ...cap(args.old_string ?? "", 500).split("\n").map((l) => `- ${l}`),
798
+ " ↓",
799
+ ...cap(args.new_string ?? "", 500).split("\n").map((l) => `+ ${l}`),
800
+ ]
801
+ }
802
+ if (name === "delete") return [`${args.path}${args.force ? "(force:跟踪文件也删)" : ""}`]
642
803
  if (name === "subagent") return cap(args.task ?? "", 500).split("\n")
643
- if (name === "memory_put") return [`[${args.type ?? ""}] ${args.title ?? ""}`]
804
+ if (name === "memory_put") return [`[${args.type ?? ""}] ${args.title ?? ""}`, ...cap(args.content ?? "", 500).split("\n")]
644
805
  return [cap(summarize(args), 300)]
645
806
  }
646
807
 
808
+ function askQuestion(text, options = []) {
809
+ // 一次只能问一个:question 是只读工具走并行通道,同批第二个直接驳回,
810
+ // 否则后到的会覆盖 state.question,先到的 Promise 永远悬挂(agent 死等)
811
+ if (state.question) {
812
+ return Promise.resolve("(error: 已有问题在等待回答;请一次只问一个,得到答复后再问下一个)")
813
+ }
814
+ if (!options.length) {
815
+ // 自由文本:打开输入态让用户打字,Enter 提交
816
+ pushLabel(`❯ Question`, ansi.bold + C.tool)
817
+ for (const line of text.split("\n")) pushLine(` ${line}`, C.text)
818
+ return new Promise((resolve) => {
819
+ state.question = { text, options: [], resolve }
820
+ state.status = "Waiting for answer..."
821
+ render()
822
+ })
823
+ }
824
+ // 选项模式:输入框内显示列表,方向键选,Enter 确认
825
+ pushLabel(`❯ Question`, ansi.bold + C.tool)
826
+ for (const line of text.split("\n")) pushLine(` ${line}`, C.text)
827
+ return new Promise((resolve) => {
828
+ state.question = { text, options, selected: 0, resolve }
829
+ state.status = "Waiting for choice..."
830
+ render()
831
+ })
832
+ }
833
+
647
834
  // ---------------------------------------------------------- 斜杠命令
648
835
 
649
836
  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: "退出" },
837
+ { name: "/plan", group: "Agent", desc: "规划模式(先设计、再实现)" },
838
+ { name: "/auto", group: "Agent", desc: "自动授权开关" },
839
+ { name: "/model", group: "Agent", desc: "选择模型" },
840
+ { name: "/goal", group: "Agent", desc: "设置/查看/取消长期目标" },
841
+ { name: "/think", group: "Agent", desc: "思维模式与推理强度" },
842
+ { name: "/skills", group: "Tools", desc: "列出项目技能" },
843
+ { name: "/mcp", group: "Tools", desc: "管理 MCP server" },
844
+ { name: "/provider", group: "Config", desc: "管理 provider(增/删/配 key)" },
845
+ { name: "/config", group: "Config", desc: "配置管理(embedding / agent)" },
846
+ { name: "/reindex", group: "Config", desc: "重建记忆索引" },
847
+ { name: "/new", group: "Session", desc: "开始新会话" },
848
+ { name: "/clear", group: "Session", desc: "清屏" },
849
+ { name: "/distill", group: "Session", desc: "从会话提取知识" },
850
+ { name: "/rewind", group: "Session", desc: "回滚到存档点" },
851
+ { name: "/exit", group: "Session", desc: "退出" },
852
+ { name: "/help", group: "", desc: "此列表" },
662
853
  ]
663
854
 
664
855
  async function handleSlash(text) {
@@ -672,6 +863,9 @@ export async function startTUI(agent, opts = {}) {
672
863
  case "/new":
673
864
  agent.history = []
674
865
  agent.tasks = []
866
+ agent.planMode = false
867
+ agent.goal = null
868
+ agent._pendingReminders = []
675
869
  state.tasks = []
676
870
  state.lines = []
677
871
  state.streaming = ""
@@ -732,8 +926,200 @@ export async function startTUI(agent, opts = {}) {
732
926
  }
733
927
  return
734
928
  }
929
+ case "/plan": {
930
+ agent.planMode = !agent.planMode
931
+ agent._pendingReminders = agent._pendingReminders ?? []
932
+ if (agent.planMode) {
933
+ 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.]")
934
+ } else {
935
+ agent._pendingReminders.push("[System reminder: plan mode is now OFF. You may edit files, run commands, and implement changes.]")
936
+ }
937
+ pushLabel(`❯ Plan`, ansi.bold + (agent.planMode ? C.tool : C.dim))
938
+ pushLine(
939
+ agent.planMode
940
+ ? `规划模式已开启:只读工具受限,先设计方案再实现。再次 /plan 退出。`
941
+ : `规划模式已关闭:可以编辑文件和执行命令了。`,
942
+ agent.planMode ? C.tool : C.dim,
943
+ )
944
+ return
945
+ }
946
+ case "/goal": {
947
+ const sub = rest[0]
948
+ if (sub === "set") {
949
+ const text = rest.slice(1).join(" ")
950
+ if (!text) { pushLine("用法: /goal set <目标描述>(; 分隔完成条件)", C.error); return }
951
+ const semi = text.indexOf(";") >= 0 ? ";" : text.indexOf(";") >= 0 ? ";" : null
952
+ const objective = semi ? text.slice(0, semi).trim() : text.trim()
953
+ const criteria = semi ? text.slice(semi + 1).trim() : ""
954
+ agent.goal = { objective, criteria, setAt: Date.now() }
955
+ pushLabel(`❯ Goal`, ansi.bold + C.warn)
956
+ pushLine(`目标已设置: ${objective}`, C.tool)
957
+ if (criteria) pushLine(` 完成条件: ${criteria}`, C.dim)
958
+ return
959
+ }
960
+ if (sub === "cancel") {
961
+ agent.goal = null
962
+ pushLabel(`❯ Goal`, ansi.bold + C.dim)
963
+ pushLine(`目标已取消。`, C.dim)
964
+ return
965
+ }
966
+ if (agent.goal) {
967
+ pushLabel(`❯ Goal`, ansi.bold + C.warn)
968
+ pushLine(`目标: ${agent.goal.objective}`, C.tool)
969
+ if (agent.goal.criteria) pushLine(` 完成条件: ${agent.goal.criteria}`, C.dim)
970
+ pushLine(` 设置于: ${new Date(agent.goal.setAt).toLocaleString()}`, C.dim)
971
+ pushLine("操作: /goal set <描述> 覆盖 | /goal cancel 取消", C.dim)
972
+ } else {
973
+ pushLabel(`❯ Goal`, ansi.bold + C.dim)
974
+ pushLine("(无活跃目标——/goal set <描述> 设置)", C.dim)
975
+ }
976
+ return
977
+ }
978
+ case "/skills": {
979
+ const { loadSkills } = await import("./skills.mjs")
980
+ const skills = await loadSkills(agent.cwd)
981
+ pushLabel(`❯ Skills`, ansi.bold + C.tool)
982
+ if (skills.length === 0) {
983
+ pushLine("(无项目技能——在 .thincoder/skills/ 下创建 .md 文件即可添加)", C.dim)
984
+ }
985
+ for (const s of skills) {
986
+ pushLine(` ${s.name}: ${s.description.slice(0, 100)}`, C.dim)
987
+ }
988
+ pushLine("激活: 告诉 agent \"load the <name> skill\"", C.dim)
989
+ return
990
+ }
991
+ case "/mcp": {
992
+ const sub = rest[0]
993
+ // ---- /mcp list — 列出配置的 servers + 连接状态 ----
994
+ if (!sub || sub === "list") {
995
+ const servers = agent.config?.mcp?.servers ?? []
996
+ pushLabel(`❯ MCP Servers`, ansi.bold + C.tool)
997
+ if (servers.length === 0) {
998
+ pushLine("(无 MCP server——使用 /mcp add <name> <command> [args] 添加)", C.dim)
999
+ }
1000
+ for (const srv of servers) {
1001
+ const connected = agent.tools.some((t) => t._mcpName === srv.name)
1002
+ const mark = connected ? "●" : "○"
1003
+ const color = connected ? C.tool : C.dim
1004
+ const toolCount = agent.tools.filter((t) => t._mcpName === srv.name).length
1005
+ const desc = srv.url ? srv.url : `${srv.command} ${(srv.args ?? []).join(" ")}`
1006
+ pushLine(` ${mark} ${srv.name}: ${desc} (${toolCount} tools)`, color)
1007
+ }
1008
+ pushLabel(`❯ 操作`, ansi.bold + C.tool)
1009
+ pushLine(`/mcp add <name> <command> [args...] 添加 stdio server`, C.dim)
1010
+ pushLine(`/mcp url <name> <url> [headers...] 添加 HTTP server`, C.dim)
1011
+ pushLine(`/mcp remove <name> 断开并移除 server`, C.dim)
1012
+ pushLine(`/mcp connect <name> 重连已配置的 server`, C.dim)
1013
+ pushLine("配置持久化到 config.json 的 mcp.servers[]", C.dim)
1014
+ return
1015
+ }
1016
+ // ---- /mcp add <name> <command> [args...] (stdio) ----
1017
+ if (sub === "add") {
1018
+ const args = rest.slice(1)
1019
+ if (args.length < 2) {
1020
+ pushLine("用法: /mcp add <name> <command> [args...]", C.error)
1021
+ pushLine(" 例: /mcp add github npx -y @modelcontextprotocol/server-github", C.dim)
1022
+ return
1023
+ }
1024
+ const name = args[0]
1025
+ const command = args[1]
1026
+ const cmdArgs = args.slice(2)
1027
+ const existing = (agent.config?.mcp?.servers ?? []).find((s) => s.name === name)
1028
+ if (existing) { pushLine(`[mcp] "${name}" 已存在,用 /mcp remove ${name} 先移除`, C.error); return }
1029
+ const srv = { name, command, args: cmdArgs.length > 0 ? cmdArgs : undefined }
1030
+ await addAndConnect(srv)
1031
+ return
1032
+ }
1033
+ // ---- /mcp url <name> <url> [key=value...] (HTTP) ----
1034
+ if (sub === "url") {
1035
+ const args = rest.slice(1)
1036
+ if (args.length < 2) {
1037
+ pushLine("用法: /mcp url <name> <url> [header=value...]", C.error)
1038
+ pushLine(" 例: /mcp url myapi https://api.example.com/mcp Authorization=\"Bearer token123\"", C.dim)
1039
+ return
1040
+ }
1041
+ const name = args[0]
1042
+ const url = args[1]
1043
+ const headerPairs = args.slice(2)
1044
+ const existing = (agent.config?.mcp?.servers ?? []).find((s) => s.name === name)
1045
+ if (existing) { pushLine(`[mcp] "${name}" 已存在,用 /mcp remove ${name} 先移除`, C.error); return }
1046
+ const headers = {}
1047
+ for (const pair of headerPairs) {
1048
+ const eq = pair.indexOf("=")
1049
+ if (eq > 0) headers[pair.slice(0, eq)] = pair.slice(eq + 1).replace(/^["']|["']$/g, "")
1050
+ }
1051
+ const srv = { name, url, headers: Object.keys(headers).length > 0 ? headers : undefined }
1052
+ await addAndConnect(srv)
1053
+ return
1054
+ }
1055
+ // ---- /mcp remove <name> ----
1056
+ if (sub === "remove") {
1057
+ const name = rest[1]
1058
+ if (!name) { pushLine("用法: /mcp remove <name>", C.error); return }
1059
+ const { removeMcpTools } = await import("./mcp.mjs")
1060
+ removeMcpTools(agent, name)
1061
+ await persistRaw((raw) => { raw.mcp ??= { servers: [] }; raw.mcp.servers = raw.mcp.servers.filter((s) => s.name !== name) })
1062
+ if (agent.config?.mcp?.servers) agent.config.mcp.servers = agent.config.mcp.servers.filter((s) => s.name !== name)
1063
+ pushLabel(`❯ MCP`, ansi.bold + C.tool)
1064
+ pushLine(`${name} 已断开并从配置移除。`, C.tool)
1065
+ return
1066
+ }
1067
+ // ---- /mcp connect <name> — 重连 ----
1068
+ if (sub === "connect") {
1069
+ const name = rest[1]
1070
+ if (!name) { pushLine("用法: /mcp connect <name>", C.error); return }
1071
+ const srv = (agent.config?.mcp?.servers ?? []).find((s) => s.name === name)
1072
+ if (!srv) { pushLine(`[mcp] "${name}" 未在配置中找到(先用 /mcp add 或 /mcp url)`, C.error); return }
1073
+ const { removeMcpTools, connectMcpServer } = await import("./mcp.mjs")
1074
+ removeMcpTools(agent, name)
1075
+ try {
1076
+ pushLine(`[mcp] 重连 ${name}...`, C.dim)
1077
+ const tools = await connectMcpServer(srv)
1078
+ agent.tools.push(...tools)
1079
+ pushLabel(`❯ MCP`, ansi.bold + C.tool)
1080
+ pushLine(`${name} 已重连,${tools.length} 个工具可用。`, C.tool)
1081
+ } catch (error) {
1082
+ pushLine(`[mcp] ${name}: ${error.message}`, C.error)
1083
+ }
1084
+ return
1085
+ }
1086
+ pushLine(`未知子命令: ${sub}(/mcp list | add | url | remove | connect)`, C.error)
1087
+ return
1088
+ }
1089
+
1090
+ // ---- /mcp 共享 helper: 保存配置 + 连接 ----
1091
+ async function addAndConnect(srv) {
1092
+ await persistRaw((raw) => {
1093
+ raw.mcp ??= { servers: [] }
1094
+ const entry = { name: srv.name }
1095
+ if (srv.url) { entry.url = srv.url; if (srv.headers) entry.headers = srv.headers }
1096
+ else { entry.command = srv.command; if (srv.args) entry.args = srv.args }
1097
+ raw.mcp.servers.push(entry)
1098
+ })
1099
+ agent.config ??= {}
1100
+ agent.config.mcp ??= { servers: [] }
1101
+ agent.config.mcp.servers.push(srv)
1102
+ try {
1103
+ pushLine(`[mcp] 连接 ${srv.name}...`, C.dim)
1104
+ const { connectMcpServer } = await import("./mcp.mjs")
1105
+ const tools = await connectMcpServer(srv)
1106
+ agent.tools.push(...tools)
1107
+ pushLabel(`❯ MCP`, ansi.bold + C.tool)
1108
+ const desc = srv.url ? srv.url : `${srv.command} ${(srv.args ?? []).join(" ")}`
1109
+ pushLine(`${srv.name} (${desc}) 已连接,${tools.length} 个工具:`, C.tool)
1110
+ for (const t of tools) pushLine(` ${t.name}: ${t.description.slice(0, 100)}`, C.dim)
1111
+ } catch (error) {
1112
+ pushLine(`[mcp] ${srv.name}: ${error.message}(配置已保存,重启后重试)`, C.error)
1113
+ }
1114
+ }
735
1115
  case "/auto":
736
1116
  agent.autoApprove = !agent.autoApprove
1117
+ agent._pendingReminders = agent._pendingReminders ?? []
1118
+ if (agent.autoApprove) {
1119
+ 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.]")
1120
+ } else {
1121
+ 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.]")
1122
+ }
737
1123
  pushLabel(`❯ Auto`, ansi.bold + (agent.autoApprove ? C.warn : C.tool))
738
1124
  pushLine(
739
1125
  agent.autoApprove
@@ -811,16 +1197,17 @@ export async function startTUI(agent, opts = {}) {
811
1197
  if (p) {
812
1198
  agent.activeProvider = arg
813
1199
  agent.provider = { ...p }
814
- // key 的环境变量兜底和 loadConfig 保持一致
1200
+ // key 的环境变量兜底和 loadConfig 保持一致(提供商专用变量只对同名生效)
815
1201
  if (!agent.provider.apiKey) {
816
- agent.provider.apiKey =
817
- process.env.THINCODER_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY
1202
+ const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[arg]
1203
+ if (envKey && process.env[envKey]) agent.provider.apiKey = process.env[envKey]
818
1204
  }
1205
+ if (!agent.provider.apiKey) agent.provider.apiKey = process.env.THINCODER_API_KEY
819
1206
  await persistRaw((raw) => { raw.activeProvider = arg })
820
1207
  agent.config.activeProvider = arg
821
1208
  pushLabel(`❯ Model`, ansi.bold + C.tool)
822
1209
  pushLine(`已切换到 ${arg} / ${p.model}${thresholdNote}(已持久化)`, C.tool)
823
- if (!agent.provider.apiKey) pushLine(`该 provider 还没配 key: /config key <apikey>`, C.warn)
1210
+ if (!agent.provider.apiKey) pushLine(`该 provider 还没配 key: /provider key <apikey>`, C.warn)
824
1211
  } else {
825
1212
  const target = agent.providers.find((pp) => pp.name === agent.activeProvider) ?? agent.providers[0]
826
1213
  if (target) target.model = arg
@@ -852,10 +1239,7 @@ export async function startTUI(agent, opts = {}) {
852
1239
  if (!preset) pushLine(`("${name}" 不是预设;预设: ${Object.keys(PRESETS).join(", ")})`, C.dim)
853
1240
  return
854
1241
  }
855
- if (!/^https?:\/\//.test(baseURL)) {
856
- pushLine(`baseURL 应以 http(s):// 开头`, C.error)
857
- return
858
- }
1242
+ if (!/^https?:\/\//.test(baseURL)) { pushLine(`baseURL 应以 http(s):// 开头`, C.error); return }
859
1243
  agent.providers.push({ name, baseURL, model, ...(preset?.desc ? { desc: preset.desc } : {}) })
860
1244
  await persistRaw((raw) => { raw.providers = agent.providers })
861
1245
  pushLabel(`❯ Provider`, ansi.bold + C.tool)
@@ -869,17 +1253,14 @@ export async function startTUI(agent, opts = {}) {
869
1253
  if (!name) { pushLine("用法: /provider remove <名称>", C.error); return }
870
1254
  const at = agent.providers.findIndex((p) => p.name === name)
871
1255
  if (at < 0) { pushLine(`未找到 provider "${name}"`, C.error); return }
872
- if (name === agent.activeProvider) {
873
- pushLine(`"${name}" 正在使用中,先 /model 切换到别的 provider 再删`, C.warn)
874
- return
875
- }
1256
+ if (name === agent.activeProvider) { pushLine(`"${name}" 正在使用中,先 /model 切换到别的 provider 再删`, C.warn); return }
876
1257
  agent.providers.splice(at, 1)
877
1258
  await persistRaw((raw) => { raw.providers = agent.providers })
878
1259
  pushLabel(`❯ Provider`, ansi.bold + C.tool)
879
1260
  pushLine(`已删除 ${name}`, C.tool)
880
1261
  return
881
1262
  }
882
- // ---- /provider key [名称] <apikey>(不填名称配当前) ----
1263
+ // ---- /provider key [名称] <apikey> ----
883
1264
  if (sub === "key") {
884
1265
  let name = agent.activeProvider
885
1266
  let keyParts = rest.slice(1)
@@ -888,11 +1269,11 @@ export async function startTUI(agent, opts = {}) {
888
1269
  keyParts = rest.slice(2)
889
1270
  }
890
1271
  const key = keyParts.join(" ")
891
- if (!key) { pushLine("用法: /provider key [名称] <apikey>(不填名称则配当前 provider)", C.error); return }
1272
+ if (!key) { pushLine("用法: /provider key [名称] <apikey>(不填名称配当前 provider)", C.error); return }
892
1273
  await setProviderKey(name, key)
893
1274
  return
894
1275
  }
895
- if (sub) { pushLine(`未知参数: ${sub}(可用: add / remove / key)`, C.error); return }
1276
+ if (sub) { pushLine(`未知: ${sub}(/provider add | remove | key)`, C.error); return }
896
1277
  // ---- /provider(无参): 列表 ----
897
1278
  pushLabel(`❯ Providers (${agent.providers.length})`, ansi.bold + C.tool)
898
1279
  for (const p of agent.providers) {
@@ -903,22 +1284,15 @@ export async function startTUI(agent, opts = {}) {
903
1284
  )
904
1285
  }
905
1286
  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)
1287
+ pushLine(`/provider add <名称|预设> <url> <模型> 添加(预设: ${Object.keys(PRESETS).join(" ")})`, C.dim)
1288
+ pushLine(`/provider remove <名称> 删除`, C.dim)
1289
+ if (!agent.provider.apiKey) pushLine("⚡ /provider key <apikey> 当前 provider 还没配 key", C.warn)
1290
+ else pushLine("/provider key [名称] <apikey> 设置/更换 key", C.dim)
909
1291
  return
910
1292
  }
911
1293
  case "/config": {
912
1294
  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(向量检索) ----
1295
+ // ---- /config embedkey <apikey>:embedding 服务的 key ----
922
1296
  if (sub === "embedkey") {
923
1297
  const key = rest.slice(1).join(" ")
924
1298
  if (!key) { pushLine("用法: /config embedkey <apikey>(embedding 服务,默认 SiliconFlow bge-m3)", C.error); return }
@@ -933,34 +1307,68 @@ export async function startTUI(agent, opts = {}) {
933
1307
  pushLine(`embedding key 已保存,向量检索已启用`, C.tool)
934
1308
  return
935
1309
  }
936
- if (sub) { pushLine(`未知参数: ${sub}(/config 查看,/config key key)`, C.error); return }
1310
+ // ---- /config set <path> <value> (高级) ----
1311
+ if (sub === "set") {
1312
+ const [path, value] = [rest[1], rest.slice(2).join(" ")]
1313
+ if (!path || !value) { pushLine("用法: /config set <path> <value> 如 /config set agent.maxTurns 80", C.error); return }
1314
+ try {
1315
+ const { configPath, loadConfig, saveConfig } = await import("./config.mjs")
1316
+ const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
1317
+ // 支持 a.b 形式的嵌套 key
1318
+ const keys = path.split(".")
1319
+ let obj = raw
1320
+ for (let i = 0; i < keys.length - 1; i++) { obj[keys[i]] ??= {}; obj = obj[keys[i]] }
1321
+ obj[keys[keys.length - 1]] = isNaN(value) ? value : Number(value)
1322
+ saveConfig(raw)
1323
+ const cfg = loadConfig()
1324
+ agent.provider = cfg.provider
1325
+ agent.providers = cfg.providersList
1326
+ agent.activeProvider = cfg.activeProvider
1327
+ agent.config = cfg
1328
+ pushLabel(`❯ Config`, ansi.bold + C.tool)
1329
+ pushLine(`已保存: ${path} = ${value}`, C.tool)
1330
+ } catch (error) {
1331
+ pushLine(`保存失败: ${error.message}`, C.error)
1332
+ }
1333
+ return
1334
+ }
1335
+ if (sub) { pushLine(`未知: ${sub}(可用: embedkey / set)`, C.error); return }
937
1336
  // ---- /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)
1337
+ const { configPath: cp } = await import("./config.mjs")
1338
+ pushLabel(`❯ 配置`, ansi.bold + C.tool)
1339
+ pushLine(`激活: ${agent.activeProvider} / ${agent.provider.model}`, C.dim)
942
1340
  pushLine(`Key: ${maskKey(agent.provider.apiKey)}`, C.dim)
943
1341
  const ac = agent.config?.agent ?? {}
944
1342
  const tn = `${ac.compactThreshold ?? 100000}${ac.compactThresholdAuto ? " (auto)" : ""}`
945
- pushLine(`agent: maxTurns=${ac.maxTurns ?? 50} | compactThreshold=${tn}`, C.dim)
1343
+ pushLine(`agent: maxTurns=${ac.maxTurns ?? 100} | compactThreshold=${tn}`, C.dim)
946
1344
  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)
1345
+ pushLabel(`❯ 管理`, ansi.bold + C.tool)
954
1346
  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)
1347
+ if (!agent.memory?.embedder) pushLine(`/config embedkey <k> 开启向量检索`, C.dim)
1348
+ pushLine(`/config set <k> <v> 修改任意配置项`, C.dim)
1349
+ pushLine(`配置文件: ${cp}`, C.dim)
959
1350
  return
960
1351
  }
961
1352
  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)
1353
+ const order = ["Agent", "Session", "Tools", "Config"]
1354
+ const byGroup = new Map()
1355
+ for (const c of SLASH_COMMANDS) {
1356
+ if (!c.group) continue
1357
+ if (!byGroup.has(c.group)) byGroup.set(c.group, [])
1358
+ byGroup.get(c.group).push(c)
1359
+ }
1360
+ const maxW = Math.max(...SLASH_COMMANDS.map((c) => c.name.length))
1361
+ for (const g of order) {
1362
+ const cmds = byGroup.get(g)
1363
+ if (!cmds?.length) continue
1364
+ byGroup.delete(g)
1365
+ pushLabel(`❯ ${g}`, ansi.bold + C.tool)
1366
+ for (const c of cmds) pushLine(` ${c.name.padEnd(maxW + 1)} ${c.desc}`, C.dim)
1367
+ }
1368
+ for (const [g, cmds] of byGroup) {
1369
+ pushLabel(`❯ ${g}`, ansi.bold + C.tool)
1370
+ for (const c of cmds) pushLine(` ${c.name.padEnd(maxW + 1)} ${c.desc}`, C.dim)
1371
+ }
964
1372
  return
965
1373
  }
966
1374
  default:
@@ -998,7 +1406,12 @@ export async function startTUI(agent, opts = {}) {
998
1406
  if (argIndex === 0) return match(["on", "off", "effort"])
999
1407
  if (argIndex === 1 && parts[1] === "effort") return match(["low", "high", "max"])
1000
1408
  }
1001
- if (cmd === "/config" && argIndex === 0) return match(["key", "embedkey"])
1409
+ if (cmd === "/config" && argIndex === 0) return match(["embedkey", "set"])
1410
+ if (cmd === "/goal" && argIndex === 0) return match(["set", "cancel"])
1411
+ if (cmd === "/mcp") {
1412
+ if (argIndex === 0) return match(["add", "url", "remove", "connect", "list"])
1413
+ if (argIndex === 1 && (parts[1] === "remove" || parts[1] === "connect")) return match((agent.config?.mcp?.servers ?? []).map((s) => s.name))
1414
+ }
1002
1415
  return []
1003
1416
  }
1004
1417
 
@@ -1090,9 +1503,11 @@ export async function startTUI(agent, opts = {}) {
1090
1503
  const header = entries.find((e) => e.type === "header" && e.name === p.name)
1091
1504
  const noteBase = `${p.baseURL}${p.apiKey ? "" : "(未配 key)"}`
1092
1505
  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
1506
+ // key 的环境变量兜底和 loadConfig 保持一致(提供商专用变量只对同名生效)
1507
+ const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[p.name]
1508
+ let apiKey = p.apiKey
1509
+ if (!apiKey && envKey && process.env[envKey]) apiKey = process.env[envKey]
1510
+ if (!apiKey) apiKey = process.env.THINCODER_API_KEY
1096
1511
  const models = await listModels(
1097
1512
  { baseURL: p.baseURL, apiKey: apiKey ?? "" },
1098
1513
  { signal: AbortSignal.timeout(10000) },
@@ -1292,9 +1707,10 @@ export async function startTUI(agent, opts = {}) {
1292
1707
  agent.activeProvider = item.provider
1293
1708
  agent.provider = { ...target }
1294
1709
  if (!agent.provider.apiKey) {
1295
- agent.provider.apiKey =
1296
- process.env.THINCODER_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY
1710
+ const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[item.provider]
1711
+ if (envKey && process.env[envKey]) agent.provider.apiKey = process.env[envKey]
1297
1712
  }
1713
+ if (!agent.provider.apiKey) agent.provider.apiKey = process.env.THINCODER_API_KEY
1298
1714
  let thresholdNote = ""
1299
1715
  if (agent.config?.agent?.compactThresholdAuto) {
1300
1716
  const { resolveCompactThreshold } = await import("./config.mjs")
@@ -1360,23 +1776,87 @@ export async function startTUI(agent, opts = {}) {
1360
1776
  // 权限确认态:y 批准 / n 拒绝 / a 批准并开启 AUTO(后续不再询问)
1361
1777
  if (state.permission) {
1362
1778
  const answer = (str || "").toLowerCase()
1363
- if (answer === "y" || answer === "n" || answer === "a" || key.name === "escape") {
1364
- const { resolve } = state.permission
1779
+ const isContinue = state.permission.name === "continue"
1780
+ const validKeys = isContinue ? ["y", "n"] : ["y", "n", "a"]
1781
+ if (validKeys.includes(answer) || key.name === "escape") {
1782
+ const { resolve, name } = state.permission
1365
1783
  state.permission = null
1366
1784
  state.status = "Processing..."
1367
- if (answer === "a") {
1785
+ if (answer === "a" && !isContinue) {
1368
1786
  agent.autoApprove = true
1787
+ agent._pendingReminders = agent._pendingReminders ?? []
1788
+ agent._pendingReminders.push("[System reminder: AUTO mode is now ON. All tool calls are automatically approved. Use /auto to disable.]")
1369
1789
  pushLine(` [auto] AUTO 已开启:后续工具调用不再询问(/auto 关闭)`, C.warn)
1370
1790
  }
1371
- resolve(answer === "y" || answer === "a")
1791
+ const approved = answer === "y" || (answer === "a" && !isContinue)
1792
+ // 决定落痕:对话区留下批准/拒绝记录(continue 询问有自己的输出,不重复记)
1793
+ if (!isContinue) {
1794
+ pushLine(` [${approved ? "approved" : "denied"}] ${name}`, approved ? C.dim : C.error)
1795
+ }
1796
+ resolve(approved)
1372
1797
  render()
1373
1798
  }
1374
1799
  return
1375
1800
  }
1376
1801
 
1802
+ // question 工具回调:自由文本 / 选项选择
1803
+ if (state.question) {
1804
+ const q = state.question
1805
+ if (q.options.length > 0) {
1806
+ // 选项模式:↑↓ 选择,Enter 确认,Esc 取消
1807
+ if (key.name === "escape") {
1808
+ q.resolve("(cancelled)")
1809
+ state.question = null
1810
+ state.status = "Processing..."
1811
+ render()
1812
+ } else if (key.name === "up") {
1813
+ q.selected = Math.max(0, (q.selected ?? 0) - 1)
1814
+ render()
1815
+ } else if (key.name === "down") {
1816
+ q.selected = Math.min(q.options.length - 1, (q.selected ?? 0) + 1)
1817
+ render()
1818
+ } else if (key.name === "return") {
1819
+ const answer = q.options[q.selected ?? 0]
1820
+ q.resolve(answer)
1821
+ state.question = null
1822
+ state.status = "Processing..."
1823
+ pushLine(` → ${answer}`, C.tool)
1824
+ render()
1825
+ }
1826
+ } else {
1827
+ // 自由文本:键入答案,Enter 提交,Esc 取消
1828
+ if (key.name === "escape") {
1829
+ q.resolve("(cancelled)")
1830
+ state.question = null
1831
+ state.status = "Processing..."
1832
+ render()
1833
+ } else if (key.name === "return") {
1834
+ const answer = (q.answer ?? "").trim()
1835
+ q.resolve(answer || "(empty answer)")
1836
+ state.question = null
1837
+ state.status = "Processing..."
1838
+ pushLine(` → ${answer || "(empty)"}`, C.tool)
1839
+ render()
1840
+ } else if (key.name === "backspace") {
1841
+ q.answer = (q.answer ?? "").slice(0, -1)
1842
+ render()
1843
+ } else if (str && !key.ctrl && !key.meta) {
1844
+ q.answer = (q.answer ?? "") + str
1845
+ render()
1846
+ }
1847
+ }
1848
+ return
1849
+ }
1850
+
1377
1851
  if (key.ctrl && key.name === "c") {
1852
+ if (state.processing && state.controller) {
1853
+ state.controller.abort()
1854
+ pushLine("[中止中…]", C.warn)
1855
+ render()
1856
+ return
1857
+ }
1378
1858
  cleanup()
1379
- setTimeout(() => process.exit(0), 100) // 同 /exit:延迟退出避开 libuv 断言
1859
+ setTimeout(() => process.exit(0), 100)
1380
1860
  }
1381
1861
 
1382
1862
  // 模型选择器:↑↓ 移动,Enter 确认,Esc 取消,其余按键吞掉