thincoder 0.1.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,14 +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
- import { runAgent } from "./agent.mjs"
10
+ import { existsSync, readFileSync } from "node:fs"
11
+ import { runAgent, ContinueError } from "./agent.mjs"
11
12
  import { saveSession, clearSession } from "./session.mjs"
13
+ import { PROVIDER_PRESETS as PRESETS } from "./config.mjs"
14
+ import { closeAllMcp } from "./mcp.mjs"
12
15
 
13
16
  // ---------------------------------------------------------------- ANSI 工具
14
17
 
@@ -226,10 +229,15 @@ export async function startTUI(agent, opts = {}) {
226
229
  historyIndex: -1,
227
230
  scroll: 0, // 从底部向上的滚动行数
228
231
  processing: false,
232
+ controller: null, // AbortController for current agent run
229
233
  permission: null, // { name, args, resolve }
234
+ question: null, // { text, options, resolve } — agent 的 question 工具回调
235
+ picker: null, // 模型选择器 { entries, lines, index, scroll, selectedLine }
236
+ wizard: null, // 首次配置向导 { step, index, scroll, selectedLine, fields, error, lines }
230
237
  tasks: [], // task 工具的任务列表(状态栏显示进度)
231
238
  reasoning: "", // 思考流缓冲(暗色展示)
232
- toolStream: "", // 当前工具的实时输出(暗色展示,bash 流式)
239
+ completion: null, // Tab 补全状态 { candidates, index }
240
+ toolStreams: {}, // 各工具的实时输出(按工具名隔离,并行工具互不串扰)
233
241
  currentTool: null, // 正在执行的工具名(状态栏显示)
234
242
  processingStarted: 0, // 本轮处理开始时间(状态栏计时)
235
243
  status: "Ready",
@@ -279,6 +287,12 @@ export async function startTUI(agent, opts = {}) {
279
287
  } catch {
280
288
  // 存失败不耽误退出
281
289
  }
290
+ // 关闭 MCP stdio 子进程,不留孤儿
291
+ try {
292
+ closeAllMcp(agent)
293
+ } catch {
294
+ // 关不掉就算了,进程马上退出
295
+ }
282
296
  process.stdin.setRawMode(false)
283
297
  process.stdout.write(ansi.mouseOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
284
298
  }
@@ -325,6 +339,10 @@ export async function startTUI(agent, opts = {}) {
325
339
  const cols = process.stdout.columns || 80
326
340
  const rows = process.stdout.rows || 24
327
341
  const model = agent.provider.model
342
+ const thinking = agent.provider.thinking
343
+ const effort = agent.provider.reasoningEffort
344
+ const thinkBadge = thinking?.type === "disabled" ? "│ think: off"
345
+ : effort ? `│ think: ${effort}` : thinking?.type === "enabled" ? "│ think: on" : ""
328
346
 
329
347
  // 输入区:全边框盒,宽度 W(所有输出行严格 ≤ cols-1,防自动折行错位)
330
348
  const W = Math.max(20, cols - 1)
@@ -336,11 +354,37 @@ export async function startTUI(agent, opts = {}) {
336
354
  inputOffset = Math.min(layout.cursorLine, layout.lines.length - MAX_INPUT_LINES)
337
355
  }
338
356
  const inputLines = layout.lines.slice(inputOffset, inputOffset + MAX_INPUT_LINES)
339
- 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
340
366
 
341
367
  const headerH = 1
342
368
  const statusH = 1
343
- const convH = Math.max(1, rows - headerH - inputBoxH - statusH)
369
+ // 浮层(模型选择器 / 初始配置向导)打开时,在对话区下方预留一块(标题 + 列表窗口)
370
+ const overlay = state.picker ?? state.wizard
371
+ const pickerH = overlay
372
+ ? Math.min(overlay.lines.length + 1, Math.max(6, rows - 12))
373
+ : 0
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)
344
388
 
345
389
  // 对话区内容行(含流式缓冲);markdown 表格先按显示宽度重排
346
390
  const convLines = []
@@ -364,9 +408,10 @@ export async function startTUI(agent, opts = {}) {
364
408
  }
365
409
  }
366
410
  }
