thincoder 0.7.1 → 0.7.3

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,7 +1,7 @@
1
1
  /**
2
2
  * tui.mjs — 裸 ANSI 终端 UI
3
3
  * 零依赖:raw mode 键盘输入、ANSI 转义渲染、自研宽字符换行。
4
- * 布局:header / 对话区(可滚动)/ todo 面板(有任务时)/ 输入框 / 状态栏。
4
+ * 布局:header / 对话区 (可滚动)/ todo 面板 (有任务时)/ 输入框 / 状态栏。
5
5
  */
6
6
 
7
7
  import { emitKeypressEvents } from "node:readline"
@@ -11,7 +11,7 @@ import { existsSync, readFileSync } from "node:fs"
11
11
  import { runAgent, ContinueError } from "./agent.mjs"
12
12
  import { estimateTokens } from "./context.mjs"
13
13
  import { saveSession, clearSession, archiveCurrent, listSlots, switchToSlot, sessionPath } from "./session.mjs"
14
- import { PROVIDER_PRESETS as PRESETS } from "./config.mjs"
14
+ import { PROVIDER_PRESETS as PRESETS, specForModel } from "./config.mjs"
15
15
  import { closeAllMcp } from "./mcp.mjs"
16
16
 
17
17
  // ---------------------------------------------------------------- ANSI 工具
