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