367
- // 工具实时输出(暗色,只保留末尾防刷屏)
368
- if (state.toolStream) {
369
- const tail = state.toolStream.slice(-4000)
411
+ // 工具实时输出(暗色,只保留末尾防刷屏;按工具名隔离防止并行工具串扰)
412
+ const allStreams = Object.values(state.toolStreams).join("")
413
+ if (allStreams) {
414
+ const tail = allStreams.slice(-4000)
370
415
  for (const wrapped of wrapText(tail, cols - 1)) {
371
416
  convLines.push({ text: wrapped, color: C.dim })
372
417
  }
@@ -379,9 +424,9 @@ export async function startTUI(agent, opts = {}) {
379
424
 
380
425
  const out = [ansi.home]
381
426
 
382
- // header
427
+ // header(超宽截断,防终端折行)
383
428
  out.push(
384
- `${ansi.bold}${C.tool} ThinCoder ${ansi.reset}${ansi.dim}│ ${model} │ ${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}`,
385
430
  )
386
431
 
387
432
  // 对话区(不足部分补空行,把输入框钉在底部)
@@ -391,16 +436,53 @@ export async function startTUI(agent, opts = {}) {
391
436
  out.push(`${l.color}${l.text}${ansi.reset}${ansi.clearLine}`)
392
437
  }
393
438
 
439
+ // 浮层(模型选择器 / 初始配置向导):列表滚动跟随选中行
440
+ if (overlay) {
441
+ const winH = pickerH - 1
442
+ if (overlay.selectedLine < overlay.scroll) overlay.scroll = overlay.selectedLine
443
+ if (overlay.selectedLine >= overlay.scroll + winH) overlay.scroll = overlay.selectedLine - winH + 1
444
+ const start = Math.max(0, Math.min(overlay.scroll, Math.max(0, overlay.lines.length - winH)))
445
+ const shown = overlay.lines.slice(start, start + winH)
446
+ const overlayTitle = state.picker ? " ❯ 选择模型 " : " ❯ 初始配置 "
447
+ out.push(`${ansi.bold}${C.tool}${overlayTitle}${ansi.reset}${ansi.dim}${state.picker ? "(↑↓ 移动, Enter 确认, Esc 取消)" : ""}${ansi.reset}${ansi.clearLine}`)
448
+ for (const l of shown) {
449
+ out.push(`${l.color}${sliceByWidth(l.text, cols - 1)}${ansi.reset}${ansi.clearLine}`)
450
+ }
451
+ for (let i = shown.length; i < winH; i++) out.push(ansi.clearLine)
452
+ }
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
+
394
461
  // 输入框(全边框,宽 W)
395
- const borderColor = state.permission ? C.warn : C.tool
396
- const title = state.permission
397
- ? ` Allow ${state.permission.name}? (y/n) `
398
- : state.processing
399
- ? " Processing... "
400
- : " 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
+ }
401
483
  const topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 3 - stringWidth(title)))}╮`
402
484
  out.push(`${borderColor}${topBorder}${ansi.reset}${ansi.clearLine}`)
403
- for (const l of inputLines) {
485
+ for (const l of boxLines) {
404
486
  const content = sliceByWidth(l, W - 4)
405
487
  const fill = " ".repeat(Math.max(0, W - 4 - stringWidth(content)))
406
488
  out.push(`${borderColor}│${ansi.reset} ${content}${fill} ${borderColor}│${ansi.reset}${ansi.clearLine}`)
@@ -411,12 +493,42 @@ export async function startTUI(agent, opts = {}) {
411
493
  const scrollHint = state.scroll > 0 ? ` │ scrolled ${state.scroll}` : ""
412
494
  const rawInput = state.input.join("")
413
495
  let statusLine
414
- if (rawInput.startsWith("/") && !state.processing && !state.permission) {
415
- const prefix = rawInput.split(/\s/)[0]
416
- const matches = SLASH_COMMANDS.filter((c) => c.name.startsWith(prefix))
417
- statusLine = matches.length > 0
418
- ? ` ${matches.map((c) => `${c.name} ${c.desc}`).join(" ")}`
419
- : ` 未知命令(/help 查看可用命令)`
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)"
505
+ } else if (state.picker) {
506
+ statusLine = " ↑↓: 选择 │ Enter: 确认 │ Esc: 取消"
507
+ } else if (state.wizard) {
508
+ statusLine = state.wizard.step === "provider"
509
+ ? " ↑↓: 选择 │ Enter: 确认 │ Esc: 跳过"
510
+ : " 输入后 Enter 确认 │ Esc: 取消"
511
+ } else if (rawInput.startsWith("/") && !state.processing && !state.permission) {
512
+ const [cmd, sub] = rawInput.split(/\s+/)
513
+ const cmds = SLASH_COMMANDS.filter((c) => c.name.startsWith(cmd))
514
+ const match = cmds.length === 1 ? cmds[0] : null
515
+ if (match?.name === "/config" && cmd === "/config") {
516
+ statusLine = " /config 查看 │ embedkey 配 embedding │ set 改参数"
517
+ } else if (match?.name === "/provider" && cmd === "/provider") {
518
+ statusLine = " /provider 列表 │ add / remove / key"
519
+ } else if (match?.name === "/model" && cmd === "/model" && !sub) {
520
+ statusLine = " /model 打开选择器 │ /model <名称> 直接切换"
521
+ } else if (match?.name === "/think" && cmd === "/think") {
522
+ statusLine = " /think 查看 │ on / off 开关 │ effort high / max 强度"
523
+ } else if (cmds.length > 0) {
524
+ if (cmds.length <= 4) {
525
+ statusLine = ` ${cmds.map((c) => `${c.name} ${c.desc}`).join(" │ ")}`
526
+ } else {
527
+ statusLine = ` ${cmds.map((c) => c.name).join(" ")} │ Tab 补全`
528
+ }
529
+ } else {
530
+ statusLine = ` 未知命令(/help 查看可用命令)`
531
+ }
420
532
  } else {
421
533
  const taskHint = state.tasks.length > 0
422
534
  ? ` │ ▶${state.tasks.filter((t) => t.status === "done").length}/${state.tasks.length}`
@@ -427,7 +539,12 @@ export async function startTUI(agent, opts = {}) {
427
539
  statusLine = ` ${statusText}${taskHint}${scrollHint} │ Enter: send │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+C: exit`
428
540
  }
429
541
  const autoBanner = agent.autoApprove ? `${C.warn} AUTO${ansi.reset}${ansi.dim}│` : ""
430
- 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}`)
431
548
 
432
549
  const frame = out.join("\r\n")