@@ -22,7 +22,7 @@ const ansi = {
22
22
  showCursor: `${ESC}[?25h`,
23
23
  altBuffer: `${ESC}[?1049h`,
24
24
  mainBuffer: `${ESC}[?1049l`,
25
- mouseOn: `${ESC}[?1000h${ESC}[?1006h`, // 基本鼠标 + SGR 扩展坐标(滚轮上报)
25
+ mouseOn: `${ESC}[?1000h${ESC}[?1006h`, // 基本鼠标 + SGR 扩展坐标 (滚轮上报)
26
26
  mouseOff: `${ESC}[?1000l${ESC}[?1006l`,
27
27
  home: `${ESC}[H`,
28
28
  clearLine: `${ESC}[K`,
@@ -34,10 +34,10 @@ const ansi = {
34
34
  }
35
35
 
36
36
  const C = {
37
- user: ansi.fg(4), // blue(标签)
38
- assistant: ansi.fg(2), // green(标签)
39
- text: ansi.fg(7), // white(对话正文)
40
- reason: `${ESC}[2m${ESC}[3m`, // dim + italic(思考流)
37
+ user: ansi.fg(4), // blue (标签)
38
+ assistant: ansi.fg(2), // green (标签)
39
+ text: ansi.fg(7), // white (对话正文)
40
+ reason: `${ESC}[2m${ESC}[3m`, // dim + italic (思考流)
41
41
  tool: ansi.fg(6), // cyan
42
42
  error: ansi.fg(1), // red
43
43
  dim: ansi.gray,
@@ -100,7 +100,7 @@ const isTableRow = (line) => (line.match(/\|/g) ?? []).length >= 2
100
100
  const isTableSeparator = (line) => /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line) && line.includes("-")
101
101
 
102
102
  /**
103
- * 识别文本中的 markdown 表格块,按显示宽度重排(修 CJK 错位)。
103
+ * 识别文本中的 markdown 表格块,按显示宽度重排 (修 CJK 错位)。
104
104
  * width 为可用显示宽度;过宽的表格按列收缩。非表格行原样保留。
105
105
  */
106
106
  export function formatTables(text, width) {
@@ -135,7 +135,7 @@ function renderTable(block, width) {
135
135
  const colCount = Math.max(...rows.map((r) => r.length))
136
136
  for (const r of rows) while (r.length < colCount) r.push("")
137
137
 
138
- // 列宽:先按内容,超宽则从最宽列开始收缩(收缩到至少 3)
138
+ // 列宽:先按内容,超宽则从最宽列开始收缩 (收缩到至少 3)
139
139
  const widths = Array.from({ length: colCount }, (_, c) =>
140
140
  Math.max(3, ...rows.map((r) => stringWidth(r[c] ?? ""))),
141
141
  )
@@ -145,7 +145,7 @@ function renderTable(block, width) {
145
145
  widths[widest]--
146
146
  }
147
147
 
148
- // 单元格渲染:sliceByWidth 截断(表头单行),padByWidth 补齐
148
+ // 单元格渲染:sliceByWidth 截断 (表头单行),padByWidth 补齐
149
149
  const fmtCell = (text, ci) => padByWidth(sliceByWidth(text, widths[ci]), widths[ci])
150
150
  const fmtRow = (cells) => "│ " + cells.map((c, i) => fmtCell(c, i)).join(" │ ") + " │"
151
151
 
@@ -153,7 +153,7 @@ function renderTable(block, width) {
153
153
  const separator = "├" + widths.map((w) => "─".repeat(w + 2)).join("┼") + "┤"
154
154
 
155
155
  const out = []
156
- // 表头:单行截断(表头通常是短标签,折行不如截断直观)
156
+ // 表头:单行截断 (表头通常是短标签,折行不如截断直观)
157
157
  out.push(fmtRow(rows[0]))
158
158
  out.push(separator)
159
159
 
@@ -170,7 +170,7 @@ function renderTable(block, width) {
170
170
  return out
171
171
  }
172
172
 
173
- /** 输入区布局:把输入缓冲折行,同时算出光标的 (行, 列) 位置(显示宽度) */
173
+ /** 输入区布局:把输入缓冲折行,同时算出光标的 (行, 列) 位置 (显示宽度) */
174
174
  export function layoutInput(chars, cursor, width) {
175
175
  const PROMPT = "▸ "
176
176
  const lines = []
@@ -207,7 +207,7 @@ export function layoutInput(chars, cursor, width) {
207
207
  }
208
208
 
209
209
  /**
210
- * 显示净化:控制字符会破坏终端网格数学(\r 回车覆盖、\t 宽度误判致整帧错位、ANSI/响铃冲屏)。
210
+ * 显示净化:控制字符会破坏终端网格数学 (\r 回车覆盖、\t 宽度误判致整帧错位、ANSI/响铃冲屏)。
211
211
  * 只动显示层——模型看到的工具结果原文不变;session 里已存的脏 display 回放时也经此净化。
212
212
  */
213
213
  const ANSI_SEQUENCE_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/g
@@ -221,7 +221,7 @@ export function sanitizeDisplay(s) {
221
221
  .replace(/\n+$/, "")
222
222
  }
223
223
 
224
- /** 文本按宽度折行(保留 \n),返回行数组 */
224
+ /** 文本按宽度折行 (保留 \n),返回行数组 */
225
225
  export function wrapText(text, width) {
226
226
  const lines = []
227
227
  for (const rawLine of text.split("\n")) {
@@ -256,8 +256,8 @@ export async function startTUI(agent, opts = {}) {
256
256
 
257
257
  const state = {
258
258
  lines: [], // 对话区行:{ text, color }
259
- streaming: "", // 当前流式缓冲
260
- input: [], // 输入缓冲区(码点数组)
259
+ streaming: "", // current流式缓冲
260
+ input: [], // 输入缓冲区 (码点数组)
261
261
  cursor: 0,
262
262
  history: [],
263
263
  historyIndex: -1,
@@ -265,30 +265,29 @@ export async function startTUI(agent, opts = {}) {
265
265
  processing: false,
266
266
  controller: null, // AbortController for current agent run
267
267
  permission: null, // { name, args, resolve }
268
- permissionPreview: [], // 权限审批的内容预览行(渲染在输入框上方,不分隔)
268
+ permissionPreview: [], // 权限审批的内容预览行 (渲染在输入框上方,不分隔)
269
269
  question: null, // { text, options, resolve } — agent 的 question 工具回调
270
270
  picker: null, // 模型选择器 { entries, lines, index, scroll, selectedLine }
271
- wizard: null, // 首次配置向导 { step, index, scroll, selectedLine, fields, error, lines }
272
- tasks: agent.tasks ?? [], // task 工具的任务列表(状态栏显示进度);会话恢复时直接带上,全完成自动收起
273
- tokens: { prompt: 0, completion: 0, cacheHit: 0, cacheMiss: 0 }, // 累计 token 用量(状态栏显示)
274
- ctxCache: { len: -1, tokens: 0 }, // 上下文占用估算缓存(estimateTokens 是 O(n),history 变长才重算)
275
- reasoning: "", // 思考流缓冲(暗色展示)
271
+ wizard: null, // 首次Config向导 { step, index, scroll, selectedLine, fields, error, lines }
272
+ tasks: agent.tasks ?? [], // task 工具的任务列表 (状态栏显示进度);会话恢复时直接带上,全完成自动收起
273
+ tokens: { prompt: 0, completion: 0, cacheHit: 0, cacheMiss: 0 }, // 累计 token 用量 (状态栏显示)
274
+ ctxCache: { len: -1, tokens: 0 }, // 上下文占用估算缓存 (estimateTokens 是 O(n),history 变长才重算)
275
+ reasoning: "", // 思考流缓冲 (暗色展示)
276
276
  completion: null, // Tab 补全状态 { candidates, index }
277
- toolStreams: {}, // 各工具的实时输出(按工具名隔离,并行工具互不串扰)
278
- subOutput: "", // 子 agent 流式输出(滚动显示,最长保留末尾 300 字符)
279
- currentSub: null, // 当前活跃的子 agent 角色名
280
- currentTool: null, // 正在执行的工具名(状态栏显示)
281
- processingStarted: 0, // 本轮处理开始时间(状态栏计时)
277
+ toolStreams: {}, // 各工具的实时输出 (按工具名隔离,并行工具互不串扰)
278
+ subTasks: {}, // 子 agent 面板:{ roleName: { role, text, done } },每 role 一行,完成后标记 done 停留片刻
279
+ currentTool: null, // 正在执行的工具名 (状态栏显示)
280
+ processingStarted: 0, // 本轮处理开始时间 (状态栏计时)
282
281
  status: "Ready",
283
282
  }
284
283
 
285
- // 恢复的会话如果所有任务已完成,自动收起 todo 面板(对齐运行时行为)
284
+ // 恢复的会话如果所有任务completed,自动收起 todo 面板 (对齐运行时行为)
286
285
  if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
287
286
  state.tasks = []
288
287
  }
289
288
 
290
- // 输入流先过一道滤网:鼠标序列(滚轮)在这里拦截处理,剥净后才交给 keypress 解析,
291
- // 防止序列残片(如 "64;72;42M")漏进输入框
289
+ // 输入流先过一道滤网:鼠标序列 (滚轮)在这里拦截处理,剥净后才交给 keypress 解析,
290
+ // 防止序列残片 (如 "64;72;42M")漏进输入框
292
291
  const keyStream = new PassThrough()
293
292
  let mousePending = "" // 跨 chunk 的不完整鼠标序列尾部
294
293
  let lastRenderedScroll = 0
@@ -300,7 +299,7 @@ export async function startTUI(agent, opts = {}) {
300
299
  let text = mousePending + chunk.toString("utf8")
301
300
  mousePending = ""
302
301
 
303
- // 滚轮:\x1b[<64;…M 上滚,\x1b[<65;…M 下滚(每次 3 行)
302
+ // 滚轮:\x1b[<64;…M 上滚,\x1b[<65;…M 下滚 (每次 3 行)
304
303
  for (const m of text.matchAll(/\x1b\[<(\d+);\d+;\d+([Mm])/g)) {
305
304
  if (Number(m[1]) === 64) {
306
305
  state.scroll += 3
@@ -328,14 +327,14 @@ export async function startTUI(agent, opts = {}) {
328
327
  const cleanup = () => {
329
328
  if (cleanedUp) return
330
329
  cleanedUp = true
331
- // 退出前保存会话(同步写);先归档当前到槽位,再落新——不丢
330
+ // 退出前保存会话 (同步写);先归档current到槽位,再落新——不丢
332
331
  try {
333
332
  archiveCurrent(agent.cwd)
334
333
  saveSession(agent, state.lines)
335
334
  } catch {
336
335
  // 存失败不耽误退出
337
336
  }
338
- // 关闭 MCP stdio 子进程,不留孤儿
337
+ // Off MCP stdio 子进程,不留孤儿
339
338
  try {
340
339
  closeAllMcp(agent)
341
340
  } catch {
@@ -348,7 +347,7 @@ export async function startTUI(agent, opts = {}) {
348
347
 
349
348
  const pushLine = (text, color) => {
350
349
  state.lines.push({ text, color })
351
- if (state.lines.length > 5000) state.lines.splice(0, 1000) // 防无限增长
350
+ if (state.lines.length > 5000) state.lines.splice(0, 1000) // 防none限增长
352
351
  render()
353
352
  }
354
353
 
@@ -359,7 +358,7 @@ export async function startTUI(agent, opts = {}) {
359
358
  render()
360
359
  }
361
360
 
362
- // 每轮对话只打一次助手标签(首个 token 或首个工具调用时)
361
+ // 每轮对话只打一次助手标签 (首个 token 或首个工具调用时)
363
362
  let assistantLabeled = false
364
363
  const ensureAssistantLabel = () => {
365
364
  if (!assistantLabeled) {
@@ -370,11 +369,11 @@ export async function startTUI(agent, opts = {}) {
370
369
 
371
370
  // ---------------------------------------------------------- 渲染
372
371
 
373
- // 帧去重 + 流式限流:内容没变的帧不重写(防闪屏);token 洪流合并到 ~25fps
372
+ // 帧去重 + 流式限流:内容没变的帧不重写 (防闪屏);token 洪流合并到 ~25fps
374
373
  let lastFrame = ""
375
374
  let renderTimer = null
376
375
 
377
- /** 流式期间的限流渲染(trailing edge:最后一次变化一定渲染到) */
376
+ /** 流式期间的限流渲染 (trailing edge:最后一次变化一定渲染到) */
378
377
  function scheduleRender() {
379
378
  if (renderTimer) return
380
379
  renderTimer = setTimeout(() => {
@@ -389,10 +388,11 @@ export async function startTUI(agent, opts = {}) {
389
388
  const model = agent.provider.model
390
389
  const thinking = agent.provider.thinking
391
390
  const effort = agent.provider.reasoningEffort
391
+ const isMultimodal = specForModel(model).multimodal
392
392
  const thinkBadge = thinking?.type === "disabled" ? "│ think: off"
393
393
  : effort ? `│ think: ${effort}` : thinking?.type === "enabled" ? "│ think: on" : ""
394
394
 
395
- // 输入区:全边框盒,宽度 W(所有输出行严格 ≤ cols-1,防自动折行错位)
395
+ // 输入区:全边框盒,宽度 W (所有输出行严格 ≤ cols-1,防自动折行错位)
396
396
  const W = Math.max(20, cols - 1)
397
397
  const layout = layoutInput(state.input, state.cursor, W - 4)
398
398
  // 最多显示 5 行;超出时以光标所在行为中心滚动
@@ -402,12 +402,12 @@ export async function startTUI(agent, opts = {}) {
402
402
  inputOffset = Math.min(layout.cursorLine, layout.lines.length - MAX_INPUT_LINES)
403
403
  }
404
404
  const inputLines = layout.lines.slice(inputOffset, inputOffset + MAX_INPUT_LINES)
405
- // question 模式下输入框显示选项/答案草稿,而不是普通输入(高度也要跟着走)
405
+ // question 模式下输入框显示选项/答案草稿,而不是普通输入 (高度也要跟着走)
406
406
  let boxLines = inputLines
407
407
  if (state.question) {
408
408
  const q = state.question
409
409
  if (q.options.length > 0) {
410
- // 选项窗口:只显示选中项 ±2,选项过多时防输入框无限增高撑破锚定布局
410
+ // 选项窗口:只显示选中项 ±2,选项过多时防输入框none限增高撑破锚定布局
411
411
  const sel = q.selected ?? 0
412
412
  const QWIN = 5
413
413
  const start = Math.max(0, Math.min(sel - 2, q.options.length - QWIN))
@@ -422,7 +422,7 @@ export async function startTUI(agent, opts = {}) {
422
422
 
423
423
  const headerH = 1
424
424
  const statusH = 1
425
- // 浮层(模型选择器 / 初始配置向导)打开时,在对话区下方预留一块(标题 + 列表窗口)
425
+ // 浮层 (模型选择器 / 初始Config向导)打开时,在对话区下方预留一块 (标题 + 列表窗口)
426
426
  const overlay = state.picker ?? state.wizard
427
427
  const pickerH = overlay
428
428
  ? Math.min(overlay.lines.length + 1, Math.max(6, rows - 12))
@@ -440,9 +440,11 @@ export async function startTUI(agent, opts = {}) {
440
440
  visibleTasks = [...inProgress, ...pending, ...done].slice(0, MAX_TASK_LINES)
441
441
  }
442
442
  const taskPanelH = visibleTasks.length
443
- // 子 agent 流式输出占位(显示时占最多 2 行)
444
- const subOutLen = (state.subOutput && state.processing) ? wrapText(state.subOutput, W - 8).slice(-2).length : 0
445
- // 权限预览占位:字符数之外再封顶显示行数(rows-8),多行短行也能把帧撑过终端高度,破坏锚定布局
443
+ // 子 agent 面板 (subTasks):每活跃子 agent 一行,上方对话区下方,最多 4 行折叠
444
+ const activeSubs = Object.values(state.subTasks).filter((s) => !s.done)
445
+ const subPanelH = Math.min(activeSubs.length, 4)
446
+ const subOutLen = subPanelH
447
+ // 权限预览占位:字符数之外再封顶显示行数 (rows-8),多行短行也能把帧撑过终端高度,破坏锚定布局
446
448
  let permPreviewLines = []
447
449
  if (state.permission) {
448
450
  const maxLines = Math.max(1, rows - 8)
@@ -456,7 +458,7 @@ export async function startTUI(agent, opts = {}) {
456
458
  const permPreviewLen = state.permission ? 1 + permPreviewLines.length : 0
457
459
  const convH = Math.max(1, rows - headerH - inputBoxH - statusH - pickerH - taskPanelH - subOutLen - permPreviewLen)
458
460
 
459
- // 对话区内容行(含流式缓冲);markdown 表格先按显示宽度重排
461
+ // 对话区内容行 (含流式缓冲);markdown 表格先按显示宽度重排
460
462
  const convLines = []
461
463
  for (const l of state.lines) {
462
464
  for (const line of formatTables(sanitizeDisplay(l.text), cols - 1)) {
@@ -465,7 +467,7 @@ export async function startTUI(agent, opts = {}) {
465
467
  }
466
468
  }
467
469
  }
468
- // 思考流(暗色)在正文流之前
470
+ // 思考流 (暗色)在正文流之前
469
471
  if (state.reasoning) {
470
472
  for (const wrapped of wrapText(sanitizeDisplay(state.reasoning), cols - 1)) {
471
473
  convLines.push({ text: wrapped, color: C.reason })
@@ -478,7 +480,7 @@ export async function startTUI(agent, opts = {}) {
478
480
  }
479
481
  }
480
482
  }
481
- // 工具实时输出(暗色,只保留末尾防刷屏;按工具名隔离防止并行工具串扰)
483
+ // 工具实时输出 (暗色,只保留末尾防刷屏;按工具名隔离防止并行工具串扰)
482
484
  const allStreams = Object.values(state.toolStreams).join("")
483
485
  if (allStreams) {
484
486
  const tail = sanitizeDisplay(allStreams.slice(-4000))
@@ -494,26 +496,26 @@ export async function startTUI(agent, opts = {}) {
494
496
 
495
497
  const out = [ansi.home]
496
498
 
497
- // header(超宽截断,防终端折行)
499
+ // header (超宽截断,防终端折行)
498
500
  out.push(
499
501
  `${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}`,
500
502
  )
501
503
 
502
- // 对话区(不足部分补空行,把输入框钉在底部)
504
+ // 对话区 (不足部分补空行,把输入框钉在底部)
503
505
  const pad = convH - visible.length
504
506
  for (let i = 0; i < pad; i++) out.push(ansi.clearLine)
505
507
  for (const l of visible) {
506
508
  out.push(`${l.color}${l.text}${ansi.reset}${ansi.clearLine}`)
507
509
  }
508
510
 
509
- // 浮层(模型选择器 / 初始配置向导):列表滚动跟随选中行
511
+ // 浮层 (模型选择器 / 初始Config向导):列表滚动跟随选中行
510
512
  if (overlay) {
511
513
  const winH = pickerH - 1
512
514
  if (overlay.selectedLine < overlay.scroll) overlay.scroll = overlay.selectedLine
513
515
  if (overlay.selectedLine >= overlay.scroll + winH) overlay.scroll = overlay.selectedLine - winH + 1
514
516
  const start = Math.max(0, Math.min(overlay.scroll, Math.max(0, overlay.lines.length - winH)))
515
517
  const shown = overlay.lines.slice(start, start + winH)
516
- const overlayTitle = state.picker ? "选择模型 " : " ❯ 初始配置 "
518
+ const overlayTitle = state.picker ? `${state.picker.title} ` : " ❯ 初始Config "
517
519
  out.push(`${ansi.bold}${C.tool}${overlayTitle}${ansi.reset}${ansi.dim}${state.picker ? "(↑↓ 移动, Enter 确认, Esc 取消)" : ""}${ansi.reset}${ansi.clearLine}`)
518
520
  for (const l of shown) {
519
521
  out.push(`${l.color}${sliceByWidth(l.text, cols - 1)}${ansi.reset}${ansi.clearLine}`)
@@ -521,23 +523,29 @@ export async function startTUI(agent, opts = {}) {
521
523
  for (let i = shown.length; i < winH; i++) out.push(ansi.clearLine)
522
524
  }
523
525
 
524
- // todo 面板(对话区与输入框之间):▶ in_progress / ✓ done(删除线) / ○ pending
526
+ // todo 面板 (对话区与输入框之间):▶ in_progress / ✓ done(删除线) / ○ pending
525
527
  for (const t of visibleTasks) {
526
528
  const mark = t.status === "done" ? "✓" : t.status === "in_progress" ? "▶" : "○"
527
529
  const color = t.status === "done" ? `${C.dim}${ESC}[9m` : t.status === "in_progress" ? C.tool : C.text
528
530
  out.push(`${color} ${mark} ${sliceByWidth(t.title, cols - 4)}${ansi.reset}${ansi.clearLine}`)
529
531
  }
530
532
 
531
- // 子 agent 流式输出(最多 2 行,滚动显示最新内容)
532
- if (state.subOutput && state.processing) {
533
- const lines = wrapText(state.subOutput, W - 8)
534
- const tail = lines.slice(-2)
535
- for (const l of tail) {
536
- out.push(`${C.dim}[${state.currentSub}] ${l}${ansi.reset}${ansi.clearLine}`)
533
+ // 子 agent 面板:每活跃子 agent 一行,done 的灰色显示后 3 秒自动清除
534
+ const subs = Object.values(state.subTasks)
535
+ if (subs.length > 0 && state.processing) {
536
+ for (const s of subs.slice(0, 4)) {
537
+ const icon = s.done ? "✓" : "…"
538
+ const color = s.done ? C.dim : C.tool
539
+ const label = `[${s.role}]`.padEnd(10)
540
+ const text = s.text ? sliceByWidth(s.text, W - 14) : (s.done ? "done" : "running...")
541
+ out.push(`${color} ${icon} ${label} ${text}${ansi.reset}${ansi.clearLine}`)
542
+ }
543
+ if (subs.length > 4) {
544
+ out.push(`${C.dim} ... +${subs.length - 4} more subagents${ansi.reset}${ansi.clearLine}`)
537
545
  }
538
546
  }
539
547
 
540
- // 权限审批内容预览(黄色,紧挨输入框上方);用上方已封顶的 permPreviewLines,渲染行数与占位一致
548
+ // 权限审批内容预览 (黄色,紧挨输入框上方);用上方已封顶的 permPreviewLines,渲染行数与占位一致
541
549
  if (state.permission) {
542
550
  out.push(`${ansi.bold}${C.warn}❯ 权限请求${ansi.reset}${ansi.clearLine}`)
543
551
  for (const wrapped of permPreviewLines) {
@@ -545,7 +553,7 @@ export async function startTUI(agent, opts = {}) {
545
553
  }
546
554
  }
547
555
 
548
- // 输入框(全边框,宽 W)
556
+ // 输入框 (全边框,宽 W)
549
557
  let borderColor = C.tool
550
558
  let title
551
559
  if (state.question) {
@@ -559,7 +567,7 @@ export async function startTUI(agent, opts = {}) {
559
567
  title = ` Allow ${state.permission.name}? (y/n/a) `
560
568
  }
561
569
  } else if (state.picker) {
562
- title = " Model "
570
+ title = " Select "
563
571
  } else if (state.wizard) {
564
572
  title = " Setup "
565
573
  } else if (state.processing) {
@@ -567,7 +575,13 @@ export async function startTUI(agent, opts = {}) {
567
575
  } else {
568
576
  title = " Input "
569
577
  }
570
- const topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 3 - stringWidth(title)))}╮`
578
+ let topBorder
579
+ if (title === " Input " && isMultimodal) {
580
+ const hint = process.platform === "win32" ? " Alt+V paste " : " Ctrl+V paste "
581
+ topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 4 - stringWidth(title) - stringWidth(hint)))}${hint}─╮`
582
+ } else {
583
+ topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 3 - stringWidth(title)))}╮`
584
+ }
571
585
  out.push(`${borderColor}${topBorder}${ansi.reset}${ansi.clearLine}`)
572
586
  for (const l of boxLines) {
573
587
  const content = sliceByWidth(l, W - 4)
@@ -576,51 +590,59 @@ export async function startTUI(agent, opts = {}) {
576
590
  }
577
591
  out.push(`${borderColor}╰${"─".repeat(Math.max(0, W - 2))}╯${ansi.reset}${ansi.clearLine}`)
578
592
 
579
- // 状态栏(输入 / 开头时变为命令提示)
593
+ // 状态栏 (输入 / 开头时变为Commands提示)
580
594
  const scrollHint = state.scroll > 0 ? ` │ scrolled ${state.scroll}` : ""
581
595
  const rawInput = state.input.join("")
582
596
  let statusLine
583
597
  if (state.question) {
584
598
  const q = state.question
585
599
  statusLine = q.options.length > 0
586
- ? " ↑↓: 选择 │ Enter: 确认 │ Esc: 取消"
587
- : " 输入回答后 Enter 提交 │ Esc: 取消"
600
+ ? " ↑↓: select │ Enter: confirm │ Esc: cancel"
601
+ : " Type answer then Enter │ Esc: cancel"
588
602
  } else if (state.permission) {
589
603
  statusLine = state.permission.name === "continue"
590
- ? " y: 继续 │ n: 停止"
591
- : " y: 批准 │ n: 拒绝 │ a: 批准并全部放行(AUTO"
604
+ ? " y: continue │ n: stop"
605
+ : " y: approve │ n: deny │ a: approve all (AUTO)"
592
606
  } else if (state.picker) {
593
- statusLine = " ↑↓: 选择 │ Enter: 确认 │ Esc: 取消"
607
+ statusLine = " ↑↓: select │ Enter: confirm │ Esc: cancel"
594
608
  } else if (state.wizard) {
595
609
  statusLine = state.wizard.step === "provider"
596
- ? " ↑↓: 选择 │ Enter: 确认 │ Esc: 跳过"
597
- : " 输入后 Enter 确认 │ Esc: 取消"
610
+ ? " ↑↓: select │ Enter: confirm │ Esc: skip"
611
+ : " Type then Enter │ Esc: cancel"
598
612
  } else if (rawInput.startsWith("/") && !state.processing && !state.permission) {
599
613
  const [cmd, sub] = rawInput.split(/\s+/)
600
614
  const cmds = SLASH_COMMANDS.filter((c) => c.name.startsWith(cmd))
601
615
  const match = cmds.length === 1 ? cmds[0] : null
602
616
  if (match?.name === "/config" && cmd === "/config") {
603
- statusLine = " /config 查看 │ embedkey embedding │ set 改参数"
617
+ statusLine = " /config open config menu"
604
618
  } else if (match?.name === "/provider" && cmd === "/provider") {
605
- statusLine = " /provider 列表 │ add / remove / key"
619
+ statusLine = " /provider open provider management menu"
606
620
  } else if (match?.name === "/model" && cmd === "/model" && !sub) {
607
- statusLine = " /model 打开选择器 │ /model <名称> 直接切换"
621
+ statusLine = " /model open model picker"
608
622
  } else if (match?.name === "/think" && cmd === "/think") {
609
- statusLine = " /think 查看 │ on / off 开关 │ effort high / max 强度"
623
+ statusLine = " /think open thinking mode menu"
624
+ } else if (match?.name === "/mcp" && cmd === "/mcp") {
625
+ statusLine = " /mcp open MCP management menu"
626
+ } else if (match?.name === "/goal" && cmd === "/goal") {
627
+ statusLine = " /goal open goal management menu"
628
+ } else if (match?.name === "/session" && cmd === "/session") {
629
+ statusLine = " /session select archived session"
630
+ } else if (match?.name === "/rewind" && cmd === "/rewind") {
631
+ statusLine = " /rewind select checkpoint to restore"
610
632
  } else if (cmds.length > 0) {
611
633
  if (cmds.length <= 4) {
612
634
  statusLine = ` ${cmds.map((c) => `${c.name} ${c.desc}`).join(" │ ")}`
613
635
  } else {
614
- statusLine = ` ${cmds.map((c) => c.name).join(" ")} │ Tab 补全`
636
+ statusLine = ` ${cmds.map((c) => c.name).join(" ")} │ Tab complete`
615
637
  }
616
638
  } else {
617
- statusLine = ` 未知命令(/help 查看可用命令)`
639
+ statusLine = ` unknown command (/help for available commands)`
618
640
  }
619
641
  } else {
620
642
  const taskHint = state.tasks.length > 0
621
643
  ? ` │ ✓${state.tasks.filter((t) => t.status === "done").length}/${state.tasks.length}`
622
644
  : ""
623
- // token 用量:↑输入 ↓输出 + 缓存命中率(DeepSeek usage 带 prompt_cache_hit/miss_tokens)
645
+ // token 用量:↑输入 ↓输出 + 缓存命中率 (DeepSeek usage 带 prompt_cache_hit/miss_tokens)
624
646
  const tk = state.tokens
625
647
  const fmtK = (n) => (n >= 10000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`)
626
648
  const cacheTotal = tk.cacheHit + tk.cacheMiss
@@ -630,7 +652,7 @@ export async function startTUI(agent, opts = {}) {
630
652
  const elapsed = state.processing ? ` ${Math.floor((Date.now() - state.processingStarted) / 1000)}s` : ""
631
653
  const toolHint = state.currentTool ? ` ${state.currentTool}…` : ""
632
654
  const statusText = state.processing ? `${state.status}${toolHint}${elapsed}` : state.status
633
- // 上下文利用率:占压缩阈值百分比(到 100% 触发压缩;≥80% 变黄提醒该收尾或 /new)
655
+ // 上下文利用率:占压缩阈值百分比 (到 100% 触发压缩;≥80% 变黄提醒该收尾或 /new)
634
656
  if (state.ctxCache.len !== agent.history.length) {
635
657
  state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
636
658
  }
@@ -657,12 +679,12 @@ export async function startTUI(agent, opts = {}) {
657
679
  process.stdout.write(frame)
658
680
  }
659
681
 
660
- // 光标:输入态定位到输入框内(IME 候选框跟随真实光标);处理中/权限确认/菜单态时隐藏
682
+ // 光标:输入态定位到输入框内 (IME 候选框跟随真实光标);处理中/权限确认/菜单态时隐藏
661
683
  if (state.processing || state.permission || state.question || state.picker || state.wizard?.step === "provider") {
662
684
  process.stdout.write(ansi.hideCursor)
663
685
  } else {
664
686
  const cursorRow = 1 + convH + pickerH + taskPanelH + 2 + (layout.cursorLine - inputOffset) // header + 对话区 + todo 面板 + 上边框 + 行偏移
665
- const cursorCol = 3 + layout.cursorCol // 左边框 + 空格 + 文本偏移(1 基)
687
+ const cursorCol = 3 + layout.cursorCol // 左边框 + 空格 + 文本偏移 (1 基)
666
688
  process.stdout.write(`${ESC}[${cursorRow};${cursorCol}H${ansi.showCursor}`)
667
689
  }
668
690
  }
@@ -680,7 +702,7 @@ export async function startTUI(agent, opts = {}) {
680
702
  state.historyIndex = -1
681
703
  state.scroll = 0
682
704
 
683
- // 斜杠命令:本地处理,不进入 agent
705
+ // 斜杠Commands:本地处理,不进入 agent
684
706
  if (text.startsWith("/")) {
685
707
  await handleSlash(text)
686
708
  return
@@ -689,7 +711,7 @@ export async function startTUI(agent, opts = {}) {
689
711
  pushLabel(`❯ You:`, ansi.bold + C.user)
690
712
  pushLine(text, C.text)
691
713
 
692
- // 任务开始前自动打存档点(git 仓库内;失败静默,不挡任务)
714
+ // 任务开始前自动打存档点 (git 仓库内;失败静默,不挡任务)
693
715
  try {
694
716
  const { createCheckpoint } = await import("./checkpoint.mjs")
695
717
  await createCheckpoint(agent.cwd)
@@ -702,10 +724,11 @@ export async function startTUI(agent, opts = {}) {
702
724
  state.status = "Processing..."
703
725
  state.streaming = ""
704
726
  state.reasoning = ""
727
+ state.subTasks = {}
705
728
  state.currentTool = null
706
729
  state.processingStarted = Date.now()
707
730
  state.controller = new AbortController()
708
- // 处理中每秒刷新一次状态栏(运行计时)
731
+ // 处理中每秒刷新一次状态栏 (运行计时)
709
732
  const ticker = setInterval(() => {
710
733
  if (state.processing) render()
711
734
  }, 1000)
@@ -713,11 +736,12 @@ export async function startTUI(agent, opts = {}) {
713
736
 
714
737
  const callbacks = {
715
738
  onToken: (t) => {
716
- // 子 agent 流式输出:前缀匹配 explore/coder/plan/sub(无角色子 agent 用 sub/)的 token 进 subOutput
739
+ // 子 agent 流式输出:前缀匹配 explore/coder/plan/sub token 进 subTasks 面板
717
740
  const subMatch = t.match(/^(explore|coder|plan|sub)\//)
718
741
  if (subMatch) {
719
- state.currentSub = subMatch[1]
720
- state.subOutput = (state.subOutput + t.slice(subMatch[0].length)).slice(-300)
742
+ const role = subMatch[1]
743
+ if (!state.subTasks[role]) state.subTasks[role] = { role, text: "", done: false }
744
+ state.subTasks[role].text = (state.subTasks[role].text + t.slice(subMatch[0].length)).slice(-200)
721
745
  scheduleRender()
722
746
  return
723
747
  }
@@ -726,11 +750,11 @@ export async function startTUI(agent, opts = {}) {
726
750
  scheduleRender()
727
751
  },
728
752
  onReasoning: (t) => {
729
- // 子 agent 的思考 token 同样带 role/ 前缀,进 subOutput 滚动区,不污染主思考流
753
+ // 子 agent 的思考 token 同样带 role/ 前缀,进 subTasks 面板,不污染主思考流
730
754
  const subMatch = t.match(/^(explore|coder|plan|sub)\//)
731
755
  if (subMatch) {
732
- state.currentSub = subMatch[1]
733
- state.subOutput = (state.subOutput + t.slice(subMatch[0].length)).slice(-300)
756
+ const role = subMatch[1]
757
+ if (!state.subTasks[role]) state.subTasks[role] = { role, text: "", done: false }
734
758
  scheduleRender()
735
759
  return
736
760
  }
@@ -746,17 +770,25 @@ export async function startTUI(agent, opts = {}) {
746
770
  },
747
771
  onToolResult: (name, result) => {
748
772
  state.currentTool = null
749
- // 子 agent 结束(父 agent 侧的 subagent 工具结果带着最终报告):清空流式缓冲,报告进对话区。
750
- // 注意只能用精确匹配——子 agent 内部工具调用不 relay 到 TUI(刷了满屏的教训)
773
+ // 子 agent 结束:标记 done,面板保留片刻后清除
751
774
  const isSubagent = name === "subagent"
752
775
  if (isSubagent) {
753
- state.subOutput = ""
754
- state.currentSub = null
755
- // agent 报告摘要(最多 8 行)直接展示在对话区
776
+ // 所有活跃子 agent 标记 done
777
+ for (const key of Object.keys(state.subTasks)) {
778
+ state.subTasks[key].done = true
779
+ }
780
+ // 子 agent 报告摘要 (最多 8 行)直接展示在对话区
756
781
  const lines = result.split("\n")
757
782
  const preview = lines.slice(0, 8).map((l) => l.slice(0, 120)).join("\n")
758
783
  if (preview) pushLine(preview, C.dim)
759
784
  if (lines.length > 8) pushLine(` ... (${lines.length - 8} more lines)`, C.dim)
785
+ // 3 秒后清除面板中 done 的条目
786
+ setTimeout(() => {
787
+ for (const key of Object.keys(state.subTasks)) {
788
+ if (state.subTasks[key].done) delete state.subTasks[key]
789
+ }
790
+ if (state.processing) render()
791
+ }, 3000)
760
792
  }
761
793
  const stream = state.toolStreams[name]
762
794
  if (stream) {
@@ -776,7 +808,7 @@ export async function startTUI(agent, opts = {}) {
776
808
  onPermissionRequest: (name, args) => askPermission(name, args),
777
809
  onQuestion: (text, options) => askQuestion(text, options),
778
810
  onCompress: () => {
779
- pushLine(" [context] 上下文过长,已自动压缩(早期对话由 LLM 摘要,任务状态保留)", C.warn)
811
+ pushLine(" [context] Context too long, auto-compacted (early conversation summarized by LLM, task state preserved)", C.warn)
780
812
  },
781
813
  onUsage: (usage) => {
782
814
  state.tokens.prompt += usage.prompt_tokens ?? 0
@@ -784,10 +816,15 @@ export async function startTUI(agent, opts = {}) {
784
816
  state.tokens.cacheHit += usage.prompt_cache_hit_tokens ?? 0
785
817
  state.tokens.cacheMiss += usage.prompt_cache_miss_tokens ?? 0
786
818
  },
819
+ // 节流等待 (主动闸门 / 429 退避):状态栏明示,防用户以为卡死
820
+ onWait: ({ phase, seconds }) => {
821
+ state.status = phase === "gate" ? `TPM 节流等待 ~${seconds}s` : `限流 429,${seconds}s 后重试`
822
+ render()
823
+ },
787
824
  onTaskUpdate: (items) => {
788
825
  state.tasks = items
789
826
  const done = items.filter((i) => i.status === "done").length
790
- // 留痕带上当前任务标题:回看历史时知道进行到哪一项
827
+ // 留痕带上current任务标题:回看历史时知道进行到哪一项
791
828
  const current = items.find((i) => i.status === "in_progress")
792
829
  pushLine(` [task] ${done}/${items.length}${current ? ` ▶ ${current.title}` : ""}`, C.dim)
793
830
  render()
@@ -810,12 +847,12 @@ export async function startTUI(agent, opts = {}) {
810
847
  } catch (error) {
811
848
  flushStream()
812
849
  if (error.name === "AbortError" || state.controller?.signal.aborted) {
813
- pushLine("[已中止]", C.warn)
850
+ pushLine("[stopped]", C.warn)
814
851
  break
815
852
  }
816
853
  if (error instanceof ContinueError) {
817
854
  pushLabel(`❯ Continue`, ansi.bold + C.warn)
818
- pushLine(`已执行 ${error.turn} 轮(上限 ${error.turn}),要继续吗?`, C.warn)
855
+ pushLine(`Ran ${error.turn} turns (limit ${error.turn}). Continue?`, C.warn)
819
856
  // 暂停询问:复用 permission 机制
820
857
  const willContinue = await new Promise((resolve) => {
821
858
  state.permission = {
@@ -828,11 +865,11 @@ export async function startTUI(agent, opts = {}) {
828
865
  })
829
866
  state.permission = null
830
867
  if (!willContinue) {
831
- pushLine("[已取消继续]", C.warn)
868
+ pushLine("[continue cancelled]", C.warn)
832
869
  break
833
870
  }
834
- pushLine("[继续执行…]", C.tool)
835
- // 重创新 AbortController:旧 signal 一旦 abort 过,resume 会立即失败(防御性,当前路径不可达但耦合紧)
871
+ pushLine("[continuing…]", C.tool)
872
+ // 重创新 AbortController:旧 signal 一旦 abort 过,resume 会立即失败 (防御性,current路径不可达但耦合紧)
836
873
  state.controller = new AbortController()
837
874
  continue
838
875
  }
@@ -843,13 +880,14 @@ export async function startTUI(agent, opts = {}) {
843
880
 
844
881
  clearInterval(ticker)
845
882
  state.processing = false
883
+ state.subTasks = {}
846
884
  state.controller = null
847
885
  state.status = "Ready"
848
- // 全部完成时自动收起 todo 面板(对齐 kimi-code TUI;agent.tasks 本身保留)
886
+ // 全部完成时自动收起 todo 面板 (对齐 kimi-code TUI;agent.tasks 本身保留)
849
887
  if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
850
888
  state.tasks = []
851
889
  }
852
- // 每轮结束后保存会话(崩溃也不丢)
890
+ // 每轮结束后保存会话 (崩溃也不丢)
853
891
  try {
854
892
  saveSession(agent, state.lines)
855
893
  } catch {
@@ -884,14 +922,14 @@ export async function startTUI(agent, opts = {}) {
884
922
  })
885
923
  }
886
924
 
887
- /** 权限请求的关键信息(按工具定制),返回行数组。name 可能带子 agent 前缀("coder/bash"),取基名匹配 */
925
+ /** 权限请求的关键信息 (按工具定制),返回行数组。name 可能带子 agent 前缀 ("coder/bash"),取基名匹配 */
888
926
  function formatPermission(name, args) {
889
- const cap = (s, n = 1000) => (s.length > n ? `${s.slice(0, n)}…(${s.length} 字符)` : s)
927
+ const cap = (s, n = 1000) => (s.length > n ? `${s.slice(0, n)}…(${s.length} chars total)` : s)
890
928
  const base = name.includes("/") ? name.split("/").pop() : name
891
929
  if (base === "bash") return cap(args.command ?? "").split("\n")
892
930
  if (base === "write") {
893
931
  // 批准写文件必须看得到要写什么:路径 + 内容预览
894
- return [`${args.path}(写入 ${(args.content ?? "").length} 字符)`, ...cap(args.content ?? "", 1000).split("\n")]
932
+ return [`${args.path} (write ${(args.content ?? "").length} chars)`, ...cap(args.content ?? "", 1000).split("\n")]
895
933
  }
896
934
  if (base === "edit") {
897
935
  // 简易 diff:- 旧内容 / + 新内容
@@ -906,7 +944,7 @@ export async function startTUI(agent, opts = {}) {
906
944
  // 补丁本身就是可读的 diff,直接预览
907
945
  return cap(args.patch ?? "", 1500).split("\n")
908
946
  }
909
- if (base === "delete") return [`${args.path}${args.force ? "force:跟踪文件也删)" : ""}`]
947
+ if (base === "delete") return [`${args.path}${args.force ? " (force: also delete tracked files)" : ""}`]
910
948
  if (base === "subagent") return cap(args.task ?? "", 500).split("\n")
911
949
  if (base === "memory_put") return [`[${args.type ?? ""}] ${args.title ?? ""}`, ...cap(args.content ?? "", 500).split("\n")]
912
950
  return [cap(summarize(args), 300)]
@@ -914,9 +952,9 @@ export async function startTUI(agent, opts = {}) {
914
952
 
915
953
  function askQuestion(text, options = []) {
916
954
  // 一次只能问一个:question 是只读工具走并行通道,同批第二个直接驳回,
917
- // 否则后到的会覆盖 state.question,先到的 Promise 永远悬挂(agent 死等)
955
+ // 否则后到的会覆盖 state.question,先到的 Promise 永远悬挂 (agent 死等)
918
956
  if (state.question) {
919
- return Promise.resolve("(error: 已有问题在等待回答;请一次只问一个,得到答复后再问下一个)")
957
+ return Promise.resolve("(error: another question is pending; ask one at a time and wait for the answer)")
920
958
  }
921
959
  if (!options.length) {
922
960
  // 自由文本:打开输入态让用户打字,Enter 提交
@@ -938,27 +976,71 @@ export async function startTUI(agent, opts = {}) {
938
976
  })
939
977
  }
940
978
 
941
- // ---------------------------------------------------------- 斜杠命令
979
+ /** Ctrl+V / Alt+V:读取剪贴板图片 → 写入工作目录临时文件 → 输入框插入 read_image 命令 */
980
+ async function pasteClipboardImage(agent) {
981
+ const { execFile } = await import("node:child_process")
982
+ const { mkdir, stat, unlink } = await import("node:fs/promises")
983
+ const { join } = await import("node:path")
984
+
985
+ const run = (cmd, args) => new Promise((resolve, reject) => {
986
+ execFile(cmd, args, { timeout: 10000 }, (err, stdout) => { if (err) reject(err); else resolve(stdout) })
987
+ })
988
+
989
+ const dest = join(agent.cwd, `.thincoder-paste-${Date.now()}.png`)
990
+ const isWin = process.platform === "win32"
991
+ const isMac = process.platform === "darwin"
992
+
993
+ try {
994
+ if (isWin) {
995
+ const psScript = `Add-Type -AssemblyName System.Windows.Forms; if ([System.Windows.Forms.Clipboard]::ContainsImage()) { [System.Windows.Forms.Clipboard]::GetImage().Save('${dest.replace(/\\/g, "\\\\")}', [System.Drawing.Imaging.ImageFormat]::Png); exit 0 } else { exit 1 }`
996
+ await run("powershell", ["-NoProfile", "-Command", psScript])
997
+ } else if (isMac) {
998
+ const script = `try; set f to (POSIX file "${dest}"); set img to the clipboard as «class PNGf»; set fd to open for access f with write permission; write img to fd; close access fd; end try`
999
+ await run("osascript", ["-e", script])
1000
+ } else {
1001
+ await run("bash", ["-c", `xclip -selection clipboard -t image/png -o > "${dest}" 2>/dev/null || { which wl-paste >/dev/null 2>&1 && wl-paste -t image/png > "${dest}" 2>/dev/null; } || exit 1`])
1002
+ }
1003
+ } catch {
1004
+ pushLine("Clipboard does not contain an image, or clipboard access failed", C.dim)
1005
+ try { await unlink(dest) } catch {}
1006
+ return
1007
+ }
1008
+
1009
+ const st = await stat(dest).catch(() => null)
1010
+ if (!st || st.size === 0) {
1011
+ pushLine("Clipboard does not contain an image, or clipboard access failed", C.dim)
1012
+ try { await unlink(dest) } catch {}
1013
+ return
1014
+ }
1015
+
1016
+ const cmd = `read_image ${dest}`
1017
+ state.input.splice(state.cursor, 0, ...[...cmd])
1018
+ state.cursor += cmd.length
1019
+ pushLine(`[image pasted → ${dest}]`, C.tool)
1020
+ render()
1021
+ }
1022
+
1023
+ // ---------------------------------------------------------- 斜杠Commands
942
1024
 
943
1025
  const SLASH_COMMANDS = [
944
- { name: "/plan", group: "Agent", desc: "规划模式(先设计、再实现)" },
945
- { name: "/auto", group: "Agent", desc: "自动授权开关" },
946
- { name: "/model", group: "Agent", desc: "选择模型" },
947
- { name: "/goal", group: "Agent", desc: "设置/查看/取消长期目标" },
948
- { name: "/think", group: "Agent", desc: "思维模式与推理强度" },
949
- { name: "/init", group: "Tools", desc: "生成项目 AGENTS.md 骨架" },
950
- { name: "/skills", group: "Tools", desc: "列出项目技能" },
951
- { name: "/mcp", group: "Tools", desc: "管理 MCP server" },
952
- { name: "/provider", group: "Config", desc: "管理 provider(增/删/配 key" },
953
- { name: "/config", group: "Config", desc: "配置管理(embedding / agent" },
954
- { name: "/reindex", group: "Config", desc: "重建记忆索引" },
955
- { name: "/new", group: "Session", desc: "新会话(旧会话归档到槽位)" },
956
- { name: "/session", group: "Session", desc: "列出/切换归档会话" },
957
- { name: "/clear", group: "Session", desc: "清屏" },
958
- { name: "/distill", group: "Session", desc: "从会话提取知识" },
959
- { name: "/rewind", group: "Session", desc: "回滚到存档点" },
960
- { name: "/exit", group: "Session", desc: "退出" },
961
- { name: "/help", group: "", desc: "此列表" },
1026
+ { name: "/plan", group: "Agent", desc: "toggle plan mode (design first, then implement)" },
1027
+ { name: "/auto", group: "Agent", desc: "toggle auto-approve" },
1028
+ { name: "/model", group: "Agent", desc: "select model" },
1029
+ { name: "/goal", group: "Agent", desc: "set/view/cancel long-term goal" },
1030
+ { name: "/think", group: "Agent", desc: "thinking mode & reasoning effort" },
1031
+ { name: "/init", group: "Tools", desc: "generate project AGENTS.md skeleton" },
1032
+ { name: "/skills", group: "Tools", desc: "list project skills" },
1033
+ { name: "/mcp", group: "Tools", desc: "manage MCP servers" },
1034
+ { name: "/provider", group: "Config", desc: "manage providers (add/remove/set key)" },
1035
+ { name: "/config", group: "Config", desc: "config management (embedding / agent)" },
1036
+ { name: "/reindex", group: "Config", desc: "rebuild memory index" },
1037
+ { name: "/new", group: "Session", desc: "new session (old one archived to slot)" },
1038
+ { name: "/session", group: "Session", desc: "list/switch archived sessions" },
1039
+ { name: "/clear", group: "Session", desc: "clear screen" },
1040
+ { name: "/distill", group: "Session", desc: "extract knowledge from session" },
1041
+ { name: "/rewind", group: "Session", desc: "restore checkpoint" },
1042
+ { name: "/exit", group: "Session", desc: "exit" },
1043
+ { name: "/help", group: "", desc: "this list" },
962
1044
  ]
963
1045
 
964
1046
  async function handleSlash(text) {
@@ -979,49 +1061,48 @@ export async function startTUI(agent, opts = {}) {
979
1061
  state.lines = []
980
1062
  state.streaming = ""
981
1063
  clearSession(agent.cwd)
982
- pushLine("已开始新会话(旧会话已归档到槽位;/session 可查看)", C.dim)
1064
+ pushLine("New session started (old session archived to slot; /session to view)", C.dim)
983
1065
  return
984
1066
  case "/exit":
985
1067
  cleanup()
986
1068
  setTimeout(() => process.exit(0), 100) // 延迟一拍:fetch 后立刻 exit 在 Windows/Node 24 会触发 libuv 断言
987
1069
  return
988
1070
  case "/session": {
989
- const slotNum = Number(rest[0])
990
- if (rest.length > 0 && !isNaN(slotNum)) {
991
- // 切换到指定槽位
992
- const data = switchToSlot(agent.cwd, slotNum)
993
- if (!data) {
994
- pushLine(`槽位 ${slotNum} 不存在`, C.dim)
995
- } else {
996
- applySession(agent, data)
997
- state.lines = data.display.length
998
- ? data.display.map((l) => ({ text: l.text, color: l.color }))
999
- : []
1000
- state.tasks = agent.tasks ?? []
1001
- // 切换过来的会话如果任务全完成,自动收起面板
1002
- if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
1003
- state.tasks = []
1004
- }
1005
- pushLabel(`── 已切换到槽位 ${slotNum}(${data.history.length} 条消息)──`, C.warn)
1006
- render()
1007
- }
1071
+ const slots = listSlots(agent.cwd)
1072
+ if (slots.length === 0) {
1073
+ pushLine("No archived sessions (use /new and old sessions auto-archive to slots)", C.dim)
1008
1074
  } else {
1009
- // 列出所有槽位
1010
- const slots = listSlots(agent.cwd)
1011
- if (slots.length === 0) {
1012
- pushLine("没有归档会话(用 /new 后旧会话会自动归档)", C.dim)
1013
- } else {
1014
- pushLabel(`归档会话(/session <n> 切换):`, ansi.bold + C.tool)
1015
- for (const s of slots) {
1016
- pushLine(` 槽位 ${s.slot} — ${s.date}`, C.text)
1017
- }
1018
- }
1075
+ const entries = [
1076
+ { type: "header", text: "Archived sessions (↑↓ select, Enter switch, Esc cancel)" },
1077
+ ...slots.map((s) => ({ type: "item", text: `Slot ${s.slot} ${s.date}`, slot: s.slot })),
1078
+ ]
1079
+ openPicker({
1080
+ title: "Switch Session",
1081
+ entries,
1082
+ onSelect: (e) => {
1083
+ const data = switchToSlot(agent.cwd, e.slot)
1084
+ if (!data) {
1085
+ pushLine(`Slot ${e.slot} not found`, C.dim)
1086
+ return
1087
+ }
1088
+ applySession(agent, data)
1089
+ state.lines = data.display.length
1090
+ ? data.display.map((l) => ({ text: l.text, color: l.color }))
1091
+ : []
1092
+ state.tasks = agent.tasks ?? []
1093
+ if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
1094
+ state.tasks = []
1095
+ }
1096
+ pushLabel(`── Switched to slot ${e.slot} (${data.history.length} messages) ──`, C.warn)
1097
+ render()
1098
+ },
1099
+ })
1019
1100
  }
1020
1101
  return
1021
1102
  }
1022
1103
  case "/reindex": {
1023
1104
  const { syncDir, codeSync, docSync } = await import("./memory.mjs")
1024
- pushLine("[reindex] 重建索引...", C.tool)
1105
+ pushLine("[reindex] Rebuilding index...", C.tool)
1025
1106
  agent.memory.db.prepare("DELETE FROM files").run()
1026
1107
  agent.memory.db.prepare("DELETE FROM code_chunks").run()
1027
1108
  agent.memory.db.prepare("DELETE FROM doc_chunks").run()
@@ -1037,26 +1118,26 @@ export async function startTUI(agent, opts = {}) {
1037
1118
  pushLine(` team: +${s.added} ~${s.updated} -${s.removed}`, C.dim)
1038
1119
  }
1039
1120
  // 重建代码索引
1040
- pushLine(` [code] 重建代码索引...`, C.tool)
1121
+ pushLine(` [code] Rebuilding code index...`, C.tool)
1041
1122
  const cr = await codeSync(agent.memory, agent.cwd, {
1042
1123
  onProgress: (p) => {
1043
1124
  if (p.phase === "index" && p.current % 20 === 0) {
1044
- pushLine(` 索引中... ${p.current}/${p.total}`, C.dim)
1125
+ pushLine(` Indexing... ${p.current}/${p.total}`, C.dim)
1045
1126
  }
1046
1127
  }
1047
1128
  })
1048
- pushLine(` code: ${cr.total} 文件,+${cr.updated} ~${cr.skipped} -${cr.removed}`, C.dim)
1129
+ pushLine(` code: ${cr.total} files, +${cr.updated} ~${cr.skipped} -${cr.removed}`, C.dim)
1049
1130
  // 重建文档索引
1050
- pushLine(` [doc] 重建文档索引...`, C.tool)
1131
+ pushLine(` [doc] Rebuilding doc index...`, C.tool)
1051
1132
  const dr = await docSync(agent.memory, agent.cwd, {
1052
1133
  onProgress: (p) => {
1053
1134
  if (p.phase === "index" && p.current % 5 === 0) {
1054
- pushLine(` 索引中... ${p.current}/${p.total}`, C.dim)
1135
+ pushLine(` Indexing... ${p.current}/${p.total}`, C.dim)
1055
1136
  }
1056
1137
  }
1057
1138
  })
1058
- pushLine(` doc: ${dr.total} 文件,+${dr.updated} ~${dr.skipped} -${dr.removed}`, C.dim)
1059
- pushLine(`[reindex] 完成,共 ${total} 条目。向量将在下次搜索时惰性生成。`, C.tool)
1139
+ pushLine(` doc: ${dr.total} files, +${dr.updated} ~${dr.skipped} -${dr.removed}`, C.dim)
1140
+ pushLine(`[reindex] Done, ${total} entries total. Vectors will be lazily generated on next search.`, C.tool)
1060
1141
  return
1061
1142
  }
1062
1143
  case "/distill":
@@ -1068,7 +1149,7 @@ export async function startTUI(agent, opts = {}) {
1068
1149
  const { join, basename } = await import("node:path")
1069
1150
  const agPath = join(agent.cwd, "AGENTS.md")
1070
1151
  if (existsSync(agPath)) {
1071
- pushLine(`AGENTS.md 已存在: ${agPath}`, C.warn)
1152
+ pushLine(`AGENTS.md already exists: ${agPath}`, C.warn)
1072
1153
  return
1073
1154
  }
1074
1155
 
@@ -1119,44 +1200,50 @@ export async function startTUI(agent, opts = {}) {
1119
1200
 
1120
1201
  const lines = [`# ${name}`, ""]
1121
1202
  if (lang) {
1122
- lines.push(`## 技术栈`, "", lang, "")
1123
- if (cmds) lines.push(`## 命令`, "", cmds, "")
1203
+ lines.push(`## Tech Stack`, "", lang, "")
1204
+ if (cmds) lines.push(`## Commands`, "", cmds, "")
1124
1205
  }
1125
1206
 
1126
1207
  const template = lines.join("\n")
1127
1208
  await writeFile(agPath, template, "utf8")
1128
1209
  pushLabel(`❯ Init`, ansi.bold + C.tool)
1129
- pushLine(`已生成 AGENTS.md → ${agPath}${lang ? ` (${lang})` : ""}`, C.tool)
1130
- if (lang) pushLine("可继续告诉我项目信息,我来补充约定和结构", C.dim)
1210
+ pushLine(`Generated AGENTS.md → ${agPath}${lang ? ` (${lang})` : ""}`, C.tool)
1211
+ if (lang) pushLine("Tell me more about the project and I will fill in conventions and structure", C.dim)
1131
1212
  return
1132
1213
  }
1133
1214
  case "/rewind": {
1134
1215
  const { listCheckpoints, rewind, isGitRepo } = await import("./checkpoint.mjs")
1135
1216
  if (!isGitRepo(agent.cwd)) {
1136
- pushLine("[rewind] 当前目录不是 git 仓库,无法使用存档点", C.error)
1217
+ pushLine("[rewind] not a git repository, checkpoints unavailable", C.error)
1137
1218
  return
1138
1219
  }
1139
- const id = rest[0]
1140
- if (!id) {
1141
- const cps = await listCheckpoints(agent.cwd)
1142
- pushLabel(`❯ Checkpoints`, ansi.bold + C.tool)
1143
- if (cps.length === 0) {
1144
- pushLine("(暂无存档点——每次提交任务前自动创建)", C.dim)
1145
- }
1146
- for (const cp of cps.slice(0, 10)) {
1147
- pushLine(` ${cp.id} ${new Date(cp.time).toLocaleString()} (+${cp.untracked} 个未跟踪文件)`, C.dim)
1148
- }
1149
- pushLine("回滚: /rewind <id>(恢复前会先存当前状态,回滚可逆)", C.dim)
1220
+ const cps = await listCheckpoints(agent.cwd)
1221
+ if (cps.length === 0) {
1222
+ pushLine("(no checkpoints created automatically before each task)", C.dim)
1150
1223
  return
1151
1224
  }
1152
- try {
1153
- const summary = await rewind(agent.cwd, id)
1154
- pushLabel(`❯ Rewind`, ansi.bold + C.warn)
1155
- pushLine(`已回滚到 ${id}:补丁${summary.patchApplied ? "已应用" : ""},删除新建文件 ${summary.deleted} 个,还原文件 ${summary.restored} 个`, C.tool)
1156
- pushLine("(当前状态已先存为新存档点,可再次 /rewind 回到刚才)", C.dim)
1157
- } catch (error) {
1158
- pushLine(`[rewind] ${error.message}`, C.error)
1159
- }
1225
+ const entries = [
1226
+ { type: "header", text: "Checkpoints (↑↓ select, Enter restore, Esc cancel)" },
1227
+ ...cps.slice(0, 12).map((cp) => ({
1228
+ type: "item",
1229
+ text: `${cp.id} ${new Date(cp.time).toLocaleString()} (+${cp.untracked} untracked files)`,
1230
+ id: cp.id,
1231
+ })),
1232
+ ]
1233
+ openPicker({
1234
+ title: "Restore Checkpoint",
1235
+ entries,
1236
+ onSelect: async (e) => {
1237
+ try {
1238
+ const summary = await rewind(agent.cwd, e.id)
1239
+ pushLabel(`❯ Rewind`, ansi.bold + C.warn)
1240
+ pushLine(`Restored to ${e.id}: patch ${summary.patchApplied ? "applied" : "none"},deleted ${summary.deleted} new files, restored ${summary.restored} 个`, C.tool)
1241
+ pushLine("(current state saved as new checkpoint; /rewind again to go back)", C.dim)
1242
+ } catch (error) {
1243
+ pushLine(`[rewind] ${error.message}`, C.error)
1244
+ }
1245
+ },
1246
+ })
1160
1247
  return
1161
1248
  }
1162
1249
  case "/plan": {
@@ -1170,44 +1257,53 @@ export async function startTUI(agent, opts = {}) {
1170
1257
  pushLabel(`❯ Plan`, ansi.bold + (agent.planMode ? C.tool : C.dim))
1171
1258
  pushLine(
1172
1259
  agent.planMode
1173
- ? `规划模式已开启:只读工具受限,先设计方案再实现。再次 /plan 退出。`
1174
- : `规划模式已关闭:可以编辑文件和执行命令了。`,
1260
+ ? `Plan mode ON: read-only tools only. Design first, then implement. /plan again to exit.`
1261
+ : `Plan mode OFF: you may now edit files and run commands.`,
1175
1262
  agent.planMode ? C.tool : C.dim,
1176
1263
  )
1177
1264
  return
1178
1265
  }
1179
1266
  case "/goal": {
1180
- const sub = rest[0]
1181
- if (sub === "set") {
1182
- const text = rest.slice(1).join(" ")
1183
- if (!text) { pushLine("用法: /goal set <目标描述>(; 分隔完成条件,必须是可机器检查的验证手段)", C.error); return }
1184
- const semi = text.indexOf(";") >= 0 ? ";" : text.indexOf(";") >= 0 ? ";" : null
1185
- const objective = semi ? text.slice(0, semi).trim() : text.trim()
1186
- const criteria = semi ? text.slice(semi + 1).trim() : ""
1187
- agent.goal = { objective, criteria, setAt: Date.now(), status: "active", turnsUsed: 0, _blockTally: null }
1188
- pushLabel(`❯ Goal`, ansi.bold + C.warn)
1189
- pushLine(`目标已设置: ${objective}`, C.tool)
1190
- if (criteria) pushLine(` 完成条件: ${criteria}`, C.dim)
1191
- else pushLine(` ⚠ 未完成条件——agent 用 goal set 设立时会被要求补上可验证的完成条件`, C.warn)
1192
- return
1193
- }
1194
- if (sub === "cancel") {
1195
- agent.goal = null
1196
- pushLabel(`❯ Goal`, ansi.bold + C.dim)
1197
- pushLine(`目标已取消。`, C.dim)
1198
- return
1199
- }
1267
+ const entries = [
1268
+ { type: "header", text: agent.goal ? `Current goal: ${agent.goal.objective.slice(0, 60)}` : "Actions" },
1269
+ { type: "item", text: "Set new goal", action: "set" },
1270
+ ]
1200
1271
  if (agent.goal) {
1201
- const statusText = { active: "进行中", complete: "已完成", blocked: "已阻塞" }[agent.goal.status] ?? agent.goal.status
1202
- pushLabel(`❯ Goal`, ansi.bold + C.warn)
1203
- pushLine(`目标: ${agent.goal.objective}`, C.tool)
1204
- if (agent.goal.criteria) pushLine(` 完成条件: ${agent.goal.criteria}`, C.dim)
1205
- pushLine(` 状态: ${statusText ?? "进行中"} │ 已用轮数: ${agent.goal.turnsUsed ?? 0} │ 设置于: ${new Date(agent.goal.setAt).toLocaleString()}`, C.dim)
1206
- pushLine("操作: /goal set <描述> 覆盖 | /goal cancel 取消", C.dim)
1207
- } else {
1208
- pushLabel(`❯ Goal`, ansi.bold + C.dim)
1209
- pushLine("(无活跃目标——/goal set <描述> 设置)", C.dim)
1210
- }
1272
+ entries.push({ type: "item", text: "Cancel goal", action: "cancel" })
1273
+ entries.push({ type: "item", text: "View details", action: "view" })
1274
+ }
1275
+ openPicker({
1276
+ title: "Goal",
1277
+ entries,
1278
+ onSelect: (e) => {
1279
+ if (e.action === "view") {
1280
+ const statusText = { active: "active", complete: "completed", blocked: "blocked" }[agent.goal.status] ?? agent.goal.status
1281
+ pushLabel(`❯ Goal`, ansi.bold + C.warn)
1282
+ pushLine(`Goal: ${agent.goal.objective}`, C.tool)
1283
+ if (agent.goal.criteria) pushLine(` Criteria: ${agent.goal.criteria}`, C.dim)
1284
+ pushLine(` Status: ${statusText} │ Turns used: ${agent.goal.turnsUsed ?? 0} │ Set at: ${new Date(agent.goal.setAt).toLocaleString()}`, C.dim)
1285
+ return
1286
+ }
1287
+ if (e.action === "cancel") {
1288
+ agent.goal = null
1289
+ pushLabel(`❯ Goal`, ansi.bold + C.dim)
1290
+ pushLine(`Goal cancelled.`, C.dim)
1291
+ return
1292
+ }
1293
+ // set — 需要输入目标文本
1294
+ askQuestion("Enter goal description (; separates criteria)").then((text) => {
1295
+ if (!text) return
1296
+ const semi = text.indexOf(";") >= 0 ? ";" : text.indexOf(";") >= 0 ? ";" : null
1297
+ const objective = semi ? text.slice(0, semi).trim() : text.trim()
1298
+ const criteria = semi ? text.slice(semi + 1).trim() : ""
1299
+ agent.goal = { objective, criteria, setAt: Date.now(), status: "active", turnsUsed: 0, _blockTally: null }
1300
+ pushLabel(`❯ Goal`, ansi.bold + C.warn)
1301
+ pushLine(`Goal set: ${objective}`, C.tool)
1302
+ if (criteria) pushLine(` Criteria: ${criteria}`, C.dim)
1303
+ else pushLine(` ⚠ No criteria — agent will be asked to provide verifiable criteria when using goal set`, C.warn)
1304
+ })
1305
+ },
1306
+ })
1211
1307
  return
1212
1308
  }
1213
1309
  case "/skills": {
@@ -1215,7 +1311,7 @@ export async function startTUI(agent, opts = {}) {
1215
1311
  const skills = await loadSkills(agent.cwd)
1216
1312
  pushLabel(`❯ Skills`, ansi.bold + C.tool)
1217
1313
  if (skills.length === 0) {
1218
- pushLine("(无项目技能——在 .thincoder/skills/ 下创建 .md 文件即可添加)", C.dim)
1314
+ pushLine(" (none项目技能——在 .thincoder/skills/ 下创建 .md 文件即可添加)", C.dim)
1219
1315
  }
1220
1316
  for (const s of skills) {
1221
1317
  pushLine(` ${s.name}: ${s.description.slice(0, 100)}`, C.dim)
@@ -1224,97 +1320,111 @@ export async function startTUI(agent, opts = {}) {
1224
1320
  return
1225
1321
  }
1226
1322
  case "/mcp": {
1227
- const sub = rest[0]
1228
- // ---- /mcp list — 列出配置的 servers + 连接状态 ----
1229
- if (!sub || sub === "list") {
1230
- const servers = agent.config?.mcp?.servers ?? []
1231
- pushLabel(`❯ MCP Servers`, ansi.bold + C.tool)
1232
- if (servers.length === 0) {
1233
- pushLine("(无 MCP server——使用 /mcp add <name> <url|command> 添加)", C.dim)
1234
- }
1235
- for (const srv of servers) {
1236
- const connected = agent.tools.some((t) => t._mcpName === srv.name)
1237
- const mark = connected ? "●" : "○"
1238
- const color = connected ? C.tool : C.dim
1239
- const toolCount = agent.tools.filter((t) => t._mcpName === srv.name).length
1240
- const desc = srv.wsUrl ? srv.wsUrl : srv.url ? srv.url : `${srv.command} ${(srv.args ?? []).join(" ")}`
1241
- pushLine(` ${mark} ${srv.name}: ${desc} (${toolCount} tools)`, color)
1242
- }
1243
- pushLabel(`❯ 操作`, ansi.bold + C.tool)
1244
- pushLine("/mcp add <name> <url|command> [args|headers...]", C.dim)
1245
- pushLine(" URL 自动识别: https://… → HTTP, ws://… → WebSocket, 其他 → stdio 命令", C.dim)
1246
- pushLine(" 例: /mcp add myapi https://api.example.com/mcp Authorization=\"Bearer x\"", C.dim)
1247
- pushLine(" 例: /mcp add github npx -y @modelcontextprotocol/server-github", C.dim)
1248
- pushLine(`/mcp remove <name> 断开并移除`, C.dim)
1249
- pushLine(`/mcp connect <name> 重连已配置的 server`, C.dim)
1250
- return
1251
- }
1252
- // ---- /mcp add <name> <url|command> [args|headers...] (统一入口,自动识别传输类型) ----
1253
- // url / ws 子命令作为别名保留(兼容旧配置)
1254
- if (sub === "add" || sub === "url" || sub === "ws") {
1255
- const args = rest.slice(1)
1256
- if (args.length < 2) {
1257
- pushLine("用法: /mcp add <name> <url|command> [args|headers...]", C.error)
1258
- pushLine(" URL 自动识别: https://… → HTTP, ws://… → WebSocket, 其他 → stdio 命令", C.dim)
1259
- return
1260
- }
1261
- const name = args[0]
1262
- const second = args[1]
1263
- const extras = args.slice(2)
1264
- const existing = (agent.config?.mcp?.servers ?? []).find((s) => s.name === name)
1265
- if (existing) { pushLine(`[mcp] "${name}" 已存在,用 /mcp remove ${name} 先移除`, C.error); return }
1266
-
1267
- const isWS = /^wss?:\/\//.test(second)
1268
- const isHTTP = /^https?:\/\//.test(second)
1269
- let srv
1270
- if (isWS || sub === "ws") {
1271
- const headers = parseHeaders(extras)
1272
- srv = { name, wsUrl: second, headers: Object.keys(headers).length > 0 ? headers : undefined }
1273
- } else if (isHTTP || sub === "url") {
1274
- const headers = parseHeaders(extras)
1275
- srv = { name, url: second, headers: Object.keys(headers).length > 0 ? headers : undefined }
1276
- } else {
1277
- srv = { name, command: second, args: extras.length > 0 ? extras : undefined }
1278
- }
1279
- await addAndConnect(srv)
1280
- return
1281
- }
1282
- // ---- /mcp remove <name> ----
1283
- if (sub === "remove") {
1284
- const name = rest[1]
1285
- if (!name) { pushLine("用法: /mcp remove <name>", C.error); return }
1286
- const { removeMcpTools } = await import("./mcp.mjs")
1287
- removeMcpTools(agent, name)
1288
- await persistRaw((raw) => { raw.mcp ??= { servers: [] }; raw.mcp.servers = raw.mcp.servers.filter((s) => s.name !== name) })
1289
- if (agent.config?.mcp?.servers) agent.config.mcp.servers = agent.config.mcp.servers.filter((s) => s.name !== name)
1290
- pushLabel(`❯ MCP`, ansi.bold + C.tool)
1291
- pushLine(`${name} 已断开并从配置移除。`, C.tool)
1292
- return
1293
- }
1294
- // ---- /mcp connect <name> — 重连 ----
1295
- if (sub === "connect") {
1296
- const name = rest[1]
1297
- if (!name) { pushLine("用法: /mcp connect <name>", C.error); return }
1298
- const srv = (agent.config?.mcp?.servers ?? []).find((s) => s.name === name)
1299
- if (!srv) { pushLine(`[mcp] "${name}" 未在配置中找到(先用 /mcp add 添加)`, C.error); return }
1300
- const { removeMcpTools, connectMcpServer } = await import("./mcp.mjs")
1301
- removeMcpTools(agent, name)
1302
- try {
1303
- pushLine(`[mcp] 重连 ${name}...`, C.dim)
1304
- const tools = await connectMcpServer(srv)
1305
- agent.tools.push(...tools)
1306
- pushLabel(`❯ MCP`, ansi.bold + C.tool)
1307
- pushLine(`${name} 已重连,${tools.length} 个工具可用。`, C.tool)
1308
- } catch (error) {
1309
- pushLine(`[mcp] ${name}: ${error.message}`, C.error)
1310
- }
1311
- return
1323
+ const servers = agent.config?.mcp?.servers ?? []
1324
+ const entries = [
1325
+ { type: "header", text: `${servers.length} MCP servers configured` },
1326
+ { type: "item", text: "View list", action: "list" },
1327
+ { type: "item", text: "Add server", action: "add" },
1328
+ ]
1329
+ if (servers.length > 0) {
1330
+ entries.push(
1331
+ { type: "item", text: "Remove server", action: "remove" },
1332
+ { type: "item", text: "Reconnect server", action: "connect" },
1333
+ )
1312
1334
  }
1313
- pushLine(`未知子命令: ${sub}(/mcp list | add | remove | connect)`, C.error)
1335
+ openPicker({
1336
+ title: "MCP",
1337
+ entries,
1338
+ onSelect: async (e) => {
1339
+ if (e.action === "list") {
1340
+ pushLabel(`❯ MCP Servers`, ansi.bold + C.tool)
1341
+ if (servers.length === 0) {
1342
+ pushLine(" (none MCP server)", C.dim)
1343
+ }
1344
+ for (const srv of servers) {
1345
+ const connected = agent.tools.some((t) => t._mcpName === srv.name)
1346
+ const mark = connected ? "●" : "○"
1347
+ const color = connected ? C.tool : C.dim
1348
+ const toolCount = agent.tools.filter((t) => t._mcpName === srv.name).length
1349
+ const desc = srv.wsUrl ? srv.wsUrl : srv.url ? srv.url : `${srv.command} ${(srv.args ?? []).join(" ")}`
1350
+ pushLine(` ${mark} ${srv.name}: ${desc} (${toolCount} tools)`, color)
1351
+ }
1352
+ return
1353
+ }
1354
+ if (e.action === "remove") {
1355
+ const removeEntries = [
1356
+ { type: "header", text: "Select server to remove" },
1357
+ ...servers.map((s) => ({ type: "item", text: s.name, name: s.name })),
1358
+ ]
1359
+ openPicker({
1360
+ title: "Remove MCP",
1361
+ entries: removeEntries,
1362
+ onSelect: async (se) => {
1363
+ const { removeMcpTools } = await import("./mcp.mjs")
1364
+ removeMcpTools(agent, se.name)
1365
+ await persistRaw((raw) => { raw.mcp ??= { servers: [] }; raw.mcp.servers = raw.mcp.servers.filter((s) => s.name !== se.name) })
1366
+ if (agent.config?.mcp?.servers) agent.config.mcp.servers = agent.config.mcp.servers.filter((s) => s.name !== se.name)
1367
+ pushLabel(`❯ MCP`, ansi.bold + C.tool)
1368
+ pushLine(`${se.name} disconnected and removed from config.`, C.tool)
1369
+ },
1370
+ })
1371
+ return
1372
+ }
1373
+ if (e.action === "connect") {
1374
+ const connEntries = [
1375
+ { type: "header", text: "Select server to reconnect" },
1376
+ ...servers.map((s) => ({ type: "item", text: s.name, name: s.name })),
1377
+ ]
1378
+ openPicker({
1379
+ title: "Reconnect MCP",
1380
+ entries: connEntries,
1381
+ onSelect: async (se) => {
1382
+ const srv = servers.find((s) => s.name === se.name)
1383
+ if (!srv) return
1384
+ const { removeMcpTools, connectMcpServer } = await import("./mcp.mjs")
1385
+ removeMcpTools(agent, se.name)
1386
+ try {
1387
+ pushLine(`[mcp] Reconnecting ${se.name}...`, C.dim)
1388
+ const tools = await connectMcpServer(srv)
1389
+ agent.tools.push(...tools)
1390
+ pushLabel(`❯ MCP`, ansi.bold + C.tool)
1391
+ pushLine(`${se.name} reconnected, ${tools.length} tools available.`, C.tool)
1392
+ } catch (error) {
1393
+ pushLine(`[mcp] ${se.name}: ${error.message}`, C.error)
1394
+ }
1395
+ },
1396
+ })
1397
+ return
1398
+ }
1399
+ if (e.action === "add") {
1400
+ askQuestion("输入: <名称> <URL|Commands> [参数...]\nURL 自动识别: https://… → HTTP, ws://… → WebSocket, 其他 → stdio Commands").then(async (text) => {
1401
+ if (!text) return
1402
+ const parts = text.split(/\s+/)
1403
+ if (parts.length < 2) { pushLine("用法: <名称> <URL|Commands> [参数...]", C.error); return }
1404
+ const [name, second, ...extras] = parts
1405
+ const existing = (agent.config?.mcp?.servers ?? []).find((s) => s.name === name)
1406
+ if (existing) { pushLine(`[mcp] "${name}" already exists`, C.error); return }
1407
+ const isWS = /^wss?:\/\//.test(second)
1408
+ const isHTTP = /^https?:\/\//.test(second)
1409
+ let srv
1410
+ if (isWS) {
1411
+ const headers = parseHeaders(extras)
1412
+ srv = { name, wsUrl: second, headers: Object.keys(headers).length > 0 ? headers : undefined }
1413
+ } else if (isHTTP) {
1414
+ const headers = parseHeaders(extras)
1415
+ srv = { name, url: second, headers: Object.keys(headers).length > 0 ? headers : undefined }
1416
+ } else {
1417
+ srv = { name, command: second, args: extras.length > 0 ? extras : undefined }
1418
+ }
1419
+ await addAndConnect(srv)
1420
+ })
1421
+ }
1422
+ },
1423
+ })
1314
1424
  return
1315
1425
  }
1316
1426
 
1317
- // ---- header 解析(/mcp add 共享)----
1427
+ // ---- header 解析 (/mcp add 共享)----
1318
1428
  function parseHeaders(pairs) {
1319
1429
  const headers = {}
1320
1430
  for (const pair of pairs) {
@@ -1324,7 +1434,7 @@ export async function startTUI(agent, opts = {}) {
1324
1434
  return headers
1325
1435
  }
1326
1436
 
1327
- // ---- /mcp 共享 helper: 保存配置 + 连接 ----
1437
+ // ---- /mcp 共享 helper: 保存Config + Connecting ----
1328
1438
  async function addAndConnect(srv) {
1329
1439
  await persistRaw((raw) => {
1330
1440
  raw.mcp ??= { servers: [] }
@@ -1338,16 +1448,16 @@ export async function startTUI(agent, opts = {}) {
1338
1448
  agent.config.mcp ??= { servers: [] }
1339
1449
  agent.config.mcp.servers.push(srv)
1340
1450
  try {
1341
- pushLine(`[mcp] 连接 ${srv.name}...`, C.dim)
1451
+ pushLine(`[mcp] Connecting ${srv.name}...`, C.dim)
1342
1452
  const { connectMcpServer } = await import("./mcp.mjs")
1343
1453
  const tools = await connectMcpServer(srv)
1344
1454
  agent.tools.push(...tools)
1345
1455
  pushLabel(`❯ MCP`, ansi.bold + C.tool)
1346
1456
  const desc = srv.wsUrl ? srv.wsUrl : srv.url ? srv.url : `${srv.command} ${(srv.args ?? []).join(" ")}`
1347
- pushLine(`${srv.name} (${desc}) 已连接,${tools.length} 个工具:`, C.tool)
1457
+ pushLine(`${srv.name} (${desc}) connected, ${tools.length} tools:`, C.tool)
1348
1458
  for (const t of tools) pushLine(` ${t.name}: ${t.description.slice(0, 100)}`, C.dim)
1349
1459
  } catch (error) {
1350
- pushLine(`[mcp] ${srv.name}: ${error.message}(配置已保存,重启后重试)`, C.error)
1460
+ pushLine(`[mcp] ${srv.name}: ${error.message} (config saved, retry after restart)`, C.error)
1351
1461
  }
1352
1462
  }
1353
1463
  case "/auto":
@@ -1361,241 +1471,241 @@ export async function startTUI(agent, opts = {}) {
1361
1471
  pushLabel(`❯ Auto`, ansi.bold + (agent.autoApprove ? C.warn : C.tool))
1362
1472
  pushLine(
1363
1473
  agent.autoApprove
1364
- ? `AUTO 已开启:所有工具调用(含写文件/bash/子 agent)不再询问,自动执行。长任务专用,/auto 关闭。`
1365
- : `AUTO 已关闭:有副作用的工具调用恢复逐个确认。`,
1474
+ ? `AUTO ON: all tool calls (write/bash/subagent) auto-approved. For long tasks. /auto to disable.`
1475
+ : `AUTO OFF: destructive tool calls require per-use approval again.`,
1366
1476
  agent.autoApprove ? C.warn : C.dim,
1367
1477
  )
1368
1478
  return
1369
1479
  case "/think": {
1370
- const sub = rest[0]
1371
1480
  const cur = agent.provider
1372
1481
  const thinkingEnabled = cur.thinking?.type === "enabled" || cur.thinking?.type === undefined
1373
- // ---- /think(无参): 查看状态 ----
1374
- if (!sub) {
1375
- pushLabel(`❯ Think`, ansi.bold + C.tool)
1376
- pushLine(`思维模式: ${thinkingEnabled ? "🟢 开启" : "⚫ 关闭"}`, C.dim)
1377
- pushLine(`推理强度: ${cur.reasoningEffort ?? "(未设置)"}`, C.dim)
1378
- pushLine(`切换: /think on | off | effort high | effort max`, C.dim)
1379
- return
1380
- }
1381
- // ---- /think on / off ----
1382
- if (sub === "on" || sub === "off") {
1383
- const enable = sub === "on"
1384
- // 仅用 reasoning_effort 的模型(K3):不碰 thinking 字段,只设/删 reasoningEffort
1385
- const { specForModel } = await import("./config.mjs")
1386
- const spec = specForModel(cur.model)
1387
- if (spec.thinkApi === "effort") {
1388
- // 仅用 reasoning_effort 的模型(K3 / Qwen):不碰 thinking 字段
1389
- if (!enable) delete cur.reasoningEffort
1390
- else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
1391
- if (!enable) await syncProviderField("reasoningEffort", undefined)
1392
- else await syncProviderField("reasoningEffort", cur.reasoningEffort)
1393
- } else {
1394
- cur.thinking = enable ? { type: "enabled" } : { type: "disabled" }
1395
- if (!enable) delete cur.reasoningEffort
1396
- else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
1397
- await syncProviderField("thinking", cur.thinking)
1398
- if (!enable) await syncProviderField("reasoningEffort", undefined)
1399
- else await syncProviderField("reasoningEffort", cur.reasoningEffort)
1400
- }
1401
- pushLabel(`❯ Think`, ansi.bold + C.tool)
1402
- pushLine(`思维模式已${enable ? "开启" : "关闭"}`, C.tool)
1403
- if (enable) pushLine(`推理强度: ${cur.reasoningEffort}`, C.dim)
1404
- return
1405
- }
1406
- // ---- /think effort <level> ----
1407
- if (sub === "effort") {
1408
- const level = rest[1]
1409
- if (!level || !["low", "high", "max"].includes(level)) {
1410
- pushLine("用法: /think effort low | high | max", C.error)
1411
- return
1412
- }
1413
- cur.reasoningEffort = level
1414
- await syncProviderField("reasoningEffort", level)
1415
- pushLabel(`❯ Think`, ansi.bold + C.tool)
1416
- pushLine(`推理强度已设为 ${level}`, C.tool)
1417
- return
1418
- }
1419
- pushLine(`未知参数: ${sub}(可用: on / off / effort / effort high|max)`, C.error)
1482
+ const { specForModel } = await import("./config.mjs")
1483
+ const spec = specForModel(cur.model)
1484
+ const isEffortOnly = spec.thinkApi === "effort"
1485
+
1486
+ const entries = [
1487
+ { type: "header", text: "Thinking mode" },
1488
+ { type: "item", text: `On${thinkingEnabled ? " ← current" : ""}`, action: "on" },
1489
+ { type: "item", text: `Off${!thinkingEnabled ? " ← current" : ""}`, action: "off" },
1490
+ { type: "header", text: "Reasoning effort" },
1491
+ ...["low", "high", "max"].map((l) => ({
1492
+ type: "item",
1493
+ text: `${l}${cur.reasoningEffort === l ? " ← current" : ""}`,
1494
+ action: "effort",
1495
+ level: l,
1496
+ })),
1497
+ ]
1498
+ openPicker({
1499
+ title: "Thinking mode",
1500
+ entries,
1501
+ defaultIndex: thinkingEnabled ? 0 : 1,
1502
+ onSelect: async (e) => {
1503
+ if (e.action === "effort") {
1504
+ cur.reasoningEffort = e.level
1505
+ await syncProviderField("reasoningEffort", e.level)
1506
+ pushLabel(`❯ Think`, ansi.bold + C.tool)
1507
+ pushLine(`Reasoning effort set to ${e.level}`, C.tool)
1508
+ } else {
1509
+ const enable = e.action === "on"
1510
+ if (isEffortOnly) {
1511
+ if (!enable) delete cur.reasoningEffort
1512
+ else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
1513
+ if (!enable) await syncProviderField("reasoningEffort", undefined)
1514
+ else await syncProviderField("reasoningEffort", cur.reasoningEffort)
1515
+ } else {
1516
+ cur.thinking = enable ? { type: "enabled" } : { type: "disabled" }
1517
+ if (!enable) delete cur.reasoningEffort
1518
+ else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
1519
+ await syncProviderField("thinking", cur.thinking)
1520
+ if (!enable) await syncProviderField("reasoningEffort", undefined)
1521
+ else await syncProviderField("reasoningEffort", cur.reasoningEffort)
1522
+ }
1523
+ pushLabel(`❯ Think`, ansi.bold + C.tool)
1524
+ pushLine(`Thinking mode已${enable ? "On" : "Off"}`, C.tool)
1525
+ if (enable) pushLine(`Reasoning effort: ${cur.reasoningEffort}`, C.dim)
1526
+ }
1527
+ },
1528
+ })
1420
1529
  return
1421
1530
  }
1422
1531
  case "/model": {
1423
- const arg = rest[0]
1424
- if (!arg) {
1425
- // 打开交互选择器:全部 provider 的全部模型,方向键选择
1426
- pushLabel(`❯ Model`, ansi.bold + C.tool)
1427
- pushLine(`/model <名称> 直接切换 provider 或模型(如 /model deepseek-v4-pro)`, C.dim)
1428
- pushLine(`/provider 管理 provider(添加/删除/配 key)`, C.dim)
1429
- openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
1430
- return
1431
- }
1432
- if (arg === "add" || arg === "--add") {
1433
- pushLine(`添加 provider 已移到 /provider add(/provider 查看全部管理命令)`, C.warn)
1434
- return
1435
- }
1436
- // 一个参数两种含义:先按 provider 名匹配,匹配不到就当模型名改当前 provider
1437
- const { resolveCompactThreshold } = await import("./config.mjs")
1438
- const p = agent.providers.find((pp) => pp.name === arg)
1439
- const newModel = p ? p.model : arg
1440
- let thresholdNote = ""
1441
- if (agent.config?.agent?.compactThresholdAuto) {
1442
- const { value } = resolveCompactThreshold(null, newModel)
1443
- agent.config.agent.compactThreshold = value
1444
- thresholdNote = `,压缩阈值随模型调整为 ${value}`
1445
- }
1446
- if (p) {
1447
- agent.activeProvider = arg
1448
- agent.provider = { ...p }
1449
- // key 的环境变量兜底和 loadConfig 保持一致(提供商专用变量只对同名生效)
1450
- if (!agent.provider.apiKey) {
1451
- const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[arg]
1452
- if (envKey && process.env[envKey]) agent.provider.apiKey = process.env[envKey]
1453
- }
1454
- if (!agent.provider.apiKey) agent.provider.apiKey = process.env.THINCODER_API_KEY
1455
- await persistRaw((raw) => { raw.activeProvider = arg })
1456
- agent.config.activeProvider = arg
1457
- pushLabel(`❯ Model`, ansi.bold + C.tool)
1458
- pushLine(`已切换到 ${arg} / ${p.model}${thresholdNote}(已持久化)`, C.tool)
1459
- if (!agent.provider.apiKey) pushLine(`该 provider 还没配 key: /provider key <apikey>`, C.warn)
1460
- } else {
1461
- const target = agent.providers.find((pp) => pp.name === agent.activeProvider) ?? agent.providers[0]
1462
- if (target) target.model = arg
1463
- agent.provider.model = arg
1464
- await persistRaw((raw) => { raw.providers = agent.providers })
1465
- pushLabel(`❯ Model`, ansi.bold + C.tool)
1466
- pushLine(`已将 ${target?.name ?? agent.activeProvider} 的模型改为 ${arg}${thresholdNote}(已持久化)`, C.tool)
1467
- }
1532
+ openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
1468
1533
  return
1469
1534
  }
1470
1535
  case "/provider": {
1471
- const sub = rest[0]
1472
- // ---- /provider add <名称> <baseURL> <模型>,或 /provider add <预设> ----
1473
- if (sub === "add") {
1474
- const name = rest[1]
1475
- if (!name) {
1476
- pushLine(`用法: /provider add <名称> <baseURL> <模型>,或 /provider add <预设>(${Object.keys(PRESETS).join(", ")})`, C.error)
1477
- return
1478
- }
1479
- if (agent.providers.some((p) => p.name === name)) {
1480
- pushLine(`"${name}" 已存在;要重建可先 /provider remove ${name}`, C.warn)
1481
- return
1482
- }
1483
- const preset = PRESETS[name]
1484
- const baseURL = (rest[2] ?? preset?.baseURL)?.replace(/\/+$/, "")
1485
- const model = rest[3] ?? preset?.model
1486
- if (!baseURL || !model) {
1487
- pushLine(`缺少参数: /provider add ${name} <baseURL> <模型>`, C.error)
1488
- if (!preset) pushLine(`("${name}" 不是预设;预设: ${Object.keys(PRESETS).join(", ")})`, C.dim)
1489
- return
1490
- }
1491
- if (!/^https?:\/\//.test(baseURL)) { pushLine(`baseURL 应以 http(s):// 开头`, C.error); return }
1492
- agent.providers.push({ name, baseURL, model, ...(preset?.desc ? { desc: preset.desc } : {}) })
1493
- await persistRaw((raw) => { raw.providers = agent.providers })
1494
- pushLabel(`❯ Provider`, ansi.bold + C.tool)
1495
- pushLine(`已添加 ${name}(${baseURL} / ${model})`, C.tool)
1496
- pushLine(`下一步: /provider key ${name} <apikey> 配 key,/model ${name} 切换`, C.dim)
1497
- return
1498
- }
1499
- // ---- /provider remove <名称> ----
1500
- if (sub === "remove" || sub === "rm") {
1501
- const name = rest[1]
1502
- if (!name) { pushLine("用法: /provider remove <名称>", C.error); return }
1503
- const at = agent.providers.findIndex((p) => p.name === name)
1504
- if (at < 0) { pushLine(`未找到 provider "${name}"`, C.error); return }
1505
- if (name === agent.activeProvider) { pushLine(`"${name}" 正在使用中,先 /model 切换到别的 provider 再删`, C.warn); return }
1506
- agent.providers.splice(at, 1)
1507
- await persistRaw((raw) => { raw.providers = agent.providers })
1508
- pushLabel(`❯ Provider`, ansi.bold + C.tool)
1509
- pushLine(`已删除 ${name}`, C.tool)
1510
- return
1511
- }
1512
- // ---- /provider key [名称] <apikey> ----
1513
- if (sub === "key") {
1514
- let name = agent.activeProvider
1515
- let keyParts = rest.slice(1)
1516
- if (rest[1] && agent.providers.some((p) => p.name === rest[1])) {
1517
- name = rest[1]
1518
- keyParts = rest.slice(2)
1519
- }
1520
- const key = keyParts.join(" ")
1521
- if (!key) { pushLine("用法: /provider key [名称] <apikey>(不填名称配当前 provider)", C.error); return }
1522
- await setProviderKey(name, key)
1523
- return
1524
- }
1525
- if (sub) { pushLine(`未知: ${sub}(/provider add | remove | key)`, C.error); return }
1526
- // ---- /provider(无参): 列表 ----
1527
- pushLabel(`❯ Providers (${agent.providers.length})`, ansi.bold + C.tool)
1528
- for (const p of agent.providers) {
1529
- const active = p.name === agent.activeProvider
1530
- pushLine(
1531
- `${active ? " ▸" : " "} ${p.name.padEnd(12)} ${p.model.padEnd(20)} ${p.baseURL}${p.apiKey ? " ●key" : " ○无key"}${active ? " ← 当前" : ""}`,
1532
- active ? C.tool : C.dim,
1536
+ const entries = [
1537
+ { type: "header", text: `${agent.providers.length} providers` },
1538
+ { type: "item", text: "View list", action: "list" },
1539
+ { type: "item", text: "Add provider", action: "add" },
1540
+ ]
1541
+ if (agent.providers.length > 0) {
1542
+ entries.push(
1543
+ { type: "item", text: "Remove provider", action: "remove" },
1533
1544
  )
1534
1545
  }
1535
- pushLabel(`❯ 操作`, ansi.bold + C.tool)
1536
- pushLine(`/provider add <名称|预设> <url> <模型> 添加(预设: ${Object.keys(PRESETS).join(" ")})`, C.dim)
1537
- pushLine(`/provider remove <名称> 删除`, C.dim)
1538
- if (!agent.provider.apiKey) pushLine(" /provider key <apikey> ← 当前 provider 还没配 key", C.warn)
1539
- else pushLine("/provider key [名称] <apikey> 设置/更换 key", C.dim)
1546
+ if (!agent.provider.apiKey) {
1547
+ entries.push({ type: "item", text: "Set API Key", action: "key" })
1548
+ } else {
1549
+ entries.push({ type: "item", text: "Change API Key", action: "key" })
1550
+ }
1551
+ openPicker({
1552
+ title: "Providers",
1553
+ entries,
1554
+ onSelect: async (e) => {
1555
+ if (e.action === "list") {
1556
+ pushLabel(`❯ Providers (${agent.providers.length})`, ansi.bold + C.tool)
1557
+ for (const p of agent.providers) {
1558
+ const active = p.name === agent.activeProvider
1559
+ pushLine(
1560
+ `${active ? " ▸" : " "} ${p.name.padEnd(12)} ${p.model.padEnd(20)} ${p.baseURL}${p.apiKey ? " ●key" : " ○nonekey"}${active ? " ← current" : ""}`,
1561
+ active ? C.tool : C.dim,
1562
+ )
1563
+ }
1564
+ return
1565
+ }
1566
+ if (e.action === "remove") {
1567
+ const candidates = agent.providers.filter((p) => p.name !== agent.activeProvider)
1568
+ if (candidates.length === 0) {
1569
+ pushLine("Cannot remove current provider (switch to another with /model first)", C.warn)
1570
+ return
1571
+ }
1572
+ const removeEntries = [
1573
+ { type: "header", text: "选择要移除的 provider (current使用的不可移除)" },
1574
+ ...candidates.map((p) => ({ type: "item", text: `${p.name} (${p.model})`, name: p.name })),
1575
+ ]
1576
+ openPicker({
1577
+ title: "Remove Provider",
1578
+ entries: removeEntries,
1579
+ onSelect: async (se) => {
1580
+ const at = agent.providers.findIndex((p) => p.name === se.name)
1581
+ agent.providers.splice(at, 1)
1582
+ await persistRaw((raw) => { raw.providers = agent.providers })
1583
+ pushLabel(`❯ Provider`, ansi.bold + C.tool)
1584
+ pushLine(`Removed ${se.name}`, C.tool)
1585
+ },
1586
+ })
1587
+ return
1588
+ }
1589
+ if (e.action === "add") {
1590
+ // Add needs text input: name baseURL model
1591
+ askQuestion(
1592
+ `输入: <名称> <baseURL> <model>\n预设可用: ${Object.keys(PRESETS).join(", ")}\nor just a preset name (e.g. deepseek) for auto-fill`,
1593
+ ).then(async (text) => {
1594
+ if (!text) return
1595
+ const parts = text.split(/\s+/)
1596
+ const name = parts[0]
1597
+ if (!name) return
1598
+ if (agent.providers.some((p) => p.name === name)) {
1599
+ pushLine(`"${name}" already exists;先 /provider → 移除`, C.warn)
1600
+ return
1601
+ }
1602
+ const preset = PRESETS[name]
1603
+ const baseURL = (parts[1] ?? preset?.baseURL)?.replace(/\/+$/, "")
1604
+ const model = parts[2] ?? preset?.model
1605
+ if (!baseURL || !model) {
1606
+ pushLine(`Missing args: ${name} <baseURL> <model>`, C.error)
1607
+ return
1608
+ }
1609
+ if (!/^https?:\/\//.test(baseURL)) { pushLine(`baseURL must start with http(s)://`, C.error); return }
1610
+ agent.providers.push({ name, baseURL, model, ...(preset?.desc ? { desc: preset.desc } : {}) })
1611
+ await persistRaw((raw) => { raw.providers = agent.providers })
1612
+ pushLabel(`❯ Provider`, ansi.bold + C.tool)
1613
+ pushLine(`Added ${name} (${baseURL} / ${model})`, C.tool)
1614
+ pushLine(`Next: /provider → Set Key`, C.dim)
1615
+ })
1616
+ return
1617
+ }
1618
+ if (e.action === "key") {
1619
+ // Key: pick which provider, then prompt for key
1620
+ const keyEntries = [
1621
+ { type: "header", text: "Select provider to configure key" },
1622
+ ...agent.providers.map((p) => ({
1623
+ type: "item",
1624
+ text: `${p.name}${p.name === agent.activeProvider ? " ← current" : ""}${p.apiKey ? " ●has key" : " ○nonekey"}`,
1625
+ name: p.name,
1626
+ })),
1627
+ ]
1628
+ openPicker({
1629
+ title: "Configure API Key",
1630
+ entries: keyEntries,
1631
+ onSelect: (se) => {
1632
+ askQuestion(`Enter API key for ${se.name}:`).then(async (key) => {
1633
+ if (!key) return
1634
+ await setProviderKey(se.name, key)
1635
+ })
1636
+ },
1637
+ })
1638
+ }
1639
+ },
1640
+ })
1540
1641
  return
1541
1642
  }
1542
1643
  case "/config": {
1543
- const sub = rest[0]
1544
- // ---- /config embedkey <apikey>:embedding 服务的 key ----
1545
- if (sub === "embedkey") {
1546
- const key = rest.slice(1).join(" ")
1547
- if (!key) { pushLine("用法: /config embedkey <apikey>(embedding 服务,默认 SiliconFlow bge-m3)", C.error); return }
1548
- agent.config.embedding ??= {}
1549
- agent.config.embedding.apiKey = key
1550
- await persistRaw((raw) => { raw.embedding = { ...(raw.embedding ?? {}), apiKey: key } })
1551
- if (agent.memory) {
1552
- const { createEmbedder } = await import("./embedding.mjs")
1553
- agent.memory.embedder = createEmbedder(agent.config.embedding)
1554
- }
1555
- pushLabel(`❯ Config`, ansi.bold + C.tool)
1556
- pushLine(`embedding key 已保存,向量检索已启用`, C.tool)
1557
- return
1558
- }
1559
- // ---- /config set <path> <value> (高级) ----
1560
- if (sub === "set") {
1561
- const [path, value] = [rest[1], rest.slice(2).join(" ")]
1562
- if (!path || !value) { pushLine("用法: /config set <path> <value> 如 /config set agent.maxTurns 80", C.error); return }
1563
- try {
1564
- const { configPath, loadConfig, saveConfig } = await import("./config.mjs")
1565
- const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
1566
- // 支持 a.b 形式的嵌套 key
1567
- const keys = path.split(".")
1568
- let obj = raw
1569
- for (let i = 0; i < keys.length - 1; i++) { obj[keys[i]] ??= {}; obj = obj[keys[i]] }
1570
- obj[keys[keys.length - 1]] = isNaN(value) ? value : Number(value)
1571
- saveConfig(raw)
1572
- const cfg = loadConfig()
1573
- agent.provider = cfg.provider
1574
- agent.providers = cfg.providersList
1575
- agent.activeProvider = cfg.activeProvider
1576
- agent.config = cfg
1577
- pushLabel(`❯ Config`, ansi.bold + C.tool)
1578
- pushLine(`已保存: ${path} = ${value}`, C.tool)
1579
- } catch (error) {
1580
- pushLine(`保存失败: ${error.message}`, C.error)
1581
- }
1582
- return
1583
- }
1584
- if (sub) { pushLine(`未知: ${sub}(可用: embedkey / set)`, C.error); return }
1585
- // ---- /config(无参): 查看 ----
1586
- const { configPath: cp } = await import("./config.mjs")
1587
- pushLabel(`❯ 配置`, ansi.bold + C.tool)
1588
- pushLine(`激活: ${agent.activeProvider} / ${agent.provider.model}`, C.dim)
1589
- pushLine(`Key: ${maskKey(agent.provider.apiKey)}`, C.dim)
1590
- const ac = agent.config?.agent ?? {}
1591
- const tn = `${ac.compactThreshold ?? 100000}${ac.compactThresholdAuto ? " (auto)" : ""}`
1592
- pushLine(`agent: maxTurns=${ac.maxTurns ?? 100} | compactThreshold=${tn}`, C.dim)
1593
- pushLine(`embedding: ${agent.memory?.embedder ? `enabled (${agent.config?.embedding?.model ?? ""})` : "disabled(纯 FTS 检索)"}`, C.dim)
1594
- pushLabel(`❯ 管理`, ansi.bold + C.tool)
1595
- pushLine(`/provider 管理 provider(添加/删除/配 key)`, C.dim)
1596
- if (!agent.memory?.embedder) pushLine(`/config embedkey <k> 开启向量检索`, C.dim)
1597
- pushLine(`/config set <k> <v> 修改任意配置项`, C.dim)
1598
- pushLine(`配置文件: ${cp}`, C.dim)
1644
+ const entries = [
1645
+ { type: "header", text: "Config" },
1646
+ { type: "item", text: "View current config", action: "view" },
1647
+ { type: "item", text: "Set embedding key (vector search)", action: "embedkey" },
1648
+ { type: "item", text: "Advanced (set path value)", action: "set" },
1649
+ ]
1650
+ openPicker({
1651
+ title: "Config",
1652
+ entries,
1653
+ onSelect: async (e) => {
1654
+ if (e.action === "view") {
1655
+ const { configPath: cp } = await import("./config.mjs")
1656
+ pushLabel(`❯ Config`, ansi.bold + C.tool)
1657
+ pushLine(`Active: ${agent.activeProvider} / ${agent.provider.model}`, C.dim)
1658
+ pushLine(`Key: ${maskKey(agent.provider.apiKey)}`, C.dim)
1659
+ const ac = agent.config?.agent ?? {}
1660
+ const tn = `${ac.compactThreshold ?? 100000}${ac.compactThresholdAuto ? " (auto)" : ""}`
1661
+ pushLine(`agent: maxTurns=${ac.maxTurns ?? 100} | compactThreshold=${tn}`, C.dim)
1662
+ pushLine(`embedding: ${agent.memory?.embedder ? `enabled (${agent.config?.embedding?.model ?? ""})` : "disabled (FTS only)"}`, C.dim)
1663
+ pushLine(`Config文件: ${cp}`, C.dim)
1664
+ return
1665
+ }
1666
+ if (e.action === "embedkey") {
1667
+ askQuestion("Enter embedding API key (default: SiliconFlow bge-m3):").then(async (key) => {
1668
+ if (!key) return
1669
+ agent.config.embedding ??= {}
1670
+ agent.config.embedding.apiKey = key
1671
+ await persistRaw((raw) => { raw.embedding = { ...(raw.embedding ?? {}), apiKey: key } })
1672
+ if (agent.memory) {
1673
+ const { createEmbedder } = await import("./embedding.mjs")
1674
+ agent.memory.embedder = createEmbedder(agent.config.embedding)
1675
+ }
1676
+ pushLabel(`❯ Config`, ansi.bold + C.tool)
1677
+ pushLine(`Embedding key saved, vector search enabled`, C.tool)
1678
+ })
1679
+ return
1680
+ }
1681
+ if (e.action === "set") {
1682
+ askQuestion("Enter: <path> <value> (e.g. agent.maxTurns 80, supports a.b nesting):").then(async (text) => {
1683
+ if (!text) return
1684
+ const parts = text.split(/\s+/)
1685
+ const [path, value] = [parts[0], parts.slice(1).join(" ")]
1686
+ if (!path || !value) { pushLine("Usage: <path> <value> e.g. agent.maxTurns 80", C.error); return }
1687
+ try {
1688
+ const { configPath, loadConfig, saveConfig } = await import("./config.mjs")
1689
+ const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
1690
+ const keys = path.split(".")
1691
+ let obj = raw
1692
+ for (let i = 0; i < keys.length - 1; i++) { obj[keys[i]] ??= {}; obj = obj[keys[i]] }
1693
+ obj[keys[keys.length - 1]] = isNaN(value) ? value : Number(value)
1694
+ saveConfig(raw)
1695
+ const cfg = loadConfig()
1696
+ agent.provider = cfg.provider
1697
+ agent.providers = cfg.providersList
1698
+ agent.activeProvider = cfg.activeProvider
1699
+ agent.config = cfg
1700
+ pushLabel(`❯ Config`, ansi.bold + C.tool)
1701
+ pushLine(`Saved: ${path} = ${value}`, C.tool)
1702
+ } catch (error) {
1703
+ pushLine(`Save failed: ${error.message}`, C.error)
1704
+ }
1705
+ })
1706
+ }
1707
+ },
1708
+ })
1599
1709
  return
1600
1710
  }
1601
1711
  case "/help": {
@@ -1621,7 +1731,7 @@ export async function startTUI(agent, opts = {}) {
1621
1731
  return
1622
1732
  }
1623
1733
  default:
1624
- pushLine(`Unknown command: ${cmd}(/help 查看可用命令)`, C.error)
1734
+ pushLine(`Unknown command: ${cmd} (/help 查看可用Commands)`, C.error)
1625
1735
  return
1626
1736
  }
1627
1737
  }
@@ -1632,18 +1742,18 @@ export async function startTUI(agent, opts = {}) {
1632
1742
  return `${key.slice(0, 5)}…${key.slice(-4)}`
1633
1743
  }
1634
1744
 
1635
- /** Tab 补全候选:命令名 / 子命令 / provider 名 / 预设名 / think 参数 */
1745
+ /** Tab 补全候选:Commands名 / 子Commands / provider 名 / 预设名 / think 参数 */
1636
1746
  function completions(input) {
1637
1747
  if (!input.startsWith("/")) return []
1638
1748
  const parts = input.split(/\s+/)
1639
- // 还在敲第一个 token:补命令名
1749
+ // 还在敲第一个 token:补Commands名
1640
1750
  if (parts.length === 1) {
1641
1751
  return SLASH_COMMANDS.filter((c) => c.name.startsWith(parts[0])).map((c) => c.name)
1642
1752
  }
1643
1753
  const cmd = parts[0]
1644
- const last = parts.at(-1) // 结尾是空格时为 "",即列出全部候选
1754
+ const last = parts.at(-1) // 结尾是空格时Enter API key for "",即列出全部候选
1645
1755
  const head = parts.slice(0, -1).join(" ")
1646
- const argIndex = parts.length - 2 // 正在敲第几个参数(0 基)
1756
+ const argIndex = parts.length - 2 // 正在敲第几个参数 (0 基)
1647
1757
  const match = (cands) => cands.filter((c) => c.startsWith(last)).map((c) => `${head} ${c}`)
1648
1758
  if (cmd === "/model" && argIndex === 0) return match(agent.providers.map((p) => p.name))
1649
1759
  if (cmd === "/provider") {
@@ -1681,7 +1791,7 @@ export async function startTUI(agent, opts = {}) {
1681
1791
  render()
1682
1792
  }
1683
1793
 
1684
- /** 读配置文件 → 修改 → 写回;文件不存在时从空对象开始 */
1794
+ /** 读Config文件 → 修改 → 写回;文件not found时从空对象开始 */
1685
1795
  async function persistRaw(mutate) {
1686
1796
  const { saveConfig, configPath } = await import("./config.mjs")
1687
1797
  const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
@@ -1689,7 +1799,7 @@ export async function startTUI(agent, opts = {}) {
1689
1799
  saveConfig(raw)
1690
1800
  }
1691
1801
 
1692
- /** 把当前激活 provider 的某个字段同步到 providers 列表并持久化 */
1802
+ /** 把current激活 provider 的某个字段同步到 providers 列表并持久化 */
1693
1803
  async function syncProviderField(field, value) {
1694
1804
  const target = agent.providers.find((p) => p.name === agent.activeProvider)
1695
1805
  if (!target) return
@@ -1701,11 +1811,24 @@ export async function startTUI(agent, opts = {}) {
1701
1811
  })
1702
1812
  }
1703
1813
 
1704
- // ---------------------------------------------------------- 模型选择器(/model)
1814
+ // ---------------------------------------------------------- 模型选择器 (/model)
1705
1815
 
1706
- const pickerItems = () => state.picker.entries.filter((e) => e.type === "item")
1816
+ const pickerItems = () => state.picker?.entries.filter((e) => e.type === "item") ?? []
1707
1817
 
1708
- /** entries 重建显示行并刷新;高亮选中项、标注当前模型 */
1818
+ /** 打开通用列表选择器。entries { type: "header"|"item", text, note?, ...extra },
1819
+ * onSelect 拿到选中条目 (含 extra 字段透传),onCancel 在 Esc 时调。 */
1820
+ function openPicker({ title, entries, onSelect, onCancel, defaultIndex = 0 }) {
1821
+ state.picker = { title, entries, lines: [], index: defaultIndex, scroll: 0, selectedLine: 0, onSelect, onCancel }
1822
+ renderPickerLines()
1823
+ }
1824
+
1825
+ function closePicker() {
1826
+ state.picker?.onCancel?.()
1827
+ state.picker = null
1828
+ render()
1829
+ }
1830
+
1831
+ /** 按 entries 重建显示行并刷新 */
1709
1832
  function renderPickerLines() {
1710
1833
  const p = state.picker
1711
1834
  if (!p) return
@@ -1714,13 +1837,13 @@ export async function startTUI(agent, opts = {}) {
1714
1837
  let selectedLine = 0
1715
1838
  for (const e of p.entries) {
1716
1839
  if (e.type === "header") {
1717
- lines.push({ text: ` ${e.name}${e.note ? ` ${e.note}` : ""}`, color: ansi.bold + C.tool })
1840
+ lines.push({ text: ` ${e.text}${e.note ? ` ${e.note}` : ""}`, color: ansi.bold + C.tool })
1718
1841
  } else {
1719
1842
  const selected = row === p.index
1720
1843
  if (selected) selectedLine = lines.length
1721
- const current = e.provider === agent.activeProvider && e.model === agent.provider.model
1844
+ const marker = e.marker ? ` ${e.marker}` : ""
1722
1845
  lines.push({
1723
- text: `${selected ? " ▸ " : " "}${e.model}${current ? " ← 当前" : ""}`,
1846
+ text: `${selected ? " ▸ " : " "}${e.text}${marker}`,
1724
1847
  color: selected ? ansi.bold + C.text : C.dim,
1725
1848
  })
1726
1849
  row++
@@ -1731,15 +1854,17 @@ export async function startTUI(agent, opts = {}) {
1731
1854
  render()
1732
1855
  }
1733
1856
 
1734
- /** 打开选择器:先列出各 provider 已配置的模型,再并发拉取各端点的全部模型展开进去 */
1857
+ // ========== 模型选择器 (基于通用 picker,异步拉取远端模型列表) ==========
1858
+
1735
1859
  async function openModelPicker() {
1736
1860
  const entries = []
1737
1861
  for (const p of agent.providers) {
1738
- entries.push({ type: "header", name: p.name, note: `${p.baseURL}${p.apiKey ? "" : "(未配 key"} 加载中...` })
1739
- entries.push({ type: "item", provider: p.name, model: p.model })
1862
+ entries.push({ type: "header", text: p.name, note: `${p.baseURL}${p.apiKey ? "" : " (no key)"} loading...` })
1863
+ entries.push({ type: "item", text: p.model, provider: p.name, model: p.model })
1740
1864
  }
1741
- state.picker = { entries, lines: [], index: 0, scroll: 0, selectedLine: 0 }
1742
- // 默认选中当前在用的模型
1865
+ const onSelect = (e) => selectModel(e).catch((err) => pushLine(`[error] ${err.message}`, C.error))
1866
+ openPicker({ title: "Select Model", entries, onSelect })
1867
+ // 默认选中current在用的模型
1743
1868
  const current = pickerItems().findIndex(
1744
1869
  (e) => e.provider === agent.activeProvider && e.model === agent.provider.model,
1745
1870
  )
@@ -1749,10 +1874,9 @@ export async function startTUI(agent, opts = {}) {
1749
1874
  const { listModels } = await import("./provider.mjs")
1750
1875
  await Promise.all(
1751
1876
  agent.providers.map(async (p) => {
1752
- const header = entries.find((e) => e.type === "header" && e.name === p.name)
1753
- const noteBase = `${p.baseURL}${p.apiKey ? "" : "(未配 key"}`
1877
+ const header = entries.find((e) => e.type === "header" && e.provider === undefined && e.text === p.name)
1878
+ const noteBase = `${p.baseURL}${p.apiKey ? "" : " (no key)"}`
1754
1879
  try {
1755
- // key 的环境变量兜底和 loadConfig 保持一致(提供商专用变量只对同名生效)
1756
1880
  const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[p.name]
1757
1881
  let apiKey = p.apiKey
1758
1882
  if (!apiKey && envKey && process.env[envKey]) apiKey = process.env[envKey]
@@ -1761,79 +1885,73 @@ export async function startTUI(agent, opts = {}) {
1761
1885
  { baseURL: p.baseURL, apiKey: apiKey ?? "" },
1762
1886
  { signal: AbortSignal.timeout(10000) },
1763
1887
  )
1764
- // 展开到该 provider 已配置模型的后面(去重)
1765
1888
  const at = entries.findIndex((e) => e.type === "item" && e.provider === p.name && e.model === p.model)
1766
1889
  entries.splice(
1767
1890
  at + 1,
1768
1891
  0,
1769
- ...models.filter((m) => m !== p.model).map((m) => ({ type: "item", provider: p.name, model: m })),
1892
+ ...models.filter((m) => m !== p.model).map((m) => ({ type: "item", text: m, provider: p.name, model: m })),
1770
1893
  )
1771
- header.note = noteBase
1894
+ if (header) header.note = noteBase
1772
1895
  } catch (error) {
1773
- header.note = `${noteBase} (拉取失败: ${sliceByWidth(error.message, 60)})`
1896
+ if (header) header.note = `${noteBase} (fetch failed: ${sliceByWidth(error.message, 60)})`
1774
1897
  }
1775
- if (state.picker?.entries === entries) renderPickerLines() // 已关闭就不再刷新
1898
+ if (state.picker?.entries === entries) renderPickerLines()
1776
1899
  }),
1777
1900
  )
1778
1901
  }
1779
1902
 
1780
- function closeModelPicker() {
1781
- state.picker = null
1782
- render()
1783
- }
1784
-
1785
- /** 给指定 provider 写 key(内存 + 配置文件);若它是当前激活的,同步运行时 */
1903
+ /** 给指定 provider 写 key (内存 + Config文件);若它是current激活的,同步运行时 */
1786
1904
  async function setProviderKey(name, key) {
1787
1905
  const target = agent.providers.find((p) => p.name === name)
1788
1906
  if (!target) {
1789
- pushLine(`未找到 provider "${name}"`, C.error)
1907
+ pushLine(`Provider "${name}"`, C.error)
1790
1908
  return
1791
1909
  }
1792
1910
  target.apiKey = key
1793
1911
  if (name === agent.activeProvider) agent.provider.apiKey = key
1794
1912
  await persistRaw((raw) => { raw.providers = agent.providers })
1795
1913
  pushLabel(`❯ Provider`, ansi.bold + C.tool)
1796
- pushLine(`apiKey 已保存到 ${name}`, C.tool)
1914
+ pushLine(`API key saved to ${name}`, C.tool)
1797
1915
  }
1798
1916
 
1799
- // ---------------------------------------------------------- 初始配置向导(首次启动)
1917
+ // ---------------------------------------------------------- 初始Config向导 (首次启动)
1800
1918
 
1801
- /** 菜单步的候选项:已有 provider(未配 key 的标注)+ 未添加的预设 + 自定义 */
1919
+ /** 菜单步的候选项:已有 provider (no key 的标注)+ 未添加的预设 + 自定义 */
1802
1920
  function wizardProviderItems() {
1803
1921
  const items = []
1804
1922
  for (const p of agent.providers) {
1805
- items.push({ kind: "existing", name: p.name, baseURL: p.baseURL, model: p.model, label: `${p.name}(已添加${p.apiKey ? "" : ",未配 key"})` })
1923
+ items.push({ kind: "existing", name: p.name, baseURL: p.baseURL, model: p.model, label: `${p.name} (added${p.apiKey ? "" : ",no key"})` })
1806
1924
  }
1807
1925
  for (const [name, p] of Object.entries(PRESETS)) {
1808
1926
  if (!agent.providers.some((x) => x.name === name)) {
1809
- items.push({ kind: "preset", name, baseURL: p.baseURL, model: p.model, label: `${name}(${p.desc})` })
1927
+ items.push({ kind: "preset", name, baseURL: p.baseURL, model: p.model, label: `${name} (${p.desc})` })
1810
1928
  }
1811
1929
  }
1812
- items.push({ kind: "custom", name: null, label: "自定义端点…" })
1930
+ items.push({ kind: "custom", name: null, label: "Custom endpoint…" })
1813
1931
  return items
1814
1932
  }
1815
1933
 
1816
- /** 文本步骤定义:提示语 + 校验(通过返回 true,否则返回错误文案) */
1934
+ /** 文本步骤定义:提示语 + 校验 (通过返回 true,否则返回错误文案) */
1817
1935
  const WIZARD_STEPS = {
1818
1936
  name: {
1819
- prompt: "给这个 provider 起个名字(字母/数字/-/_,如 my-openai)",
1937
+ prompt: "给这个 provider 起个名字 (字母/数字/-/_,如 my-openai)",
1820
1938
  validate: (v) =>
1821
- (/^[\w-]+$/.test(v) && !agent.providers.some((p) => p.name === v)) || "名字需为字母/数字/-/_,且不与已有 provider 重名",
1939
+ (/^[\w-]+$/.test(v) && !agent.providers.some((p) => p.name === v)) || "Name must be alphanumeric/-/_ and unique",
1822
1940
  },
1823
1941
  baseURL: {
1824
- prompt: "输入 baseURL(如 https://api.openai.com/v1)",
1825
- validate: (v) => /^https?:\/\/.+/.test(v) || "baseURL 应以 http(s):// 开头",
1942
+ prompt: "输入 baseURL (如 https://api.openai.com/v1)",
1943
+ validate: (v) => /^https?:\/\/.+/.test(v) || "baseURL must start with http(s)://",
1826
1944
  },
1827
1945
  model: {
1828
- prompt: "输入模型名(如 gpt-4o)",
1829
- validate: (v) => v.length > 0 || "模型名不能为空",
1946
+ prompt: "输入模型名 (如 gpt-4o)",
1947
+ validate: (v) => v.length > 0 || "Model name required",
1830
1948
  },
1831
1949
  key: {
1832
1950
  prompt: "输入 API key",
1833
1951
  validate: (v) => v.length > 0 || "key 不能为空",
1834
1952
  },
1835
1953
  embedkey: {
1836
- prompt: "可选:embedding API keySiliconFlow,记忆向量检索用;直接回车跳过)",
1954
+ prompt: "可选:embedding API key (SiliconFlow,记忆向量检索用;直接回车跳过)",
1837
1955
  validate: () => true, // 可跳过
1838
1956
  },
1839
1957
  }
@@ -1849,7 +1967,7 @@ export async function startTUI(agent, opts = {}) {
1849
1967
  if (!w) return
1850
1968
  const lines = []
1851
1969
  if (w.step === "provider") {
1852
- lines.push({ text: " 选择一个模型提供商:", color: C.text })
1970
+ lines.push({ text: " Choose a model provider:", color: C.text })
1853
1971
  wizardProviderItems().forEach((it, i) => {
1854
1972
  if (i === w.index) w.selectedLine = lines.length
1855
1973
  lines.push({
@@ -1859,11 +1977,11 @@ export async function startTUI(agent, opts = {}) {
1859
1977
  })
1860
1978
  } else {
1861
1979
  const f = w.fields
1862
- if (f.name) lines.push({ text: ` 提供商: ${f.name}`, color: C.dim })
1980
+ if (f.name) lines.push({ text: ` Provider: ${f.name}`, color: C.dim })
1863
1981
  if (f.baseURL) lines.push({ text: ` baseURL: ${f.baseURL}`, color: C.dim })
1864
1982
  if (f.model) lines.push({ text: ` 模型: ${f.model}`, color: C.dim })
1865
1983
  lines.push({ text: ` ❯ ${WIZARD_STEPS[w.step].prompt}`, color: ansi.bold + C.text })
1866
- lines.push({ text: " (在下方输入框输入)", color: C.dim })
1984
+ lines.push({ text: " (type in input box below)", color: C.dim })
1867
1985
  w.selectedLine = 0
1868
1986
  }
1869
1987
  if (w.error) lines.push({ text: ` ${w.error}`, color: C.error })
@@ -1906,11 +2024,11 @@ export async function startTUI(agent, opts = {}) {
1906
2024
 
1907
2025
  function cancelWizard() {
1908
2026
  state.wizard = null
1909
- pushLine("已跳过初始配置。之后随时可用 /provider add 添加提供商、/provider key 配 key。", C.dim)
2027
+ pushLine("已跳过初始Config。之后随时可用 /provider add 添加Provider、/provider key 配 key。", C.dim)
1910
2028
  render()
1911
2029
  }
1912
2030
 
1913
- /** 向导完成:写入 provider(有则更新)、设为激活、持久化,然后接模型选择器 */
2031
+ /** 向导完成:写入 provider (有则更新)、设为激活、持久化,然后接模型选择器 */
1914
2032
  async function finishWizard() {
1915
2033
  const f = state.wizard.fields
1916
2034
  state.wizard = null
@@ -1929,7 +2047,7 @@ export async function startTUI(agent, opts = {}) {
1929
2047
  })
1930
2048
  agent.config.activeProvider = f.name
1931
2049
  pushLabel(`❯ Setup`, ansi.bold + C.tool)
1932
- pushLine(`配置完成:${f.name} / ${f.model}(已写入配置文件)`, C.tool)
2050
+ pushLine(`Setup complete: ${f.name} / ${f.model} (saved to config)`, C.tool)
1933
2051
  // embedding key:配了就启用向量检索,没配提示事后通道
1934
2052
  if (f.embedkey) {
1935
2053
  agent.config.embedding ??= {}
@@ -1939,17 +2057,17 @@ export async function startTUI(agent, opts = {}) {
1939
2057
  const { createEmbedder } = await import("./embedding.mjs")
1940
2058
  agent.memory.embedder = createEmbedder(agent.config.embedding)
1941
2059
  }
1942
- pushLine(`向量检索已启用(${agent.config.embedding.model ?? "BAAI/bge-m3"})`, C.tool)
2060
+ pushLine(`Vector search enabled (${agent.config.embedding.model ?? "BAAI/bge-m3"})`, C.tool)
1943
2061
  } else {
1944
- pushLine(`向量检索未启用(记忆退化为纯文本检索);之后可 /config embedkey <key> 开启`, C.dim)
2062
+ pushLine(`向量检索未启用 (记忆退化为纯文本检索);之后可 /config embedkey <key> On`, C.dim)
1945
2063
  }
1946
- pushLine(`选择要用的模型(Esc 保持 ${f.model})`, C.dim)
2064
+ pushLine(`Select model (Esc to keep ${f.model})`, C.dim)
1947
2065
  openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
1948
2066
  }
1949
2067
 
1950
2068
  /** 选中:切换 provider + 模型,持久化,阈值随模型走 */
1951
2069
  async function selectModel(item) {
1952
- closeModelPicker()
2070
+ closePicker()
1953
2071
  const target = agent.providers.find((pp) => pp.name === item.provider)
1954
2072
  if (!target) return
1955
2073
  target.model = item.model
@@ -1965,7 +2083,7 @@ export async function startTUI(agent, opts = {}) {
1965
2083
  const { resolveCompactThreshold } = await import("./config.mjs")
1966
2084
  const { value } = resolveCompactThreshold(null, item.model)
1967
2085
  agent.config.agent.compactThreshold = value
1968
- thresholdNote = `,压缩阈值随模型调整为 ${value}`
2086
+ thresholdNote = `, compact threshold adjusted to ${value}`
1969
2087
  }
1970
2088
  await persistRaw((raw) => {
1971
2089
  raw.providers = agent.providers
@@ -1973,14 +2091,14 @@ export async function startTUI(agent, opts = {}) {
1973
2091
  })
1974
2092
  agent.config.activeProvider = item.provider
1975
2093
  pushLabel(`❯ Model`, ansi.bold + C.tool)
1976
- pushLine(`已切换到 ${item.provider} / ${item.model}${thresholdNote}(已持久化)`, C.tool)
1977
- if (!agent.provider.apiKey) pushLine(`该 provider 还没配 key: /config key <apikey>`, C.warn)
2094
+ pushLine(`Switched to ${item.provider} / ${item.model}${thresholdNote} (persisted)`, C.tool)
2095
+ if (!agent.provider.apiKey) pushLine(`Provider has no key: /provider Set Key`, C.warn)
1978
2096
  }
1979
2097
 
1980
- /** /distill:从当前会话提取候选,逐条 y/n 确认后入库 */
2098
+ /** /distill:从current会话提取候选,逐条 y/n 确认后入库 */
1981
2099
  async function runDistill() {
1982
2100
  if (agent.history.length === 0) {
1983
- pushLine("[distill] 当前会话为空,没有可提取的内容", C.dim)
2101
+ pushLine("[distill] current会话为空,没有可提取的内容", C.dim)
1984
2102
  return
1985
2103
  }
1986
2104
  state.processing = true
@@ -1988,17 +2106,17 @@ export async function startTUI(agent, opts = {}) {
1988
2106
  render()
1989
2107
  try {
1990
2108
  const { extractCandidates, historyToTranscript, saveCandidate } = await import("./distill.mjs")
1991
- pushLine("[distill] 正在分析会话...", C.tool)
2109
+ pushLine("[distill] Analyzing session...", C.tool)
1992
2110
  const candidates = await extractCandidates(agent.provider, historyToTranscript(agent.history))
1993
2111
  if (candidates.length === 0) {
1994
- pushLine("[distill] 本次会话没有值得沉淀的知识", C.dim)
2112
+ pushLine("[distill] No knowledge worth saving from this session", C.dim)
1995
2113
  return
1996
2114
  }
1997
2115
  let saved = 0
1998
2116
  for (const c of candidates) {
1999
- pushLine(`── 候选 [${c.type}] ${c.title} (scope: ${c.scope ?? "personal"})`, C.warn)
2117
+ pushLine(`── Candidate [${c.type}] ${c.title} (scope: ${c.scope ?? "personal"})`, C.warn)
2000
2118
  for (const line of c.content.split("\n").slice(0, 6)) pushLine(` ${line}`, C.dim)
2001
- if (c.type === "rule") pushLine(" (rule 类建议手动撰写;确认提取请按 y)", C.warn)
2119
+ if (c.type === "rule") pushLine(" (rule type consider writing manually; press y to extract)", C.warn)
2002
2120
  const accept = await askPermission("distill-save", { title: c.title })
2003
2121
  if (!accept) {
2004
2122
  pushLine(" skipped", C.dim)
@@ -2008,7 +2126,7 @@ export async function startTUI(agent, opts = {}) {
2008
2126
  pushLine(` saved -> ${where}`, C.tool)
2009
2127
  saved++
2010
2128
  }
2011
- pushLine(`[distill] 完成:入库 ${saved}/${candidates.length} 条`, C.tool)
2129
+ pushLine(`[distill] Done: saved ${saved}/${candidates.length} 条`, C.tool)
2012
2130
  } catch (error) {
2013
2131
  pushLine(`[distill] error: ${error.message}`, C.error)
2014
2132
  } finally {
@@ -2022,7 +2140,7 @@ export async function startTUI(agent, opts = {}) {
2022
2140
 
2023
2141
  // keypress 挂在过滤后的 keyStream 上:鼠标序列已在上游滤网中处理并剥除
2024
2142
  keyStream.on("keypress", (str, key = {}) => {
2025
- // 权限确认态:y 批准 / n 拒绝 / a 批准并开启 AUTO(后续不再询问)
2143
+ // 权限确认态:y 批准 / n 拒绝 / a 批准并On AUTO (后续不再询问)
2026
2144
  if (state.permission) {
2027
2145
  const answer = (str || "").toLowerCase()
2028
2146
  const isContinue = state.permission.name === "continue"
@@ -2036,10 +2154,10 @@ export async function startTUI(agent, opts = {}) {
2036
2154
  agent.autoApprove = true
2037
2155
  agent._pendingReminders = agent._pendingReminders ?? []
2038
2156
  agent._pendingReminders.push("[System reminder: AUTO mode is now ON. All tool calls are automatically approved. Use /auto to disable.]")
2039
- pushLine(` [auto] AUTO 已开启:后续工具调用不再询问(/auto 关闭)`, C.warn)
2157
+ pushLine(` [auto] AUTO 已On:后续工具调用不再询问 (/auto Off)`, C.warn)
2040
2158
  }
2041
2159
  const approved = answer === "y" || (answer === "a" && !isContinue)
2042
- // 决定落痕:对话区留下批准/拒绝记录(continue 询问有自己的输出,不重复记)
2160
+ // 决定落痕:对话区留下批准/拒绝记录 (continue 询问有自己的输出,不重复记)
2043
2161
  if (!isContinue) {
2044
2162
  pushLine(` [${approved ? "approved" : "denied"}] ${name}`, approved ? C.dim : C.error)
2045
2163
  }
@@ -2101,7 +2219,7 @@ export async function startTUI(agent, opts = {}) {
2101
2219
  if (key.ctrl && key.name === "c") {
2102
2220
  if (state.processing && state.controller) {
2103
2221
  state.controller.abort()
2104
- pushLine("[中止中…]", C.warn)
2222
+ pushLine("[Aborting…]", C.warn)
2105
2223
  render()
2106
2224
  return
2107
2225
  }
@@ -2109,11 +2227,11 @@ export async function startTUI(agent, opts = {}) {
2109
2227
  setTimeout(() => process.exit(0), 100)
2110
2228
  }
2111
2229
 
2112
- // 模型选择器:↑↓ 移动,Enter 确认,Esc 取消,其余按键吞掉
2230
+ // 通用列表选择器:↑↓ 移动,Enter 确认,Esc 取消
2113
2231
  if (state.picker) {
2114
2232
  const items = pickerItems()
2115
2233
  if (key.name === "escape") {
2116
- closeModelPicker()
2234
+ closePicker()
2117
2235
  } else if (key.name === "up" && items.length) {
2118
2236
  state.picker.index = (state.picker.index - 1 + items.length) % items.length
2119
2237
  renderPickerLines()
@@ -2121,12 +2239,14 @@ export async function startTUI(agent, opts = {}) {
2121
2239
  state.picker.index = (state.picker.index + 1) % items.length
2122
2240
  renderPickerLines()
2123
2241
  } else if (key.name === "return" && items.length) {
2124
- selectModel(items[state.picker.index]).catch((e) => pushLine(`[error] ${e.message}`, C.error))
2242
+ const selected = items[state.picker.index]
2243
+ state.picker.onSelect?.(selected)
2244
+ closePicker()
2125
2245
  }
2126
2246
  return
2127
2247
  }
2128
2248
 
2129
- // 初始配置向导:菜单步 ↑↓/Enter/Esc;文本步 Enter 提交、Esc 取消,编辑键落到正常输入
2249
+ // 初始Config向导:菜单步 ↑↓/Enter/Esc;文本步 Enter 提交、Esc 取消,编辑键落到正常输入
2130
2250
  if (state.wizard) {
2131
2251
  const w = state.wizard
2132
2252
  if (key.name === "escape") {
@@ -2168,7 +2288,7 @@ export async function startTUI(agent, opts = {}) {
2168
2288
 
2169
2289
  if (state.processing) return // 处理中锁定输入
2170
2290
 
2171
- // Tab:斜杠命令补全(循环候选);其余输入忽略(\t 会顶破输入框,永不直接插入)
2291
+ // Tab:斜杠Commands补全 (循环候选);其余输入忽略 (\t 会顶破输入框,永不直接插入)
2172
2292
  if (key.name === "tab") {
2173
2293
  handleTab()
2174
2294
  return
@@ -2238,11 +2358,18 @@ export async function startTUI(agent, opts = {}) {
2238
2358
  return
2239
2359
  }
2240
2360
  if (key.name === "return") {
2241
- submit()
2361
+ submit().catch((e) => pushLine(`[error] ${e.message}`, C.error))
2362
+ return
2363
+ }
2364
+
2365
+ // Ctrl+V (Unix) / Alt+V (Windows):粘贴剪贴板图片 → 存临时文件 → 输入框插入 read_image
2366
+ const isPasteImage = (key.name === "v" && (key.ctrl || key.meta)) || (key.name === "v" && key.alt)
2367
+ if (isPasteImage) {
2368
+ pasteClipboardImage(agent).catch((e) => pushLine(`[error] ${e.message}`, C.error))
2242
2369
  return
2243
2370
  }
2244
2371
 
2245
- // 可打印字符 / 粘贴(str 可能一次多个字符);Tab 一律转成两个空格(\t 显示宽度不定,会顶破输入框)
2372
+ // 可打印字符 / 粘贴 (str 可能一次多个字符);Tab 一律转成两个空格 (\t 显示宽度不定,会顶破输入框)
2246
2373
  // \r\n 在 Windows raw mode 下可能漏进来冲乱页面
2247
2374
  if (str && !key.ctrl && !key.meta) {
2248
2375
  const chars = [...str.replace(/[\r\n]+/g, "").replace(/\t/g, " ")]
@@ -2254,18 +2381,18 @@ export async function startTUI(agent, opts = {}) {
2254
2381
 
2255
2382
  // 启动画面
2256
2383
  if (!agent.provider.apiKey) {
2257
- pushLabel(`欢迎使用 ThinCoder!`, ansi.bold + C.tool)
2258
- pushLine("检测到还没配置 API key,进入初始配置(Esc 可随时跳过)", C.text)
2384
+ pushLabel(`Welcome to ThinCoder!`, ansi.bold + C.tool)
2385
+ pushLine("检测到还没Config API key,进入初始Config (Esc 可随时跳过)", C.text)
2259
2386
  startWizard()
2260
2387
  } else {
2261
2388
  pushLine(`Welcome to ThinCoder. Provider: ${agent.activeProvider} / ${agent.provider.model}`, C.dim)
2262
2389
  }
2263
2390
  pushLine(`Tools: ${agent.tools.map((t) => t.name).join(", ")}`, C.dim)
2264
- // 恢复上次会话:重建对话区显示(tool 结果行省略,保持清爽)
2391
+ // 恢复上次会话:重建对话区显示 (tool 结果行省略,保持清爽)
2265
2392
  if (opts.restored?.display?.length) {
2266
2393
  // 用户视角的恢复:display 是退出前对话区的原样快照,所见即所得
2267
2394
  state.lines = [...opts.restored.display.map((l) => ({ text: l.text, color: l.color })), ...state.lines]
2268
- pushLabel(`── 已恢复上次会话(退出前原样回放);/new 开始新会话 ──`, C.warn)
2395
+ pushLabel(`── Restored previous session; /new for a fresh session ──`, C.warn)
2269
2396
  } else if (opts.restored?.history?.length) {
2270
2397
  // 重建对话区:user/assistant 消息逐条展示,tool 结果行只保留首行摘要
2271
2398
  for (let i = 0; i < opts.restored.history.length; i++) {
@@ -2287,15 +2414,15 @@ export async function startTUI(agent, opts = {}) {
2287
2414
  }
2288
2415
  // tool 消息本身不单独渲染——已在 assistant 的 tool_calls 后以摘要形式展示
2289
2416
  }
2290
- pushLabel(`── 已恢复上次会话(${opts.restored.history.length} 条消息);/new 开始新会话 ──`, C.warn)
2417
+ pushLabel(`── Restored previous session (${opts.restored.history.length} messages); /new for a fresh session ──`, C.warn)
2291
2418
  }
2292
2419
  // 有归档槽位时给个提示
2293
2420
  if (listSlots(agent.cwd).length > 0) {
2294
- pushLine("提示:存在归档会话,/session 可查看/切换", C.dim)
2421
+ pushLine("Tip: archived sessions available — /session to view/switch", C.dim)
2295
2422
  }
2296
2423
  render()
2297
2424
 
2298
- // 后台索引(进界面后再跑,不阻塞启动);进度走底部状态栏,不往对话区塞行
2425
+ // 后台索引 (进界面后再跑,不阻塞启动);进度走底部状态栏,不往对话区塞行
2299
2426
  ;(async () => {
2300
2427
  const { codeSync, docSync } = await import("./memory.mjs")
2301
2428
  const cwd = agent.cwd