433
550
  if (frame !== lastFrame) {
@@ -435,11 +552,11 @@ export async function startTUI(agent, opts = {}) {
435
552
  process.stdout.write(frame)
436
553
  }
437
554
 
438
- // 光标:输入态定位到输入框内(IME 候选框跟随真实光标);处理中/权限确认时隐藏
439
- if (state.processing || state.permission) {
555
+ // 光标:输入态定位到输入框内(IME 候选框跟随真实光标);处理中/权限确认/菜单态时隐藏
556
+ if (state.processing || state.permission || state.question || state.picker || state.wizard?.step === "provider") {
440
557
  process.stdout.write(ansi.hideCursor)
441
558
  } else {
442
- const cursorRow = 1 + convH + 2 + (layout.cursorLine - inputOffset) // header + 对话区 + 上边框 + 行偏移
559
+ const cursorRow = 1 + convH + taskPanelH + 2 + (layout.cursorLine - inputOffset) // header + 对话区 + todo 面板 + 上边框 + 行偏移
443
560
  const cursorCol = 3 + layout.cursorCol // 左边框 + 空格 + 文本偏移(1 基)
444
561
  process.stdout.write(`${ESC}[${cursorRow};${cursorCol}H${ansi.showCursor}`)
445
562
  }
@@ -482,63 +599,105 @@ export async function startTUI(agent, opts = {}) {
482
599
  state.reasoning = ""
483
600
  state.currentTool = null
484
601
  state.processingStarted = Date.now()
602
+ state.controller = new AbortController()
485
603
  // 处理中每秒刷新一次状态栏(运行计时)
486
604
  const ticker = setInterval(() => {
487
605
  if (state.processing) render()
488
606
  }, 1000)
489
607
  render()
490
608
 
491
- try {
492
- await runAgent(agent, text, {
493
- onToken: (t) => {
494
- ensureAssistantLabel()
495
- state.streaming += t
496
- scheduleRender() // token 洪流限流,防闪屏
497
- },
498
- onReasoning: (t) => {
499
- ensureAssistantLabel()
500
- state.reasoning += t
501
- scheduleRender()
502
- },
503
- onToolCall: (name, args) => {
504
- flushStream()
505
- ensureAssistantLabel()
506
- state.currentTool = name
507
- pushLine(` [tool] ${name} ${summarize(args)}`, C.tool)
508
- },
509
- onToolResult: (name, result) => {
510
- state.currentTool = null
511
- if (state.toolStream) {
512
- // 实时输出落盘为历史行(保留末尾 4000 字符),并清掉临时缓冲
513
- const tail = state.toolStream.trimEnd().slice(-4000)
514
- if (tail) pushLine(tail, C.dim)
515
- 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
516
682
  }
517
- const first = result.split("\n")[0]
518
- pushLine(` [done] ${name} ${sliceByWidth(first, 100)}`, C.dim)
519
- },
520
- onToolOutput: (name, chunk) => {
521
- state.toolStream += chunk
522
- scheduleRender()
523
- },
524
- onPermissionRequest: (name, args) => askPermission(name, args),
525
- onTaskUpdate: (items) => {
526
- state.tasks = items
527
- const done = items.filter((i) => i.status === "done").length
528
- pushLine(` [task] ${done}/${items.length}`, C.dim)
529
- render()
530
- },
531
- })
532
- flushStream()
533
- } catch (error) {
534
- flushStream()
535
- pushLine(`[error] ${error.message}`, C.error)
536
- } finally {
537
- 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
+ }
538
691
  }
539
692
 
693
+ clearInterval(ticker)
540
694
  state.processing = false
695
+ state.controller = null
541
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
+ }
542
701
  // 每轮结束后保存会话(崩溃也不丢)
543
702
  try {
544
703
  saveSession(agent)
@@ -565,6 +724,9 @@ export async function startTUI(agent, opts = {}) {
565
724
  pushLine(` [auto] ${name} ${summarize(args)}`, C.warn)
566
725
  return Promise.resolve(true)
567
726
  }
727
+ // 把关键参数摆出来:批什么要让人看明白
728
+ pushLabel(`❯ 权限请求`, ansi.bold + C.warn)
729
+ for (const line of formatPermission(name, args)) pushLine(` ${line}`, C.text)
568
730
  return new Promise((resolve) => {
569
731
  state.permission = { name, args, resolve }
570
732
  state.status = `Waiting: ${name}`
@@ -572,19 +734,62 @@ export async function startTUI(agent, opts = {}) {
572
734
  })
573
735
  }
574
736
 
737
+ /** 权限请求的关键信息(按工具定制),返回行数组 */
738
+ function formatPermission(name, args) {
739
+ const cap = (s, n = 1000) => (s.length > n ? `${s.slice(0, n)}…(共 ${s.length} 字符)` : s)
740
+ if (name === "bash") return cap(args.command ?? "").split("\n")
741
+ if (name === "write") return [`${args.path}(写入 ${(args.content ?? "").length} 字符)`]
742
+ if (name === "edit") return [`${args.path}(替换 ${(args.old_string ?? "").length} 字符 → ${(args.new_string ?? "").length} 字符)`]
743
+ if (name === "subagent") return cap(args.task ?? "", 500).split("\n")
744
+ if (name === "memory_put") return [`[${args.type ?? ""}] ${args.title ?? ""}`]
745
+ return [cap(summarize(args), 300)]
746
+ }
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
+
575
774
  // ---------------------------------------------------------- 斜杠命令
576
775
 
577
776
  const SLASH_COMMANDS = [
578
- { name: "/help", desc: "命令列表" },
579
- { name: "/model", desc: "查看/切换模型" },
580
- { name: "/config", desc: "查看当前配置" },
581
- { name: "/auto", desc: "自动授权开关" },
582
- { name: "/rewind", desc: "回滚到存档点" },
583
- { name: "/reindex", desc: "重建记忆索引" },
584
- { name: "/distill", desc: "从会话提取知识" },
585
- { name: "/new", desc: "开始新会话" },
586
- { name: "/clear", desc: "清屏" },
587
- { 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: "此列表" },
588
793
  ]
589
794
 
590
795
  async function handleSlash(text) {
@@ -598,6 +803,9 @@ export async function startTUI(agent, opts = {}) {
598
803
  case "/new":
599
804
  agent.history = []
600
805
  agent.tasks = []
806
+ agent.planMode = false
807
+ agent.goal = null
808
+ agent._pendingReminders = []
601
809
  state.tasks = []
602
810
  state.lines = []
603
811
  state.streaming = ""
@@ -606,7 +814,7 @@ export async function startTUI(agent, opts = {}) {
606
814
  return
607
815
  case "/exit":
608
816
  cleanup()
609
- process.exit(0)
817
+ setTimeout(() => process.exit(0), 100) // 延迟一拍:fetch 后立刻 exit 在 Windows/Node 24 会触发 libuv 断言
610
818
  return
611
819
  case "/reindex": {
612
820
  const { syncDir } = await import("./memory.mjs")
@@ -658,8 +866,200 @@ export async function startTUI(agent, opts = {}) {
658
866
  }
659
867
  return
660
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
+ }
661
1055
  case "/auto":
662
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
+ }
663
1063
  pushLabel(`❯ Auto`, ansi.bold + (agent.autoApprove ? C.warn : C.tool))
664
1064
  pushLine(
665
1065
  agent.autoApprove
@@ -668,53 +1068,247 @@ export async function startTUI(agent, opts = {}) {
668
1068
  agent.autoApprove ? C.warn : C.dim,
669
1069
  )
670
1070
  return
1071
+ case "/think": {
1072
+ const sub = rest[0]
1073
+ const cur = agent.provider
1074
+ const thinkingEnabled = cur.thinking?.type === "enabled" || cur.thinking?.type === undefined
1075
+ // ---- /think(无参): 查看状态 ----
1076
+ if (!sub) {
1077
+ pushLabel(`❯ Think`, ansi.bold + C.tool)
1078
+ pushLine(`思维模式: ${thinkingEnabled ? "🟢 开启" : "⚫ 关闭"}`, C.dim)
1079
+ pushLine(`推理强度: ${cur.reasoningEffort ?? "(未设置)"}`, C.dim)
1080
+ pushLine(`切换: /think on | off | effort high | effort max`, C.dim)
1081
+ return
1082
+ }
1083
+ // ---- /think on / off ----
1084
+ if (sub === "on" || sub === "off") {
1085
+ const enable = sub === "on"
1086
+ cur.thinking = enable ? { type: "enabled" } : { type: "disabled" }
1087
+ if (!enable) delete cur.reasoningEffort
1088
+ else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
1089
+ await syncProviderField("thinking", cur.thinking)
1090
+ if (!enable) await syncProviderField("reasoningEffort", undefined)
1091
+ else await syncProviderField("reasoningEffort", cur.reasoningEffort)
1092
+ pushLabel(`❯ Think`, ansi.bold + C.tool)
1093
+ pushLine(`思维模式已${enable ? "开启" : "关闭"}`, C.tool)
1094
+ if (enable) pushLine(`推理强度: ${cur.reasoningEffort}`, C.dim)
1095
+ return
1096
+ }
1097
+ // ---- /think effort <level> ----
1098
+ if (sub === "effort") {
1099
+ const level = rest[1]
1100
+ if (!level || !["low", "high", "max"].includes(level)) {
1101
+ pushLine("用法: /think effort low | high | max", C.error)
1102
+ return
1103
+ }
1104
+ cur.reasoningEffort = level
1105
+ await syncProviderField("reasoningEffort", level)
1106
+ pushLabel(`❯ Think`, ansi.bold + C.tool)
1107
+ pushLine(`推理强度已设为 ${level}`, C.tool)
1108
+ return
1109
+ }
1110
+ pushLine(`未知参数: ${sub}(可用: on / off / effort / effort high|max)`, C.error)
1111
+ return
1112
+ }
671
1113
  case "/model": {
672
1114
  const arg = rest[0]
673
1115
  if (!arg) {
1116
+ // 打开交互选择器:全部 provider 的全部模型,方向键选择
674
1117
  pushLabel(`❯ Model`, ansi.bold + C.tool)
675
- pushLine(`model: ${agent.provider.model}`, C.dim)
676
- pushLine(`baseURL: ${agent.provider.baseURL}`, C.dim)
677
- pushLine(`切换: /model <名称>(仅本次会话;永久修改请编辑 ~/.thincoder/config.json)`, C.dim)
678
- // 拉取端点可用模型列表
679
- pushLine(`正在拉取可用模型...`, C.dim)
680
- try {
681
- const { listModels } = await import("./provider.mjs")
682
- const models = await listModels(agent.provider)
683
- pushLabel(`❯ Available (${models.length})`, ansi.bold + C.tool)
684
- for (const m of models) {
685
- pushLine(` ${m === agent.provider.model ? "▸ " : " "}${m}`, m === agent.provider.model ? C.tool : C.dim)
686
- }
687
- } catch (error) {
688
- pushLine(` (拉取失败: ${error.message})`, C.error)
1118
+ pushLine(`/model <名称> 直接切换 provider 或模型(如 /model deepseek-v4-pro)`, C.dim)
1119
+ pushLine(`/provider 管理 provider(添加/删除/配 key)`, C.dim)
1120
+ openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
1121
+ return
1122
+ }
1123
+ if (arg === "add" || arg === "--add") {
1124
+ pushLine(`添加 provider 已移到 /provider add(/provider 查看全部管理命令)`, C.warn)
1125
+ return
1126
+ }
1127
+ // 一个参数两种含义:先按 provider 名匹配,匹配不到就当模型名改当前 provider
1128
+ const { resolveCompactThreshold } = await import("./config.mjs")
1129
+ const p = agent.providers.find((pp) => pp.name === arg)
1130
+ const newModel = p ? p.model : arg
1131
+ let thresholdNote = ""
1132
+ if (agent.config?.agent?.compactThresholdAuto) {
1133
+ const { value } = resolveCompactThreshold(null, newModel)
1134
+ agent.config.agent.compactThreshold = value
1135
+ thresholdNote = `,压缩阈值随模型调整为 ${value}`
1136
+ }
1137
+ if (p) {
1138
+ agent.activeProvider = arg
1139
+ agent.provider = { ...p }
1140
+ // key 的环境变量兜底和 loadConfig 保持一致(提供商专用变量只对同名生效)
1141
+ if (!agent.provider.apiKey) {
1142
+ const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[arg]
1143
+ if (envKey && process.env[envKey]) agent.provider.apiKey = process.env[envKey]
689
1144
  }
1145
+ if (!agent.provider.apiKey) agent.provider.apiKey = process.env.THINCODER_API_KEY
1146
+ await persistRaw((raw) => { raw.activeProvider = arg })
1147
+ agent.config.activeProvider = arg
1148
+ pushLabel(`❯ Model`, ansi.bold + C.tool)
1149
+ pushLine(`已切换到 ${arg} / ${p.model}${thresholdNote}(已持久化)`, C.tool)
1150
+ if (!agent.provider.apiKey) pushLine(`该 provider 还没配 key: /provider key <apikey>`, C.warn)
690
1151
  } else {
1152
+ const target = agent.providers.find((pp) => pp.name === agent.activeProvider) ?? agent.providers[0]
1153
+ if (target) target.model = arg
691
1154
  agent.provider.model = arg
692
- // 阈值是自动推导的则跟着新模型走;用户显式配置过的不动
693
- let thresholdNote = ""
694
- if (agent.config?.agent?.compactThresholdAuto) {
695
- const { resolveCompactThreshold } = await import("./config.mjs")
696
- const { value } = resolveCompactThreshold(null, arg)
697
- agent.config.agent.compactThreshold = value
698
- thresholdNote = `,压缩阈值随模型调整为 ${value}`
699
- }
1155
+ await persistRaw((raw) => { raw.providers = agent.providers })
700
1156
  pushLabel(`❯ Model`, ansi.bold + C.tool)
701
- pushLine(`已切换到 ${arg}(仅本次会话)${thresholdNote}`, C.tool)
1157
+ pushLine(`已将 ${target?.name ?? agent.activeProvider} 的模型改为 ${arg}${thresholdNote}(已持久化)`, C.tool)
1158
+ }
1159
+ return
1160
+ }
1161
+ case "/provider": {
1162
+ const sub = rest[0]
1163
+ // ---- /provider add <名称> <baseURL> <模型>,或 /provider add <预设> ----
1164
+ if (sub === "add") {
1165
+ const name = rest[1]
1166
+ if (!name) {
1167
+ pushLine(`用法: /provider add <名称> <baseURL> <模型>,或 /provider add <预设>(${Object.keys(PRESETS).join(", ")})`, C.error)
1168
+ return
1169
+ }
1170
+ if (agent.providers.some((p) => p.name === name)) {
1171
+ pushLine(`"${name}" 已存在;要重建可先 /provider remove ${name}`, C.warn)
1172
+ return
1173
+ }
1174
+ const preset = PRESETS[name]
1175
+ const baseURL = (rest[2] ?? preset?.baseURL)?.replace(/\/+$/, "")
1176
+ const model = rest[3] ?? preset?.model
1177
+ if (!baseURL || !model) {
1178
+ pushLine(`缺少参数: /provider add ${name} <baseURL> <模型>`, C.error)
1179
+ if (!preset) pushLine(`("${name}" 不是预设;预设: ${Object.keys(PRESETS).join(", ")})`, C.dim)
1180
+ return
1181
+ }
1182
+ if (!/^https?:\/\//.test(baseURL)) { pushLine(`baseURL 应以 http(s):// 开头`, C.error); return }
1183
+ agent.providers.push({ name, baseURL, model, ...(preset?.desc ? { desc: preset.desc } : {}) })
1184
+ await persistRaw((raw) => { raw.providers = agent.providers })
1185
+ pushLabel(`❯ Provider`, ansi.bold + C.tool)
1186
+ pushLine(`已添加 ${name}(${baseURL} / ${model})`, C.tool)
1187
+ pushLine(`下一步: /provider key ${name} <apikey> 配 key,/model ${name} 切换`, C.dim)
1188
+ return
1189
+ }
1190
+ // ---- /provider remove <名称> ----
1191
+ if (sub === "remove" || sub === "rm") {
1192
+ const name = rest[1]
1193
+ if (!name) { pushLine("用法: /provider remove <名称>", C.error); return }
1194
+ const at = agent.providers.findIndex((p) => p.name === name)
1195
+ if (at < 0) { pushLine(`未找到 provider "${name}"`, C.error); return }
1196
+ if (name === agent.activeProvider) { pushLine(`"${name}" 正在使用中,先 /model 切换到别的 provider 再删`, C.warn); return }
1197
+ agent.providers.splice(at, 1)
1198
+ await persistRaw((raw) => { raw.providers = agent.providers })
1199
+ pushLabel(`❯ Provider`, ansi.bold + C.tool)
1200
+ pushLine(`已删除 ${name}`, C.tool)
1201
+ return
702
1202
  }
1203
+ // ---- /provider key [名称] <apikey> ----
1204
+ if (sub === "key") {
1205
+ let name = agent.activeProvider
1206
+ let keyParts = rest.slice(1)
1207
+ if (rest[1] && agent.providers.some((p) => p.name === rest[1])) {
1208
+ name = rest[1]
1209
+ keyParts = rest.slice(2)
1210
+ }
1211
+ const key = keyParts.join(" ")
1212
+ if (!key) { pushLine("用法: /provider key [名称] <apikey>(不填名称配当前 provider)", C.error); return }
1213
+ await setProviderKey(name, key)
1214
+ return
1215
+ }
1216
+ if (sub) { pushLine(`未知: ${sub}(/provider add | remove | key)`, C.error); return }
1217
+ // ---- /provider(无参): 列表 ----
1218
+ pushLabel(`❯ Providers (${agent.providers.length})`, ansi.bold + C.tool)
1219
+ for (const p of agent.providers) {
1220
+ const active = p.name === agent.activeProvider
1221
+ pushLine(
1222
+ `${active ? " ▸" : " "} ${p.name.padEnd(12)} ${p.model.padEnd(20)} ${p.baseURL}${p.apiKey ? " ●key" : " ○无key"}${active ? " ← 当前" : ""}`,
1223
+ active ? C.tool : C.dim,
1224
+ )
1225
+ }
1226
+ pushLabel(`❯ 操作`, ansi.bold + C.tool)
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)
703
1231
  return
704
1232
  }
705
1233
  case "/config": {
706
- pushLabel(`❯ Config`, ansi.bold + C.tool)
707
- pushLine(`provider: ${agent.provider.baseURL} | model: ${agent.provider.model}`, C.dim)
708
- pushLine(`apiKey: ${maskKey(agent.provider.apiKey)}`, C.dim)
1234
+ const sub = rest[0]
1235
+ // ---- /config embedkey <apikey>:embedding 服务的 key ----
1236
+ if (sub === "embedkey") {
1237
+ const key = rest.slice(1).join(" ")
1238
+ if (!key) { pushLine("用法: /config embedkey <apikey>(embedding 服务,默认 SiliconFlow bge-m3)", C.error); return }
1239
+ agent.config.embedding ??= {}
1240
+ agent.config.embedding.apiKey = key
1241
+ await persistRaw((raw) => { raw.embedding = { ...(raw.embedding ?? {}), apiKey: key } })
1242
+ if (agent.memory) {
1243
+ const { createEmbedder } = await import("./embedding.mjs")
1244
+ agent.memory.embedder = createEmbedder(agent.config.embedding)
1245
+ }
1246
+ pushLabel(`❯ Config`, ansi.bold + C.tool)
1247
+ pushLine(`embedding key 已保存,向量检索已启用`, C.tool)
1248
+ return
1249
+ }
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 }
1276
+ // ---- /config(无参): 查看 ----
1277
+ const { configPath: cp } = await import("./config.mjs")
1278
+ pushLabel(`❯ 配置`, ansi.bold + C.tool)
1279
+ pushLine(`激活: ${agent.activeProvider} / ${agent.provider.model}`, C.dim)
1280
+ pushLine(`Key: ${maskKey(agent.provider.apiKey)}`, C.dim)
709
1281
  const ac = agent.config?.agent ?? {}
710
- const thresholdNote = `${ac.compactThreshold ?? 100000}${ac.compactThresholdAuto ? " (auto,随模型)" : ""}`
711
- pushLine(`agent: maxTurns=${ac.maxTurns ?? 50} | compactThreshold=${thresholdNote}`, C.dim)
712
- pushLine(`memory: ${agent.memory ? "enabled" : "disabled"}${agent.memory?.embedder ? " + vector" : " (FTS only)"}`, C.dim)
1282
+ const tn = `${ac.compactThreshold ?? 100000}${ac.compactThresholdAuto ? " (auto)" : ""}`
1283
+ pushLine(`agent: maxTurns=${ac.maxTurns ?? 100} | compactThreshold=${tn}`, C.dim)
1284
+ pushLine(`embedding: ${agent.memory?.embedder ? `enabled (${agent.config?.embedding?.model ?? ""})` : "disabled(纯 FTS 检索)"}`, C.dim)
1285
+ pushLabel(`❯ 管理`, ansi.bold + C.tool)
1286
+ pushLine(`/provider 管理 provider(添加/删除/配 key)`, 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)
713
1290
  return
714
1291
  }
715
1292
  case "/help": {
716
- pushLabel(`❯ Commands`, ansi.bold + C.tool)
717
- 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
+ }
718
1312
  return
719
1313
  }
720
1314
  default:
@@ -729,6 +1323,351 @@ export async function startTUI(agent, opts = {}) {
729
1323
  return `${key.slice(0, 5)}…${key.slice(-4)}`
730
1324
  }
731
1325
 
1326
+ /** Tab 补全候选:命令名 / 子命令 / provider 名 / 预设名 / think 参数 */
1327
+ function completions(input) {
1328
+ if (!input.startsWith("/")) return []
1329
+ const parts = input.split(/\s+/)
1330
+ // 还在敲第一个 token:补命令名
1331
+ if (parts.length === 1) {
1332
+ return SLASH_COMMANDS.filter((c) => c.name.startsWith(parts[0])).map((c) => c.name)
1333
+ }
1334
+ const cmd = parts[0]
1335
+ const last = parts.at(-1) // 结尾是空格时为 "",即列出全部候选
1336
+ const head = parts.slice(0, -1).join(" ")
1337
+ const argIndex = parts.length - 2 // 正在敲第几个参数(0 基)
1338
+ const match = (cands) => cands.filter((c) => c.startsWith(last)).map((c) => `${head} ${c}`)
1339
+ if (cmd === "/model" && argIndex === 0) return match(agent.providers.map((p) => p.name))
1340
+ if (cmd === "/provider") {
1341
+ if (argIndex === 0) return match(["add", "remove", "key"])
1342
+ if (argIndex === 1 && parts[1] === "add") return match(Object.keys(PRESETS))
1343
+ if (argIndex === 1 && (parts[1] === "remove" || parts[1] === "key")) return match(agent.providers.map((p) => p.name))
1344
+ }
1345
+ if (cmd === "/think") {
1346
+ if (argIndex === 0) return match(["on", "off", "effort"])
1347
+ if (argIndex === 1 && parts[1] === "effort") return match(["low", "high", "max"])
1348
+ }
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
+ }
1355
+ return []
1356
+ }
1357
+
1358
+ /** Tab:计算候选并循环替换输入 */
1359
+ function handleTab() {
1360
+ const input = state.input.join("")
1361
+ if (state.completion && input === state.completion.candidates[state.completion.index]) {
1362
+ // 上一次的候选还在输入框:循环到下一个
1363
+ state.completion.index = (state.completion.index + 1) % state.completion.candidates.length
1364
+ } else {
1365
+ const candidates = completions(input)
1366
+ if (candidates.length === 0) return
1367
+ state.completion = { candidates, index: 0 }
1368
+ }
1369
+ const text = state.completion.candidates[state.completion.index]
1370
+ state.input = [...text]
1371
+ state.cursor = state.input.length
1372
+ render()
1373
+ }
1374
+
1375
+ /** 读配置文件 → 修改 → 写回;文件不存在时从空对象开始 */
1376
+ async function persistRaw(mutate) {
1377
+ const { saveConfig, configPath } = await import("./config.mjs")
1378
+ const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
1379
+ mutate(raw)
1380
+ saveConfig(raw)
1381
+ }
1382
+
1383
+ /** 把当前激活 provider 的某个字段同步到 providers 列表并持久化 */
1384
+ async function syncProviderField(field, value) {
1385
+ const target = agent.providers.find((p) => p.name === agent.activeProvider)
1386
+ if (!target) return
1387
+ if (value === undefined) delete target[field]
1388
+ else target[field] = value
1389
+ // 全量写回:raw 里的 providers 顺序/内容可能与运行时列表不一致,逐字段改容易写错位
1390
+ await persistRaw((raw) => {
1391
+ raw.providers = agent.providers
1392
+ })
1393
+ }
1394
+
1395
+ // ---------------------------------------------------------- 模型选择器(/model)
1396
+
1397
+ const pickerItems = () => state.picker.entries.filter((e) => e.type === "item")
1398
+
1399
+ /** 按 entries 重建显示行并刷新;高亮选中项、标注当前模型 */
1400
+ function renderPickerLines() {
1401
+ const p = state.picker
1402
+ if (!p) return
1403
+ const lines = []
1404
+ let row = 0
1405
+ let selectedLine = 0
1406
+ for (const e of p.entries) {
1407
+ if (e.type === "header") {
1408
+ lines.push({ text: ` ${e.name}${e.note ? ` ${e.note}` : ""}`, color: ansi.bold + C.tool })
1409
+ } else {
1410
+ const selected = row === p.index
1411
+ if (selected) selectedLine = lines.length
1412
+ const current = e.provider === agent.activeProvider && e.model === agent.provider.model
1413
+ lines.push({
1414
+ text: `${selected ? " ▸ " : " "}${e.model}${current ? " ← 当前" : ""}`,
1415
+ color: selected ? ansi.bold + C.text : C.dim,
1416
+ })
1417
+ row++
1418
+ }
1419
+ }
1420
+ p.lines = lines
1421
+ p.selectedLine = selectedLine
1422
+ render()
1423
+ }
1424
+
1425
+ /** 打开选择器:先列出各 provider 已配置的模型,再并发拉取各端点的全部模型展开进去 */
1426
+ async function openModelPicker() {
1427
+ const entries = []
1428
+ for (const p of agent.providers) {
1429
+ entries.push({ type: "header", name: p.name, note: `${p.baseURL}${p.apiKey ? "" : "(未配 key)"} 加载中...` })
1430
+ entries.push({ type: "item", provider: p.name, model: p.model })
1431
+ }
1432
+ state.picker = { entries, lines: [], index: 0, scroll: 0, selectedLine: 0 }
1433
+ // 默认选中当前在用的模型
1434
+ const current = pickerItems().findIndex(
1435
+ (e) => e.provider === agent.activeProvider && e.model === agent.provider.model,
1436
+ )
1437
+ if (current >= 0) state.picker.index = current
1438
+ renderPickerLines()
1439
+
1440
+ const { listModels } = await import("./provider.mjs")
1441
+ await Promise.all(
1442
+ agent.providers.map(async (p) => {
1443
+ const header = entries.find((e) => e.type === "header" && e.name === p.name)
1444
+ const noteBase = `${p.baseURL}${p.apiKey ? "" : "(未配 key)"}`
1445
+ try {
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
1451
+ const models = await listModels(
1452
+ { baseURL: p.baseURL, apiKey: apiKey ?? "" },
1453
+ { signal: AbortSignal.timeout(10000) },
1454
+ )
1455
+ // 展开到该 provider 已配置模型的后面(去重)
1456
+ const at = entries.findIndex((e) => e.type === "item" && e.provider === p.name && e.model === p.model)
1457
+ entries.splice(
1458
+ at + 1,
1459
+ 0,
1460
+ ...models.filter((m) => m !== p.model).map((m) => ({ type: "item", provider: p.name, model: m })),
1461
+ )
1462
+ header.note = noteBase
1463
+ } catch (error) {
1464
+ header.note = `${noteBase} (拉取失败: ${sliceByWidth(error.message, 60)})`
1465
+ }
1466
+ if (state.picker?.entries === entries) renderPickerLines() // 已关闭就不再刷新
1467
+ }),
1468
+ )
1469
+ }
1470
+
1471
+ function closeModelPicker() {
1472
+ state.picker = null
1473
+ render()
1474
+ }
1475
+
1476
+ /** 给指定 provider 写 key(内存 + 配置文件);若它是当前激活的,同步运行时 */
1477
+ async function setProviderKey(name, key) {
1478
+ const target = agent.providers.find((p) => p.name === name)
1479
+ if (!target) {
1480
+ pushLine(`未找到 provider "${name}"`, C.error)
1481
+ return
1482
+ }
1483
+ target.apiKey = key
1484
+ if (name === agent.activeProvider) agent.provider.apiKey = key
1485
+ await persistRaw((raw) => { raw.providers = agent.providers })
1486
+ pushLabel(`❯ Provider`, ansi.bold + C.tool)
1487
+ pushLine(`apiKey 已保存到 ${name}`, C.tool)
1488
+ }
1489
+
1490
+ // ---------------------------------------------------------- 初始配置向导(首次启动)
1491
+
1492
+ /** 菜单步的候选项:已有 provider(未配 key 的标注)+ 未添加的预设 + 自定义 */
1493
+ function wizardProviderItems() {
1494
+ const items = []
1495
+ for (const p of agent.providers) {
1496
+ items.push({ kind: "existing", name: p.name, baseURL: p.baseURL, model: p.model, label: `${p.name}(已添加${p.apiKey ? "" : ",未配 key"})` })
1497
+ }
1498
+ for (const [name, p] of Object.entries(PRESETS)) {
1499
+ if (!agent.providers.some((x) => x.name === name)) {
1500
+ items.push({ kind: "preset", name, baseURL: p.baseURL, model: p.model, label: `${name}(${p.desc})` })
1501
+ }
1502
+ }
1503
+ items.push({ kind: "custom", name: null, label: "自定义端点…" })
1504
+ return items
1505
+ }
1506
+
1507
+ /** 文本步骤定义:提示语 + 校验(通过返回 true,否则返回错误文案) */
1508
+ const WIZARD_STEPS = {
1509
+ name: {
1510
+ prompt: "给这个 provider 起个名字(字母/数字/-/_,如 my-openai)",
1511
+ validate: (v) =>
1512
+ (/^[\w-]+$/.test(v) && !agent.providers.some((p) => p.name === v)) || "名字需为字母/数字/-/_,且不与已有 provider 重名",
1513
+ },
1514
+ baseURL: {
1515
+ prompt: "输入 baseURL(如 https://api.openai.com/v1)",
1516
+ validate: (v) => /^https?:\/\/.+/.test(v) || "baseURL 应以 http(s):// 开头",
1517
+ },
1518
+ model: {
1519
+ prompt: "输入模型名(如 gpt-4o)",
1520
+ validate: (v) => v.length > 0 || "模型名不能为空",
1521
+ },
1522
+ key: {
1523
+ prompt: "输入 API key",
1524
+ validate: (v) => v.length > 0 || "key 不能为空",
1525
+ },
1526
+ embedkey: {
1527
+ prompt: "可选:embedding API key(SiliconFlow,记忆向量检索用;直接回车跳过)",
1528
+ validate: () => true, // 可跳过
1529
+ },
1530
+ }
1531
+ const WIZARD_NEXT = { name: "baseURL", baseURL: "model", model: "key", key: "embedkey", embedkey: null }
1532
+
1533
+ function startWizard() {
1534
+ state.wizard = { step: "provider", index: 0, scroll: 0, selectedLine: 0, fields: {}, error: null, lines: [] }
1535
+ renderWizard()
1536
+ }
1537
+
1538
+ function renderWizard() {
1539
+ const w = state.wizard
1540
+ if (!w) return
1541
+ const lines = []
1542
+ if (w.step === "provider") {
1543
+ lines.push({ text: " 选择一个模型提供商:", color: C.text })
1544
+ wizardProviderItems().forEach((it, i) => {
1545
+ if (i === w.index) w.selectedLine = lines.length
1546
+ lines.push({
1547
+ text: `${i === w.index ? " ▸ " : " "}${it.label}`,
1548
+ color: i === w.index ? ansi.bold + C.text : C.dim,
1549
+ })
1550
+ })
1551
+ } else {
1552
+ const f = w.fields
1553
+ if (f.name) lines.push({ text: ` 提供商: ${f.name}`, color: C.dim })
1554
+ if (f.baseURL) lines.push({ text: ` baseURL: ${f.baseURL}`, color: C.dim })
1555
+ if (f.model) lines.push({ text: ` 模型: ${f.model}`, color: C.dim })
1556
+ lines.push({ text: ` ❯ ${WIZARD_STEPS[w.step].prompt}`, color: ansi.bold + C.text })
1557
+ lines.push({ text: " (在下方输入框输入)", color: C.dim })
1558
+ w.selectedLine = 0
1559
+ }
1560
+ if (w.error) lines.push({ text: ` ${w.error}`, color: C.error })
1561
+ w.lines = lines
1562
+ render()
1563
+ }
1564
+
1565
+ function wizardChooseProvider(item) {
1566
+ const w = state.wizard
1567
+ if (item.kind === "custom") {
1568
+ w.step = "name"
1569
+ } else {
1570
+ w.fields = { name: item.name, baseURL: item.baseURL, model: item.model }
1571
+ w.step = "key"
1572
+ }
1573
+ renderWizard()
1574
+ }
1575
+
1576
+ function wizardSubmitText() {
1577
+ const w = state.wizard
1578
+ const value = state.input.join("").trim()
1579
+ const ok = WIZARD_STEPS[w.step].validate(value)
1580
+ if (ok !== true) {
1581
+ w.error = ok
1582
+ renderWizard()
1583
+ return
1584
+ }
1585
+ w.error = null
1586
+ state.input = []
1587
+ state.cursor = 0
1588
+ w.fields[w.step === "key" ? "key" : w.step] = w.step === "baseURL" ? value.replace(/\/+$/, "") : value
1589
+ const next = WIZARD_NEXT[w.step]
1590
+ if (next) {
1591
+ w.step = next
1592
+ renderWizard()
1593
+ } else {
1594
+ finishWizard().catch((e) => pushLine(`[error] ${e.message}`, C.error))
1595
+ }
1596
+ }
1597
+
1598
+ function cancelWizard() {
1599
+ state.wizard = null
1600
+ pushLine("已跳过初始配置。之后随时可用 /provider add 添加提供商、/provider key 配 key。", C.dim)
1601
+ render()
1602
+ }
1603
+
1604
+ /** 向导完成:写入 provider(有则更新)、设为激活、持久化,然后接模型选择器 */
1605
+ async function finishWizard() {
1606
+ const f = state.wizard.fields
1607
+ state.wizard = null
1608
+ const existing = agent.providers.find((p) => p.name === f.name)
1609
+ if (existing) Object.assign(existing, { baseURL: f.baseURL, model: f.model, apiKey: f.key })
1610
+ else agent.providers.push({ name: f.name, baseURL: f.baseURL, model: f.model, apiKey: f.key })
1611
+ agent.activeProvider = f.name
1612
+ agent.provider = { ...agent.providers.find((p) => p.name === f.name) }
1613
+ if (agent.config?.agent?.compactThresholdAuto) {
1614
+ const { resolveCompactThreshold } = await import("./config.mjs")
1615
+ agent.config.agent.compactThreshold = resolveCompactThreshold(null, f.model).value
1616
+ }
1617
+ await persistRaw((raw) => {
1618
+ raw.providers = agent.providers
1619
+ raw.activeProvider = f.name
1620
+ })
1621
+ agent.config.activeProvider = f.name
1622
+ pushLabel(`❯ Setup`, ansi.bold + C.tool)
1623
+ pushLine(`配置完成:${f.name} / ${f.model}(已写入配置文件)`, C.tool)
1624
+ // embedding key:配了就启用向量检索,没配提示事后通道
1625
+ if (f.embedkey) {
1626
+ agent.config.embedding ??= {}
1627
+ agent.config.embedding.apiKey = f.embedkey
1628
+ await persistRaw((raw) => { raw.embedding = { ...(raw.embedding ?? {}), apiKey: f.embedkey } })
1629
+ if (agent.memory && !agent.memory.embedder) {
1630
+ const { createEmbedder } = await import("./embedding.mjs")
1631
+ agent.memory.embedder = createEmbedder(agent.config.embedding)
1632
+ }
1633
+ pushLine(`向量检索已启用(${agent.config.embedding.model ?? "BAAI/bge-m3"})`, C.tool)
1634
+ } else {
1635
+ pushLine(`向量检索未启用(记忆退化为纯文本检索);之后可 /config embedkey <key> 开启`, C.dim)
1636
+ }
1637
+ pushLine(`选择要用的模型(Esc 保持 ${f.model})`, C.dim)
1638
+ openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
1639
+ }
1640
+
1641
+ /** 选中:切换 provider + 模型,持久化,阈值随模型走 */
1642
+ async function selectModel(item) {
1643
+ closeModelPicker()
1644
+ const target = agent.providers.find((pp) => pp.name === item.provider)
1645
+ if (!target) return
1646
+ target.model = item.model
1647
+ agent.activeProvider = item.provider
1648
+ agent.provider = { ...target }
1649
+ if (!agent.provider.apiKey) {
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]
1652
+ }
1653
+ if (!agent.provider.apiKey) agent.provider.apiKey = process.env.THINCODER_API_KEY
1654
+ let thresholdNote = ""
1655
+ if (agent.config?.agent?.compactThresholdAuto) {
1656
+ const { resolveCompactThreshold } = await import("./config.mjs")
1657
+ const { value } = resolveCompactThreshold(null, item.model)
1658
+ agent.config.agent.compactThreshold = value
1659
+ thresholdNote = `,压缩阈值随模型调整为 ${value}`
1660
+ }
1661
+ await persistRaw((raw) => {
1662
+ raw.providers = agent.providers
1663
+ raw.activeProvider = item.provider
1664
+ })
1665
+ agent.config.activeProvider = item.provider
1666
+ pushLabel(`❯ Model`, ansi.bold + C.tool)
1667
+ pushLine(`已切换到 ${item.provider} / ${item.model}${thresholdNote}(已持久化)`, C.tool)
1668
+ if (!agent.provider.apiKey) pushLine(`该 provider 还没配 key: /config key <apikey>`, C.warn)
1669
+ }
1670
+
732
1671
  /** /distill:从当前会话提取候选,逐条 y/n 确认后入库 */
733
1672
  async function runDistill() {
734
1673
  if (agent.history.length === 0) {
@@ -774,22 +1713,130 @@ export async function startTUI(agent, opts = {}) {
774
1713
 
775
1714
  // keypress 挂在过滤后的 keyStream 上:鼠标序列已在上游滤网中处理并剥除
776
1715
  keyStream.on("keypress", (str, key = {}) => {
777
- // 权限确认态:只认 y/n
1716
+ // 权限确认态:y 批准 / n 拒绝 / a 批准并开启 AUTO(后续不再询问)
778
1717
  if (state.permission) {
779
1718
  const answer = (str || "").toLowerCase()
780
- if (answer === "y" || answer === "n" || 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") {
781
1722
  const { resolve } = state.permission
782
1723
  state.permission = null
783
1724
  state.status = "Processing..."
784
- resolve(answer === "y")
1725
+ if (answer === "a" && !isContinue) {
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.]")
1729
+ pushLine(` [auto] AUTO 已开启:后续工具调用不再询问(/auto 关闭)`, C.warn)
1730
+ }
1731
+ resolve(answer === "y" || (answer === "a" && !isContinue))
785
1732
  render()
786
1733
  }
787
1734
  return
788
1735
  }
789
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
+
790
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
+ }
791
1793
  cleanup()
792
- process.exit(0)
1794
+ setTimeout(() => process.exit(0), 100)
1795
+ }
1796
+
1797
+ // 模型选择器:↑↓ 移动,Enter 确认,Esc 取消,其余按键吞掉
1798
+ if (state.picker) {
1799
+ const items = pickerItems()
1800
+ if (key.name === "escape") {
1801
+ closeModelPicker()
1802
+ } else if (key.name === "up" && items.length) {
1803
+ state.picker.index = (state.picker.index - 1 + items.length) % items.length
1804
+ renderPickerLines()
1805
+ } else if (key.name === "down" && items.length) {
1806
+ state.picker.index = (state.picker.index + 1) % items.length
1807
+ renderPickerLines()
1808
+ } else if (key.name === "return" && items.length) {
1809
+ selectModel(items[state.picker.index]).catch((e) => pushLine(`[error] ${e.message}`, C.error))
1810
+ }
1811
+ return
1812
+ }
1813
+
1814
+ // 初始配置向导:菜单步 ↑↓/Enter/Esc;文本步 Enter 提交、Esc 取消,编辑键落到正常输入
1815
+ if (state.wizard) {
1816
+ const w = state.wizard
1817
+ if (key.name === "escape") {
1818
+ cancelWizard()
1819
+ return
1820
+ }
1821
+ if (w.step === "provider") {
1822
+ const items = wizardProviderItems()
1823
+ if (key.name === "up" && items.length) {
1824
+ w.index = (w.index - 1 + items.length) % items.length
1825
+ renderWizard()
1826
+ } else if (key.name === "down" && items.length) {
1827
+ w.index = (w.index + 1) % items.length
1828
+ renderWizard()
1829
+ } else if (key.name === "return" && items.length) {
1830
+ wizardChooseProvider(items[w.index])
1831
+ }
1832
+ return
1833
+ }
1834
+ if (key.name === "return") {
1835
+ wizardSubmitText()
1836
+ return
1837
+ }
1838
+ // 文本步骤屏蔽翻页/历史,其余编辑键放行到下面的普通输入逻辑
1839
+ if (key.name === "up" || key.name === "down" || key.name === "pageup" || key.name === "pagedown") return
793
1840
  }
794
1841
 
795
1842
  // 翻页
@@ -806,6 +1853,12 @@ export async function startTUI(agent, opts = {}) {
806
1853
 
807
1854
  if (state.processing) return // 处理中锁定输入
808
1855
 
1856
+ // Tab:斜杠命令补全(循环候选);其余输入忽略(\t 会顶破输入框,永不直接插入)
1857
+ if (key.name === "tab") {
1858
+ handleTab()
1859
+ return
1860
+ }
1861
+
809
1862
  // 输入历史
810
1863
  if (key.name === "up") {
811
1864
  if (state.history.length) {
@@ -874,9 +1927,9 @@ export async function startTUI(agent, opts = {}) {
874
1927
  return
875
1928
  }
876
1929
 
877
- // 可打印字符 / 粘贴(str 可能一次多个字符)
1930
+ // 可打印字符 / 粘贴(str 可能一次多个字符);Tab 一律转成两个空格(\t 显示宽度不定,会顶破输入框)
878
1931
  if (str && !key.ctrl && !key.meta) {
879
- const chars = [...str.replace(/\r/g, "")]
1932
+ const chars = [...str.replace(/\r/g, "").replace(/\t/g, " ")]
880
1933
  state.input.splice(state.cursor, 0, ...chars)
881
1934
  state.cursor += chars.length
882
1935
  render()
@@ -884,7 +1937,13 @@ export async function startTUI(agent, opts = {}) {
884
1937
  })
885
1938
 
886
1939
  // 启动画面
887
- pushLine(`Welcome to ThinCoder. Model: ${agent.provider.model}`, C.dim)
1940
+ if (!agent.provider.apiKey) {
1941
+ pushLabel(`欢迎使用 ThinCoder!`, ansi.bold + C.tool)
1942
+ pushLine("检测到还没配置 API key,进入初始配置(Esc 可随时跳过)", C.text)
1943
+ startWizard()
1944
+ } else {
1945
+ pushLine(`Welcome to ThinCoder. Provider: ${agent.activeProvider} / ${agent.provider.model}`, C.dim)
1946
+ }
888
1947
  pushLine(`Tools: ${agent.tools.map((t) => t.name).join(", ")}`, C.dim)
889
1948
  // 恢复上次会话:重建对话区显示(tool 结果行省略,保持清爽)
890
1949
  if (opts.restored?.history?.length) {