thincoder 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -35
- package/bin/thincoder.mjs +194 -23
- package/package.json +1 -1
- package/src/SYSTEM_PROMPT.md +30 -0
- package/src/agent.mjs +471 -61
- package/src/coder-overlay.md +14 -0
- package/src/config.mjs +91 -20
- package/src/context.mjs +41 -6
- package/src/explore-overlay.md +10 -0
- package/src/mcp.mjs +359 -0
- package/src/provider.mjs +7 -1
- package/src/session.mjs +7 -4
- 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 +1181 -122
package/src/tui.mjs
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* tui.mjs — 裸 ANSI 终端 UI
|
|
3
3
|
* 零依赖:raw mode 键盘输入、ANSI 转义渲染、自研宽字符换行。
|
|
4
|
-
* 布局:header / 对话区(可滚动)/ 输入框 / 状态栏。
|
|
4
|
+
* 布局:header / 对话区(可滚动)/ todo 面板(有任务时)/ 输入框 / 状态栏。
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { emitKeypressEvents } from "node:readline"
|
|
8
8
|
import { PassThrough } from "node:stream"
|
|
9
9
|
import { basename } from "node:path"
|
|
10
|
-
import {
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
11
|
+
import { runAgent, ContinueError } from "./agent.mjs"
|
|
11
12
|
import { saveSession, clearSession } from "./session.mjs"
|
|
13
|
+
import { PROVIDER_PRESETS as PRESETS } from "./config.mjs"
|
|
14
|
+
import { closeAllMcp } from "./mcp.mjs"
|
|
12
15
|
|
|
13
16
|
// ---------------------------------------------------------------- ANSI 工具
|
|
14
17
|
|
|
@@ -226,10 +229,15 @@ export async function startTUI(agent, opts = {}) {
|
|
|
226
229
|
historyIndex: -1,
|
|
227
230
|
scroll: 0, // 从底部向上的滚动行数
|
|
228
231
|
processing: false,
|
|
232
|
+
controller: null, // AbortController for current agent run
|
|
229
233
|
permission: null, // { name, args, resolve }
|
|
234
|
+
question: null, // { text, options, resolve } — agent 的 question 工具回调
|
|
235
|
+
picker: null, // 模型选择器 { entries, lines, index, scroll, selectedLine }
|
|
236
|
+
wizard: null, // 首次配置向导 { step, index, scroll, selectedLine, fields, error, lines }
|
|
230
237
|
tasks: [], // task 工具的任务列表(状态栏显示进度)
|
|
231
238
|
reasoning: "", // 思考流缓冲(暗色展示)
|
|
232
|
-
|
|
239
|
+
completion: null, // Tab 补全状态 { candidates, index }
|
|
240
|
+
toolStreams: {}, // 各工具的实时输出(按工具名隔离,并行工具互不串扰)
|
|
233
241
|
currentTool: null, // 正在执行的工具名(状态栏显示)
|
|
234
242
|
processingStarted: 0, // 本轮处理开始时间(状态栏计时)
|
|
235
243
|
status: "Ready",
|
|
@@ -279,6 +287,12 @@ export async function startTUI(agent, opts = {}) {
|
|
|
279
287
|
} catch {
|
|
280
288
|
// 存失败不耽误退出
|
|
281
289
|
}
|
|
290
|
+
// 关闭 MCP stdio 子进程,不留孤儿
|
|
291
|
+
try {
|
|
292
|
+
closeAllMcp(agent)
|
|
293
|
+
} catch {
|
|
294
|
+
// 关不掉就算了,进程马上退出
|
|
295
|
+
}
|
|
282
296
|
process.stdin.setRawMode(false)
|
|
283
297
|
process.stdout.write(ansi.mouseOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
|
|
284
298
|
}
|
|
@@ -325,6 +339,10 @@ export async function startTUI(agent, opts = {}) {
|
|
|
325
339
|
const cols = process.stdout.columns || 80
|
|
326
340
|
const rows = process.stdout.rows || 24
|
|
327
341
|
const model = agent.provider.model
|
|
342
|
+
const thinking = agent.provider.thinking
|
|
343
|
+
const effort = agent.provider.reasoningEffort
|
|
344
|
+
const thinkBadge = thinking?.type === "disabled" ? "│ think: off"
|
|
345
|
+
: effort ? `│ think: ${effort}` : thinking?.type === "enabled" ? "│ think: on" : ""
|
|
328
346
|
|
|
329
347
|
// 输入区:全边框盒,宽度 W(所有输出行严格 ≤ cols-1,防自动折行错位)
|
|
330
348
|
const W = Math.max(20, cols - 1)
|
|
@@ -336,11 +354,37 @@ export async function startTUI(agent, opts = {}) {
|
|
|
336
354
|
inputOffset = Math.min(layout.cursorLine, layout.lines.length - MAX_INPUT_LINES)
|
|
337
355
|
}
|
|
338
356
|
const inputLines = layout.lines.slice(inputOffset, inputOffset + MAX_INPUT_LINES)
|
|
339
|
-
|
|
357
|
+
// question 模式下输入框显示选项/答案草稿,而不是普通输入(高度也要跟着走)
|
|
358
|
+
let boxLines = inputLines
|
|
359
|
+
if (state.question) {
|
|
360
|
+
const q = state.question
|
|
361
|
+
boxLines = q.options.length > 0
|
|
362
|
+
? q.options.map((opt, i) => (i === (q.selected ?? 0) ? "▸ " : " ") + opt)
|
|
363
|
+
: ["▸ " + (q.answer ?? "")]
|
|
364
|
+
}
|
|
365
|
+
const inputBoxH = boxLines.length + 2
|
|
340
366
|
|
|
341
367
|
const headerH = 1
|
|
342
368
|
const statusH = 1
|
|
343
|
-
|
|
369
|
+
// 浮层(模型选择器 / 初始配置向导)打开时,在对话区下方预留一块(标题 + 列表窗口)
|
|
370
|
+
const overlay = state.picker ?? state.wizard
|
|
371
|
+
const pickerH = overlay
|
|
372
|
+
? Math.min(overlay.lines.length + 1, Math.max(6, rows - 12))
|
|
373
|
+
: 0
|
|
374
|
+
// todo 面板:有任务列表时占对话区与输入框之间最多 5 行
|
|
375
|
+
// 折叠时优先 in_progress,兼顾最早的 pending 和最近的 done
|
|
376
|
+
const MAX_TASK_LINES = 5
|
|
377
|
+
let visibleTasks = []
|
|
378
|
+
if (state.tasks.length <= MAX_TASK_LINES) {
|
|
379
|
+
visibleTasks = state.tasks
|
|
380
|
+
} else {
|
|
381
|
+
const inProgress = state.tasks.filter((t) => t.status === "in_progress")
|
|
382
|
+
const pending = state.tasks.filter((t) => t.status === "pending")
|
|
383
|
+
const done = state.tasks.filter((t) => t.status === "done")
|
|
384
|
+
visibleTasks = [...inProgress, ...pending, ...done].slice(0, MAX_TASK_LINES)
|
|
385
|
+
}
|
|
386
|
+
const taskPanelH = visibleTasks.length
|
|
387
|
+
const convH = Math.max(1, rows - headerH - inputBoxH - statusH - pickerH - taskPanelH)
|
|
344
388
|
|
|
345
389
|
// 对话区内容行(含流式缓冲);markdown 表格先按显示宽度重排
|
|
346
390
|
const convLines = []
|
|
@@ -364,9 +408,10 @@ export async function startTUI(agent, opts = {}) {
|
|
|
364
408
|
}
|
|
365
409
|
}
|
|
366
410
|
}
|
|
367
|
-
//
|
|
368
|
-
|
|
369
|
-
|
|
411
|
+
// 工具实时输出(暗色,只保留末尾防刷屏;按工具名隔离防止并行工具串扰)
|
|
412
|
+
const allStreams = Object.values(state.toolStreams).join("")
|
|
413
|
+
if (allStreams) {
|
|
414
|
+
const tail = allStreams.slice(-4000)
|
|
370
415
|
for (const wrapped of wrapText(tail, cols - 1)) {
|
|
371
416
|
convLines.push({ text: wrapped, color: C.dim })
|
|
372
417
|
}
|
|
@@ -379,9 +424,9 @@ export async function startTUI(agent, opts = {}) {
|
|
|
379
424
|
|
|
380
425
|
const out = [ansi.home]
|
|
381
426
|
|
|
382
|
-
// header
|
|
427
|
+
// header(超宽截断,防终端折行)
|
|
383
428
|
out.push(
|
|
384
|
-
`${ansi.bold}${C.tool} ThinCoder ${ansi.reset}${ansi.dim}│ ${model} │ ${basename(agent.cwd)}${ansi.reset}${ansi.clearLine}`,
|
|
429
|
+
`${ansi.bold}${C.tool} ThinCoder ${ansi.reset}${ansi.dim}│ ${sliceByWidth(model, 30)}${thinkBadge ? " " + thinkBadge : ""} │ ${sliceByWidth(basename(agent.cwd), Math.max(10, cols - 60))}${ansi.reset}${ansi.clearLine}`,
|
|
385
430
|
)
|
|
386
431
|
|
|
387
432
|
// 对话区(不足部分补空行,把输入框钉在底部)
|
|
@@ -391,16 +436,53 @@ export async function startTUI(agent, opts = {}) {
|
|
|
391
436
|
out.push(`${l.color}${l.text}${ansi.reset}${ansi.clearLine}`)
|
|
392
437
|
}
|
|
393
438
|
|
|
439
|
+
// 浮层(模型选择器 / 初始配置向导):列表滚动跟随选中行
|
|
440
|
+
if (overlay) {
|
|
441
|
+
const winH = pickerH - 1
|
|
442
|
+
if (overlay.selectedLine < overlay.scroll) overlay.scroll = overlay.selectedLine
|
|
443
|
+
if (overlay.selectedLine >= overlay.scroll + winH) overlay.scroll = overlay.selectedLine - winH + 1
|
|
444
|
+
const start = Math.max(0, Math.min(overlay.scroll, Math.max(0, overlay.lines.length - winH)))
|
|
445
|
+
const shown = overlay.lines.slice(start, start + winH)
|
|
446
|
+
const overlayTitle = state.picker ? " ❯ 选择模型 " : " ❯ 初始配置 "
|
|
447
|
+
out.push(`${ansi.bold}${C.tool}${overlayTitle}${ansi.reset}${ansi.dim}${state.picker ? "(↑↓ 移动, Enter 确认, Esc 取消)" : ""}${ansi.reset}${ansi.clearLine}`)
|
|
448
|
+
for (const l of shown) {
|
|
449
|
+
out.push(`${l.color}${sliceByWidth(l.text, cols - 1)}${ansi.reset}${ansi.clearLine}`)
|
|
450
|
+
}
|
|
451
|
+
for (let i = shown.length; i < winH; i++) out.push(ansi.clearLine)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// todo 面板(对话区与输入框之间):▶ in_progress / ✓ done / ○ pending
|
|
455
|
+
for (const t of visibleTasks) {
|
|
456
|
+
const mark = t.status === "done" ? "✓" : t.status === "in_progress" ? "▶" : "○"
|
|
457
|
+
const color = t.status === "done" ? C.dim : t.status === "in_progress" ? C.tool : C.text
|
|
458
|
+
out.push(`${color} ${mark} ${sliceByWidth(t.title, cols - 4)}${ansi.reset}${ansi.clearLine}`)
|
|
459
|
+
}
|
|
460
|
+
|
|
394
461
|
// 输入框(全边框,宽 W)
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
462
|
+
let borderColor = C.tool
|
|
463
|
+
let title
|
|
464
|
+
if (state.question) {
|
|
465
|
+
borderColor = C.tool
|
|
466
|
+
title = ` ${sliceByWidth(state.question.text, W - 6)} `
|
|
467
|
+
} else if (state.permission) {
|
|
468
|
+
borderColor = C.warn
|
|
469
|
+
if (state.permission.name === "continue") {
|
|
470
|
+
title = " Continue? (y/n) "
|
|
471
|
+
} else {
|
|
472
|
+
title = ` Allow ${state.permission.name}? (y/n/a) `
|
|
473
|
+
}
|
|
474
|
+
} else if (state.picker) {
|
|
475
|
+
title = " Model "
|
|
476
|
+
} else if (state.wizard) {
|
|
477
|
+
title = " Setup "
|
|
478
|
+
} else if (state.processing) {
|
|
479
|
+
title = " Processing... "
|
|
480
|
+
} else {
|
|
481
|
+
title = " Input "
|
|
482
|
+
}
|
|
401
483
|
const topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 3 - stringWidth(title)))}╮`
|
|
402
484
|
out.push(`${borderColor}${topBorder}${ansi.reset}${ansi.clearLine}`)
|
|
403
|
-
for (const l of
|
|
485
|
+
for (const l of boxLines) {
|
|
404
486
|
const content = sliceByWidth(l, W - 4)
|
|
405
487
|
const fill = " ".repeat(Math.max(0, W - 4 - stringWidth(content)))
|
|
406
488
|
out.push(`${borderColor}│${ansi.reset} ${content}${fill} ${borderColor}│${ansi.reset}${ansi.clearLine}`)
|
|
@@ -411,12 +493,42 @@ export async function startTUI(agent, opts = {}) {
|
|
|
411
493
|
const scrollHint = state.scroll > 0 ? ` │ scrolled ${state.scroll}` : ""
|
|
412
494
|
const rawInput = state.input.join("")
|
|
413
495
|
let statusLine
|
|
414
|
-
if (
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
496
|
+
if (state.question) {
|
|
497
|
+
const q = state.question
|
|
498
|
+
statusLine = q.options.length > 0
|
|
499
|
+
? " ↑↓: 选择 │ Enter: 确认 │ Esc: 取消"
|
|
500
|
+
: " 输入回答后 Enter 提交 │ Esc: 取消"
|
|
501
|
+
} else if (state.permission) {
|
|
502
|
+
statusLine = state.permission.name === "continue"
|
|
503
|
+
? " y: 继续 │ n: 停止"
|
|
504
|
+
: " y: 批准 │ n: 拒绝 │ a: 批准并全部放行(AUTO)"
|
|
505
|
+
} else if (state.picker) {
|
|
506
|
+
statusLine = " ↑↓: 选择 │ Enter: 确认 │ Esc: 取消"
|
|
507
|
+
} else if (state.wizard) {
|
|
508
|
+
statusLine = state.wizard.step === "provider"
|
|
509
|
+
? " ↑↓: 选择 │ Enter: 确认 │ Esc: 跳过"
|
|
510
|
+
: " 输入后 Enter 确认 │ Esc: 取消"
|
|
511
|
+
} else if (rawInput.startsWith("/") && !state.processing && !state.permission) {
|
|
512
|
+
const [cmd, sub] = rawInput.split(/\s+/)
|
|
513
|
+
const cmds = SLASH_COMMANDS.filter((c) => c.name.startsWith(cmd))
|
|
514
|
+
const match = cmds.length === 1 ? cmds[0] : null
|
|
515
|
+
if (match?.name === "/config" && cmd === "/config") {
|
|
516
|
+
statusLine = " /config 查看 │ embedkey 配 embedding │ set 改参数"
|
|
517
|
+
} else if (match?.name === "/provider" && cmd === "/provider") {
|
|
518
|
+
statusLine = " /provider 列表 │ add / remove / key"
|
|
519
|
+
} else if (match?.name === "/model" && cmd === "/model" && !sub) {
|
|
520
|
+
statusLine = " /model 打开选择器 │ /model <名称> 直接切换"
|
|
521
|
+
} else if (match?.name === "/think" && cmd === "/think") {
|
|
522
|
+
statusLine = " /think 查看 │ on / off 开关 │ effort high / max 强度"
|
|
523
|
+
} else if (cmds.length > 0) {
|
|
524
|
+
if (cmds.length <= 4) {
|
|
525
|
+
statusLine = ` ${cmds.map((c) => `${c.name} ${c.desc}`).join(" │ ")}`
|
|
526
|
+
} else {
|
|
527
|
+
statusLine = ` ${cmds.map((c) => c.name).join(" ")} │ Tab 补全`
|
|
528
|
+
}
|
|
529
|
+
} else {
|
|
530
|
+
statusLine = ` 未知命令(/help 查看可用命令)`
|
|
531
|
+
}
|
|
420
532
|
} else {
|
|
421
533
|
const taskHint = state.tasks.length > 0
|
|
422
534
|
? ` │ ▶${state.tasks.filter((t) => t.status === "done").length}/${state.tasks.length}`
|
|
@@ -427,7 +539,12 @@ export async function startTUI(agent, opts = {}) {
|
|
|
427
539
|
statusLine = ` ${statusText}${taskHint}${scrollHint} │ Enter: send │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+C: exit`
|
|
428
540
|
}
|
|
429
541
|
const autoBanner = agent.autoApprove ? `${C.warn} AUTO${ansi.reset}${ansi.dim}│` : ""
|
|
430
|
-
|
|
542
|
+
const planBanner = agent.planMode ? `${C.tool} PLAN${ansi.reset}${ansi.dim}│` : ""
|
|
543
|
+
// 状态栏最多一行:终端宽度扣掉 banner 前缀的可视列数,防折行
|
|
544
|
+
const bannerPrefix = (agent.planMode ? " PLAN│ " : "") + (agent.autoApprove ? " AUTO│ " : "")
|
|
545
|
+
const statusMax = cols - 1 - (bannerPrefix ? stringWidth(bannerPrefix) : 0)
|
|
546
|
+
statusLine = sliceByWidth(statusLine, Math.max(10, statusMax))
|
|
547
|
+
out.push(`${ansi.dim}${planBanner}${autoBanner}${statusLine}${ansi.reset}${ansi.clearLine}`)
|
|
431
548
|
|
|
432
549
|
const frame = out.join("\r\n")
|
|
433
550
|
if (frame !== lastFrame) {
|
|
@@ -435,11 +552,11 @@ export async function startTUI(agent, opts = {}) {
|
|
|
435
552
|
process.stdout.write(frame)
|
|
436
553
|
}
|
|
437
554
|
|
|
438
|
-
// 光标:输入态定位到输入框内(IME
|
|
439
|
-
if (state.processing || state.permission) {
|
|
555
|
+
// 光标:输入态定位到输入框内(IME 候选框跟随真实光标);处理中/权限确认/菜单态时隐藏
|
|
556
|
+
if (state.processing || state.permission || state.question || state.picker || state.wizard?.step === "provider") {
|
|
440
557
|
process.stdout.write(ansi.hideCursor)
|
|
441
558
|
} else {
|
|
442
|
-
const cursorRow = 1 + convH + 2 + (layout.cursorLine - inputOffset) // header + 对话区 + 上边框 + 行偏移
|
|
559
|
+
const cursorRow = 1 + convH + taskPanelH + 2 + (layout.cursorLine - inputOffset) // header + 对话区 + todo 面板 + 上边框 + 行偏移
|
|
443
560
|
const cursorCol = 3 + layout.cursorCol // 左边框 + 空格 + 文本偏移(1 基)
|
|
444
561
|
process.stdout.write(`${ESC}[${cursorRow};${cursorCol}H${ansi.showCursor}`)
|
|
445
562
|
}
|
|
@@ -482,63 +599,105 @@ export async function startTUI(agent, opts = {}) {
|
|
|
482
599
|
state.reasoning = ""
|
|
483
600
|
state.currentTool = null
|
|
484
601
|
state.processingStarted = Date.now()
|
|
602
|
+
state.controller = new AbortController()
|
|
485
603
|
// 处理中每秒刷新一次状态栏(运行计时)
|
|
486
604
|
const ticker = setInterval(() => {
|
|
487
605
|
if (state.processing) render()
|
|
488
606
|
}, 1000)
|
|
489
607
|
render()
|
|
490
608
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
609
|
+
const callbacks = {
|
|
610
|
+
onToken: (t) => {
|
|
611
|
+
ensureAssistantLabel()
|
|
612
|
+
state.streaming += t
|
|
613
|
+
scheduleRender()
|
|
614
|
+
},
|
|
615
|
+
onReasoning: (t) => {
|
|
616
|
+
ensureAssistantLabel()
|
|
617
|
+
state.reasoning += t
|
|
618
|
+
scheduleRender()
|
|
619
|
+
},
|
|
620
|
+
onToolCall: (name, args) => {
|
|
621
|
+
flushStream()
|
|
622
|
+
ensureAssistantLabel()
|
|
623
|
+
state.currentTool = name
|
|
624
|
+
pushLine(` [tool] ${name} ${summarize(args)}`, C.tool)
|
|
625
|
+
},
|
|
626
|
+
onToolResult: (name, result) => {
|
|
627
|
+
state.currentTool = null
|
|
628
|
+
const stream = state.toolStreams[name]
|
|
629
|
+
if (stream) {
|
|
630
|
+
const tail = stream.trimEnd().slice(-4000)
|
|
631
|
+
if (tail) pushLine(tail, C.dim)
|
|
632
|
+
delete state.toolStreams[name]
|
|
633
|
+
}
|
|
634
|
+
const first = result.split("\n")[0]
|
|
635
|
+
pushLine(` [done] ${name} → ${sliceByWidth(first, 100)}`, C.dim)
|
|
636
|
+
},
|
|
637
|
+
onToolOutput: (name, chunk) => {
|
|
638
|
+
state.toolStreams[name] = (state.toolStreams[name] ?? "") + chunk
|
|
639
|
+
scheduleRender()
|
|
640
|
+
},
|
|
641
|
+
onPermissionRequest: (name, args) => askPermission(name, args),
|
|
642
|
+
onQuestion: (text, options) => askQuestion(text, options),
|
|
643
|
+
onCompress: () => {
|
|
644
|
+
pushLine(" [context] 上下文过长,已自动压缩(早期对话由 LLM 摘要,任务状态保留)", C.warn)
|
|
645
|
+
},
|
|
646
|
+
onTaskUpdate: (items) => {
|
|
647
|
+
state.tasks = items
|
|
648
|
+
const done = items.filter((i) => i.status === "done").length
|
|
649
|
+
pushLine(` [task] ${done}/${items.length}`, C.dim)
|
|
650
|
+
render()
|
|
651
|
+
},
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
for (let resume = false; ; resume = true) {
|
|
655
|
+
try {
|
|
656
|
+
await runAgent(agent, text, callbacks, { signal: state.controller.signal, resume })
|
|
657
|
+
flushStream()
|
|
658
|
+
break // 正常完成,退出循环
|
|
659
|
+
} catch (error) {
|
|
660
|
+
flushStream()
|
|
661
|
+
if (error.name === "AbortError" || state.controller?.signal.aborted) {
|
|
662
|
+
pushLine("[已中止]", C.warn)
|
|
663
|
+
break
|
|
664
|
+
}
|
|
665
|
+
if (error instanceof ContinueError) {
|
|
666
|
+
pushLabel(`❯ Continue`, ansi.bold + C.warn)
|
|
667
|
+
pushLine(`已执行 ${error.turn} 轮(上限 ${error.turn}),要继续吗?`, C.warn)
|
|
668
|
+
// 暂停询问:复用 permission 机制
|
|
669
|
+
const willContinue = await new Promise((resolve) => {
|
|
670
|
+
state.permission = {
|
|
671
|
+
name: "continue",
|
|
672
|
+
args: { turns: error.turn },
|
|
673
|
+
resolve,
|
|
674
|
+
}
|
|
675
|
+
state.status = `Continue after ${error.turn} turns?`
|
|
676
|
+
render()
|
|
677
|
+
})
|
|
678
|
+
state.permission = null
|
|
679
|
+
if (!willContinue) {
|
|
680
|
+
pushLine("[已取消继续]", C.warn)
|
|
681
|
+
break
|
|
516
682
|
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
onTaskUpdate: (items) => {
|
|
526
|
-
state.tasks = items
|
|
527
|
-
const done = items.filter((i) => i.status === "done").length
|
|
528
|
-
pushLine(` [task] ${done}/${items.length}`, C.dim)
|
|
529
|
-
render()
|
|
530
|
-
},
|
|
531
|
-
})
|
|
532
|
-
flushStream()
|
|
533
|
-
} catch (error) {
|
|
534
|
-
flushStream()
|
|
535
|
-
pushLine(`[error] ${error.message}`, C.error)
|
|
536
|
-
} finally {
|
|
537
|
-
clearInterval(ticker)
|
|
683
|
+
pushLine("[继续执行…]", C.tool)
|
|
684
|
+
// 重创新 AbortController:旧 signal 一旦 abort 过,resume 会立即失败(防御性,当前路径不可达但耦合紧)
|
|
685
|
+
state.controller = new AbortController()
|
|
686
|
+
continue
|
|
687
|
+
}
|
|
688
|
+
pushLine(`[error] ${error.message}`, C.error)
|
|
689
|
+
break
|
|
690
|
+
}
|
|
538
691
|
}
|
|
539
692
|
|
|
693
|
+
clearInterval(ticker)
|
|
540
694
|
state.processing = false
|
|
695
|
+
state.controller = null
|
|
541
696
|
state.status = "Ready"
|
|
697
|
+
// 全部完成时自动收起 todo 面板(对齐 kimi-code TUI;agent.tasks 本身保留)
|
|
698
|
+
if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
|
|
699
|
+
state.tasks = []
|
|
700
|
+
}
|
|
542
701
|
// 每轮结束后保存会话(崩溃也不丢)
|
|
543
702
|
try {
|
|
544
703
|
saveSession(agent)
|
|
@@ -565,6 +724,9 @@ export async function startTUI(agent, opts = {}) {
|
|
|
565
724
|
pushLine(` [auto] ${name} ${summarize(args)}`, C.warn)
|
|
566
725
|
return Promise.resolve(true)
|
|
567
726
|
}
|
|
727
|
+
// 把关键参数摆出来:批什么要让人看明白
|
|
728
|
+
pushLabel(`❯ 权限请求`, ansi.bold + C.warn)
|
|
729
|
+
for (const line of formatPermission(name, args)) pushLine(` ${line}`, C.text)
|
|
568
730
|
return new Promise((resolve) => {
|
|
569
731
|
state.permission = { name, args, resolve }
|
|
570
732
|
state.status = `Waiting: ${name}`
|
|
@@ -572,19 +734,62 @@ export async function startTUI(agent, opts = {}) {
|
|
|
572
734
|
})
|
|
573
735
|
}
|
|
574
736
|
|
|
737
|
+
/** 权限请求的关键信息(按工具定制),返回行数组 */
|
|
738
|
+
function formatPermission(name, args) {
|
|
739
|
+
const cap = (s, n = 1000) => (s.length > n ? `${s.slice(0, n)}…(共 ${s.length} 字符)` : s)
|
|
740
|
+
if (name === "bash") return cap(args.command ?? "").split("\n")
|
|
741
|
+
if (name === "write") return [`${args.path}(写入 ${(args.content ?? "").length} 字符)`]
|
|
742
|
+
if (name === "edit") return [`${args.path}(替换 ${(args.old_string ?? "").length} 字符 → ${(args.new_string ?? "").length} 字符)`]
|
|
743
|
+
if (name === "subagent") return cap(args.task ?? "", 500).split("\n")
|
|
744
|
+
if (name === "memory_put") return [`[${args.type ?? ""}] ${args.title ?? ""}`]
|
|
745
|
+
return [cap(summarize(args), 300)]
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function askQuestion(text, options = []) {
|
|
749
|
+
// 一次只能问一个:question 是只读工具走并行通道,同批第二个直接驳回,
|
|
750
|
+
// 否则后到的会覆盖 state.question,先到的 Promise 永远悬挂(agent 死等)
|
|
751
|
+
if (state.question) {
|
|
752
|
+
return Promise.resolve("(error: 已有问题在等待回答;请一次只问一个,得到答复后再问下一个)")
|
|
753
|
+
}
|
|
754
|
+
if (!options.length) {
|
|
755
|
+
// 自由文本:打开输入态让用户打字,Enter 提交
|
|
756
|
+
pushLabel(`❯ Question`, ansi.bold + C.tool)
|
|
757
|
+
for (const line of text.split("\n")) pushLine(` ${line}`, C.text)
|
|
758
|
+
return new Promise((resolve) => {
|
|
759
|
+
state.question = { text, options: [], resolve }
|
|
760
|
+
state.status = "Waiting for answer..."
|
|
761
|
+
render()
|
|
762
|
+
})
|
|
763
|
+
}
|
|
764
|
+
// 选项模式:输入框内显示列表,方向键选,Enter 确认
|
|
765
|
+
pushLabel(`❯ Question`, ansi.bold + C.tool)
|
|
766
|
+
for (const line of text.split("\n")) pushLine(` ${line}`, C.text)
|
|
767
|
+
return new Promise((resolve) => {
|
|
768
|
+
state.question = { text, options, selected: 0, resolve }
|
|
769
|
+
state.status = "Waiting for choice..."
|
|
770
|
+
render()
|
|
771
|
+
})
|
|
772
|
+
}
|
|
773
|
+
|
|
575
774
|
// ---------------------------------------------------------- 斜杠命令
|
|
576
775
|
|
|
577
776
|
const SLASH_COMMANDS = [
|
|
578
|
-
{ name: "/
|
|
579
|
-
{ name: "/
|
|
580
|
-
{ name: "/
|
|
581
|
-
{ name: "/
|
|
582
|
-
{ name: "/
|
|
583
|
-
{ name: "/
|
|
584
|
-
{ name: "/
|
|
585
|
-
{ name: "/
|
|
586
|
-
{ name: "/
|
|
587
|
-
{ 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: "此列表" },
|
|
588
793
|
]
|
|
589
794
|
|
|
590
795
|
async function handleSlash(text) {
|
|
@@ -598,6 +803,9 @@ export async function startTUI(agent, opts = {}) {
|
|
|
598
803
|
case "/new":
|
|
599
804
|
agent.history = []
|
|
600
805
|
agent.tasks = []
|
|
806
|
+
agent.planMode = false
|
|
807
|
+
agent.goal = null
|
|
808
|
+
agent._pendingReminders = []
|
|
601
809
|
state.tasks = []
|
|
602
810
|
state.lines = []
|
|
603
811
|
state.streaming = ""
|
|
@@ -606,7 +814,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
606
814
|
return
|
|
607
815
|
case "/exit":
|
|
608
816
|
cleanup()
|
|
609
|
-
process.exit(0)
|
|
817
|
+
setTimeout(() => process.exit(0), 100) // 延迟一拍:fetch 后立刻 exit 在 Windows/Node 24 会触发 libuv 断言
|
|
610
818
|
return
|
|
611
819
|
case "/reindex": {
|
|
612
820
|
const { syncDir } = await import("./memory.mjs")
|
|
@@ -658,8 +866,200 @@ export async function startTUI(agent, opts = {}) {
|
|
|
658
866
|
}
|
|
659
867
|
return
|
|
660
868
|
}
|
|
869
|
+
case "/plan": {
|
|
870
|
+
agent.planMode = !agent.planMode
|
|
871
|
+
agent._pendingReminders = agent._pendingReminders ?? []
|
|
872
|
+
if (agent.planMode) {
|
|
873
|
+
agent._pendingReminders.push("[System reminder: plan mode is now ON. You are restricted to READ-ONLY tools — explore, search, read, analyze. DO NOT write, edit, or run mutation commands. Present your design to the user first.]")
|
|
874
|
+
} else {
|
|
875
|
+
agent._pendingReminders.push("[System reminder: plan mode is now OFF. You may edit files, run commands, and implement changes.]")
|
|
876
|
+
}
|
|
877
|
+
pushLabel(`❯ Plan`, ansi.bold + (agent.planMode ? C.tool : C.dim))
|
|
878
|
+
pushLine(
|
|
879
|
+
agent.planMode
|
|
880
|
+
? `规划模式已开启:只读工具受限,先设计方案再实现。再次 /plan 退出。`
|
|
881
|
+
: `规划模式已关闭:可以编辑文件和执行命令了。`,
|
|
882
|
+
agent.planMode ? C.tool : C.dim,
|
|
883
|
+
)
|
|
884
|
+
return
|
|
885
|
+
}
|
|
886
|
+
case "/goal": {
|
|
887
|
+
const sub = rest[0]
|
|
888
|
+
if (sub === "set") {
|
|
889
|
+
const text = rest.slice(1).join(" ")
|
|
890
|
+
if (!text) { pushLine("用法: /goal set <目标描述>(; 分隔完成条件)", C.error); return }
|
|
891
|
+
const semi = text.indexOf(";") >= 0 ? ";" : text.indexOf(";") >= 0 ? ";" : null
|
|
892
|
+
const objective = semi ? text.slice(0, semi).trim() : text.trim()
|
|
893
|
+
const criteria = semi ? text.slice(semi + 1).trim() : ""
|
|
894
|
+
agent.goal = { objective, criteria, setAt: Date.now() }
|
|
895
|
+
pushLabel(`❯ Goal`, ansi.bold + C.warn)
|
|
896
|
+
pushLine(`目标已设置: ${objective}`, C.tool)
|
|
897
|
+
if (criteria) pushLine(` 完成条件: ${criteria}`, C.dim)
|
|
898
|
+
return
|
|
899
|
+
}
|
|
900
|
+
if (sub === "cancel") {
|
|
901
|
+
agent.goal = null
|
|
902
|
+
pushLabel(`❯ Goal`, ansi.bold + C.dim)
|
|
903
|
+
pushLine(`目标已取消。`, C.dim)
|
|
904
|
+
return
|
|
905
|
+
}
|
|
906
|
+
if (agent.goal) {
|
|
907
|
+
pushLabel(`❯ Goal`, ansi.bold + C.warn)
|
|
908
|
+
pushLine(`目标: ${agent.goal.objective}`, C.tool)
|
|
909
|
+
if (agent.goal.criteria) pushLine(` 完成条件: ${agent.goal.criteria}`, C.dim)
|
|
910
|
+
pushLine(` 设置于: ${new Date(agent.goal.setAt).toLocaleString()}`, C.dim)
|
|
911
|
+
pushLine("操作: /goal set <描述> 覆盖 | /goal cancel 取消", C.dim)
|
|
912
|
+
} else {
|
|
913
|
+
pushLabel(`❯ Goal`, ansi.bold + C.dim)
|
|
914
|
+
pushLine("(无活跃目标——/goal set <描述> 设置)", C.dim)
|
|
915
|
+
}
|
|
916
|
+
return
|
|
917
|
+
}
|
|
918
|
+
case "/skills": {
|
|
919
|
+
const { loadSkills } = await import("./skills.mjs")
|
|
920
|
+
const skills = await loadSkills(agent.cwd)
|
|
921
|
+
pushLabel(`❯ Skills`, ansi.bold + C.tool)
|
|
922
|
+
if (skills.length === 0) {
|
|
923
|
+
pushLine("(无项目技能——在 .thincoder/skills/ 下创建 .md 文件即可添加)", C.dim)
|
|
924
|
+
}
|
|
925
|
+
for (const s of skills) {
|
|
926
|
+
pushLine(` ${s.name}: ${s.description.slice(0, 100)}`, C.dim)
|
|
927
|
+
}
|
|
928
|
+
pushLine("激活: 告诉 agent \"load the <name> skill\"", C.dim)
|
|
929
|
+
return
|
|
930
|
+
}
|
|
931
|
+
case "/mcp": {
|
|
932
|
+
const sub = rest[0]
|
|
933
|
+
// ---- /mcp list — 列出配置的 servers + 连接状态 ----
|
|
934
|
+
if (!sub || sub === "list") {
|
|
935
|
+
const servers = agent.config?.mcp?.servers ?? []
|
|
936
|
+
pushLabel(`❯ MCP Servers`, ansi.bold + C.tool)
|
|
937
|
+
if (servers.length === 0) {
|
|
938
|
+
pushLine("(无 MCP server——使用 /mcp add <name> <command> [args] 添加)", C.dim)
|
|
939
|
+
}
|
|
940
|
+
for (const srv of servers) {
|
|
941
|
+
const connected = agent.tools.some((t) => t._mcpName === srv.name)
|
|
942
|
+
const mark = connected ? "●" : "○"
|
|
943
|
+
const color = connected ? C.tool : C.dim
|
|
944
|
+
const toolCount = agent.tools.filter((t) => t._mcpName === srv.name).length
|
|
945
|
+
const desc = srv.url ? srv.url : `${srv.command} ${(srv.args ?? []).join(" ")}`
|
|
946
|
+
pushLine(` ${mark} ${srv.name}: ${desc} (${toolCount} tools)`, color)
|
|
947
|
+
}
|
|
948
|
+
pushLabel(`❯ 操作`, ansi.bold + C.tool)
|
|
949
|
+
pushLine(`/mcp add <name> <command> [args...] 添加 stdio server`, C.dim)
|
|
950
|
+
pushLine(`/mcp url <name> <url> [headers...] 添加 HTTP server`, C.dim)
|
|
951
|
+
pushLine(`/mcp remove <name> 断开并移除 server`, C.dim)
|
|
952
|
+
pushLine(`/mcp connect <name> 重连已配置的 server`, C.dim)
|
|
953
|
+
pushLine("配置持久化到 config.json 的 mcp.servers[]", C.dim)
|
|
954
|
+
return
|
|
955
|
+
}
|
|
956
|
+
// ---- /mcp add <name> <command> [args...] (stdio) ----
|
|
957
|
+
if (sub === "add") {
|
|
958
|
+
const args = rest.slice(1)
|
|
959
|
+
if (args.length < 2) {
|
|
960
|
+
pushLine("用法: /mcp add <name> <command> [args...]", C.error)
|
|
961
|
+
pushLine(" 例: /mcp add github npx -y @modelcontextprotocol/server-github", C.dim)
|
|
962
|
+
return
|
|
963
|
+
}
|
|
964
|
+
const name = args[0]
|
|
965
|
+
const command = args[1]
|
|
966
|
+
const cmdArgs = args.slice(2)
|
|
967
|
+
const existing = (agent.config?.mcp?.servers ?? []).find((s) => s.name === name)
|
|
968
|
+
if (existing) { pushLine(`[mcp] "${name}" 已存在,用 /mcp remove ${name} 先移除`, C.error); return }
|
|
969
|
+
const srv = { name, command, args: cmdArgs.length > 0 ? cmdArgs : undefined }
|
|
970
|
+
await addAndConnect(srv)
|
|
971
|
+
return
|
|
972
|
+
}
|
|
973
|
+
// ---- /mcp url <name> <url> [key=value...] (HTTP) ----
|
|
974
|
+
if (sub === "url") {
|
|
975
|
+
const args = rest.slice(1)
|
|
976
|
+
if (args.length < 2) {
|
|
977
|
+
pushLine("用法: /mcp url <name> <url> [header=value...]", C.error)
|
|
978
|
+
pushLine(" 例: /mcp url myapi https://api.example.com/mcp Authorization=\"Bearer token123\"", C.dim)
|
|
979
|
+
return
|
|
980
|
+
}
|
|
981
|
+
const name = args[0]
|
|
982
|
+
const url = args[1]
|
|
983
|
+
const headerPairs = args.slice(2)
|
|
984
|
+
const existing = (agent.config?.mcp?.servers ?? []).find((s) => s.name === name)
|
|
985
|
+
if (existing) { pushLine(`[mcp] "${name}" 已存在,用 /mcp remove ${name} 先移除`, C.error); return }
|
|
986
|
+
const headers = {}
|
|
987
|
+
for (const pair of headerPairs) {
|
|
988
|
+
const eq = pair.indexOf("=")
|
|
989
|
+
if (eq > 0) headers[pair.slice(0, eq)] = pair.slice(eq + 1).replace(/^["']|["']$/g, "")
|
|
990
|
+
}
|
|
991
|
+
const srv = { name, url, headers: Object.keys(headers).length > 0 ? headers : undefined }
|
|
992
|
+
await addAndConnect(srv)
|
|
993
|
+
return
|
|
994
|
+
}
|
|
995
|
+
// ---- /mcp remove <name> ----
|
|
996
|
+
if (sub === "remove") {
|
|
997
|
+
const name = rest[1]
|
|
998
|
+
if (!name) { pushLine("用法: /mcp remove <name>", C.error); return }
|
|
999
|
+
const { removeMcpTools } = await import("./mcp.mjs")
|
|
1000
|
+
removeMcpTools(agent, name)
|
|
1001
|
+
await persistRaw((raw) => { raw.mcp ??= { servers: [] }; raw.mcp.servers = raw.mcp.servers.filter((s) => s.name !== name) })
|
|
1002
|
+
if (agent.config?.mcp?.servers) agent.config.mcp.servers = agent.config.mcp.servers.filter((s) => s.name !== name)
|
|
1003
|
+
pushLabel(`❯ MCP`, ansi.bold + C.tool)
|
|
1004
|
+
pushLine(`${name} 已断开并从配置移除。`, C.tool)
|
|
1005
|
+
return
|
|
1006
|
+
}
|
|
1007
|
+
// ---- /mcp connect <name> — 重连 ----
|
|
1008
|
+
if (sub === "connect") {
|
|
1009
|
+
const name = rest[1]
|
|
1010
|
+
if (!name) { pushLine("用法: /mcp connect <name>", C.error); return }
|
|
1011
|
+
const srv = (agent.config?.mcp?.servers ?? []).find((s) => s.name === name)
|
|
1012
|
+
if (!srv) { pushLine(`[mcp] "${name}" 未在配置中找到(先用 /mcp add 或 /mcp url)`, C.error); return }
|
|
1013
|
+
const { removeMcpTools, connectMcpServer } = await import("./mcp.mjs")
|
|
1014
|
+
removeMcpTools(agent, name)
|
|
1015
|
+
try {
|
|
1016
|
+
pushLine(`[mcp] 重连 ${name}...`, C.dim)
|
|
1017
|
+
const tools = await connectMcpServer(srv)
|
|
1018
|
+
agent.tools.push(...tools)
|
|
1019
|
+
pushLabel(`❯ MCP`, ansi.bold + C.tool)
|
|
1020
|
+
pushLine(`${name} 已重连,${tools.length} 个工具可用。`, C.tool)
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
pushLine(`[mcp] ${name}: ${error.message}`, C.error)
|
|
1023
|
+
}
|
|
1024
|
+
return
|
|
1025
|
+
}
|
|
1026
|
+
pushLine(`未知子命令: ${sub}(/mcp list | add | url | remove | connect)`, C.error)
|
|
1027
|
+
return
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// ---- /mcp 共享 helper: 保存配置 + 连接 ----
|
|
1031
|
+
async function addAndConnect(srv) {
|
|
1032
|
+
await persistRaw((raw) => {
|
|
1033
|
+
raw.mcp ??= { servers: [] }
|
|
1034
|
+
const entry = { name: srv.name }
|
|
1035
|
+
if (srv.url) { entry.url = srv.url; if (srv.headers) entry.headers = srv.headers }
|
|
1036
|
+
else { entry.command = srv.command; if (srv.args) entry.args = srv.args }
|
|
1037
|
+
raw.mcp.servers.push(entry)
|
|
1038
|
+
})
|
|
1039
|
+
agent.config ??= {}
|
|
1040
|
+
agent.config.mcp ??= { servers: [] }
|
|
1041
|
+
agent.config.mcp.servers.push(srv)
|
|
1042
|
+
try {
|
|
1043
|
+
pushLine(`[mcp] 连接 ${srv.name}...`, C.dim)
|
|
1044
|
+
const { connectMcpServer } = await import("./mcp.mjs")
|
|
1045
|
+
const tools = await connectMcpServer(srv)
|
|
1046
|
+
agent.tools.push(...tools)
|
|
1047
|
+
pushLabel(`❯ MCP`, ansi.bold + C.tool)
|
|
1048
|
+
const desc = srv.url ? srv.url : `${srv.command} ${(srv.args ?? []).join(" ")}`
|
|
1049
|
+
pushLine(`${srv.name} (${desc}) 已连接,${tools.length} 个工具:`, C.tool)
|
|
1050
|
+
for (const t of tools) pushLine(` ${t.name}: ${t.description.slice(0, 100)}`, C.dim)
|
|
1051
|
+
} catch (error) {
|
|
1052
|
+
pushLine(`[mcp] ${srv.name}: ${error.message}(配置已保存,重启后重试)`, C.error)
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
661
1055
|
case "/auto":
|
|
662
1056
|
agent.autoApprove = !agent.autoApprove
|
|
1057
|
+
agent._pendingReminders = agent._pendingReminders ?? []
|
|
1058
|
+
if (agent.autoApprove) {
|
|
1059
|
+
agent._pendingReminders.push("[System reminder: AUTO mode is now ON. All tool calls are automatically approved — you may write, edit, and run commands without asking. Use this for long autonomous tasks. The user can still interrupt.]")
|
|
1060
|
+
} else {
|
|
1061
|
+
agent._pendingReminders.push("[System reminder: AUTO mode is now OFF. Destructive tool calls now require user approval again. Confirm before writing files, running commands, or spawning subagents.]")
|
|
1062
|
+
}
|
|
663
1063
|
pushLabel(`❯ Auto`, ansi.bold + (agent.autoApprove ? C.warn : C.tool))
|
|
664
1064
|
pushLine(
|
|
665
1065
|
agent.autoApprove
|
|
@@ -668,53 +1068,247 @@ export async function startTUI(agent, opts = {}) {
|
|
|
668
1068
|
agent.autoApprove ? C.warn : C.dim,
|
|
669
1069
|
)
|
|
670
1070
|
return
|
|
1071
|
+
case "/think": {
|
|
1072
|
+
const sub = rest[0]
|
|
1073
|
+
const cur = agent.provider
|
|
1074
|
+
const thinkingEnabled = cur.thinking?.type === "enabled" || cur.thinking?.type === undefined
|
|
1075
|
+
// ---- /think(无参): 查看状态 ----
|
|
1076
|
+
if (!sub) {
|
|
1077
|
+
pushLabel(`❯ Think`, ansi.bold + C.tool)
|
|
1078
|
+
pushLine(`思维模式: ${thinkingEnabled ? "🟢 开启" : "⚫ 关闭"}`, C.dim)
|
|
1079
|
+
pushLine(`推理强度: ${cur.reasoningEffort ?? "(未设置)"}`, C.dim)
|
|
1080
|
+
pushLine(`切换: /think on | off | effort high | effort max`, C.dim)
|
|
1081
|
+
return
|
|
1082
|
+
}
|
|
1083
|
+
// ---- /think on / off ----
|
|
1084
|
+
if (sub === "on" || sub === "off") {
|
|
1085
|
+
const enable = sub === "on"
|
|
1086
|
+
cur.thinking = enable ? { type: "enabled" } : { type: "disabled" }
|
|
1087
|
+
if (!enable) delete cur.reasoningEffort
|
|
1088
|
+
else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
|
|
1089
|
+
await syncProviderField("thinking", cur.thinking)
|
|
1090
|
+
if (!enable) await syncProviderField("reasoningEffort", undefined)
|
|
1091
|
+
else await syncProviderField("reasoningEffort", cur.reasoningEffort)
|
|
1092
|
+
pushLabel(`❯ Think`, ansi.bold + C.tool)
|
|
1093
|
+
pushLine(`思维模式已${enable ? "开启" : "关闭"}`, C.tool)
|
|
1094
|
+
if (enable) pushLine(`推理强度: ${cur.reasoningEffort}`, C.dim)
|
|
1095
|
+
return
|
|
1096
|
+
}
|
|
1097
|
+
// ---- /think effort <level> ----
|
|
1098
|
+
if (sub === "effort") {
|
|
1099
|
+
const level = rest[1]
|
|
1100
|
+
if (!level || !["low", "high", "max"].includes(level)) {
|
|
1101
|
+
pushLine("用法: /think effort low | high | max", C.error)
|
|
1102
|
+
return
|
|
1103
|
+
}
|
|
1104
|
+
cur.reasoningEffort = level
|
|
1105
|
+
await syncProviderField("reasoningEffort", level)
|
|
1106
|
+
pushLabel(`❯ Think`, ansi.bold + C.tool)
|
|
1107
|
+
pushLine(`推理强度已设为 ${level}`, C.tool)
|
|
1108
|
+
return
|
|
1109
|
+
}
|
|
1110
|
+
pushLine(`未知参数: ${sub}(可用: on / off / effort / effort high|max)`, C.error)
|
|
1111
|
+
return
|
|
1112
|
+
}
|
|
671
1113
|
case "/model": {
|
|
672
1114
|
const arg = rest[0]
|
|
673
1115
|
if (!arg) {
|
|
1116
|
+
// 打开交互选择器:全部 provider 的全部模型,方向键选择
|
|
674
1117
|
pushLabel(`❯ Model`, ansi.bold + C.tool)
|
|
675
|
-
pushLine(
|
|
676
|
-
pushLine(
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
1118
|
+
pushLine(`/model <名称> 直接切换 provider 或模型(如 /model deepseek-v4-pro)`, C.dim)
|
|
1119
|
+
pushLine(`/provider 管理 provider(添加/删除/配 key)`, C.dim)
|
|
1120
|
+
openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
1121
|
+
return
|
|
1122
|
+
}
|
|
1123
|
+
if (arg === "add" || arg === "--add") {
|
|
1124
|
+
pushLine(`添加 provider 已移到 /provider add(/provider 查看全部管理命令)`, C.warn)
|
|
1125
|
+
return
|
|
1126
|
+
}
|
|
1127
|
+
// 一个参数两种含义:先按 provider 名匹配,匹配不到就当模型名改当前 provider
|
|
1128
|
+
const { resolveCompactThreshold } = await import("./config.mjs")
|
|
1129
|
+
const p = agent.providers.find((pp) => pp.name === arg)
|
|
1130
|
+
const newModel = p ? p.model : arg
|
|
1131
|
+
let thresholdNote = ""
|
|
1132
|
+
if (agent.config?.agent?.compactThresholdAuto) {
|
|
1133
|
+
const { value } = resolveCompactThreshold(null, newModel)
|
|
1134
|
+
agent.config.agent.compactThreshold = value
|
|
1135
|
+
thresholdNote = `,压缩阈值随模型调整为 ${value}`
|
|
1136
|
+
}
|
|
1137
|
+
if (p) {
|
|
1138
|
+
agent.activeProvider = arg
|
|
1139
|
+
agent.provider = { ...p }
|
|
1140
|
+
// key 的环境变量兜底和 loadConfig 保持一致(提供商专用变量只对同名生效)
|
|
1141
|
+
if (!agent.provider.apiKey) {
|
|
1142
|
+
const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[arg]
|
|
1143
|
+
if (envKey && process.env[envKey]) agent.provider.apiKey = process.env[envKey]
|
|
689
1144
|
}
|
|
1145
|
+
if (!agent.provider.apiKey) agent.provider.apiKey = process.env.THINCODER_API_KEY
|
|
1146
|
+
await persistRaw((raw) => { raw.activeProvider = arg })
|
|
1147
|
+
agent.config.activeProvider = arg
|
|
1148
|
+
pushLabel(`❯ Model`, ansi.bold + C.tool)
|
|
1149
|
+
pushLine(`已切换到 ${arg} / ${p.model}${thresholdNote}(已持久化)`, C.tool)
|
|
1150
|
+
if (!agent.provider.apiKey) pushLine(`该 provider 还没配 key: /provider key <apikey>`, C.warn)
|
|
690
1151
|
} else {
|
|
1152
|
+
const target = agent.providers.find((pp) => pp.name === agent.activeProvider) ?? agent.providers[0]
|
|
1153
|
+
if (target) target.model = arg
|
|
691
1154
|
agent.provider.model = arg
|
|
692
|
-
|
|
693
|
-
let thresholdNote = ""
|
|
694
|
-
if (agent.config?.agent?.compactThresholdAuto) {
|
|
695
|
-
const { resolveCompactThreshold } = await import("./config.mjs")
|
|
696
|
-
const { value } = resolveCompactThreshold(null, arg)
|
|
697
|
-
agent.config.agent.compactThreshold = value
|
|
698
|
-
thresholdNote = `,压缩阈值随模型调整为 ${value}`
|
|
699
|
-
}
|
|
1155
|
+
await persistRaw((raw) => { raw.providers = agent.providers })
|
|
700
1156
|
pushLabel(`❯ Model`, ansi.bold + C.tool)
|
|
701
|
-
pushLine(
|
|
1157
|
+
pushLine(`已将 ${target?.name ?? agent.activeProvider} 的模型改为 ${arg}${thresholdNote}(已持久化)`, C.tool)
|
|
1158
|
+
}
|
|
1159
|
+
return
|
|
1160
|
+
}
|
|
1161
|
+
case "/provider": {
|
|
1162
|
+
const sub = rest[0]
|
|
1163
|
+
// ---- /provider add <名称> <baseURL> <模型>,或 /provider add <预设> ----
|
|
1164
|
+
if (sub === "add") {
|
|
1165
|
+
const name = rest[1]
|
|
1166
|
+
if (!name) {
|
|
1167
|
+
pushLine(`用法: /provider add <名称> <baseURL> <模型>,或 /provider add <预设>(${Object.keys(PRESETS).join(", ")})`, C.error)
|
|
1168
|
+
return
|
|
1169
|
+
}
|
|
1170
|
+
if (agent.providers.some((p) => p.name === name)) {
|
|
1171
|
+
pushLine(`"${name}" 已存在;要重建可先 /provider remove ${name}`, C.warn)
|
|
1172
|
+
return
|
|
1173
|
+
}
|
|
1174
|
+
const preset = PRESETS[name]
|
|
1175
|
+
const baseURL = (rest[2] ?? preset?.baseURL)?.replace(/\/+$/, "")
|
|
1176
|
+
const model = rest[3] ?? preset?.model
|
|
1177
|
+
if (!baseURL || !model) {
|
|
1178
|
+
pushLine(`缺少参数: /provider add ${name} <baseURL> <模型>`, C.error)
|
|
1179
|
+
if (!preset) pushLine(`("${name}" 不是预设;预设: ${Object.keys(PRESETS).join(", ")})`, C.dim)
|
|
1180
|
+
return
|
|
1181
|
+
}
|
|
1182
|
+
if (!/^https?:\/\//.test(baseURL)) { pushLine(`baseURL 应以 http(s):// 开头`, C.error); return }
|
|
1183
|
+
agent.providers.push({ name, baseURL, model, ...(preset?.desc ? { desc: preset.desc } : {}) })
|
|
1184
|
+
await persistRaw((raw) => { raw.providers = agent.providers })
|
|
1185
|
+
pushLabel(`❯ Provider`, ansi.bold + C.tool)
|
|
1186
|
+
pushLine(`已添加 ${name}(${baseURL} / ${model})`, C.tool)
|
|
1187
|
+
pushLine(`下一步: /provider key ${name} <apikey> 配 key,/model ${name} 切换`, C.dim)
|
|
1188
|
+
return
|
|
1189
|
+
}
|
|
1190
|
+
// ---- /provider remove <名称> ----
|
|
1191
|
+
if (sub === "remove" || sub === "rm") {
|
|
1192
|
+
const name = rest[1]
|
|
1193
|
+
if (!name) { pushLine("用法: /provider remove <名称>", C.error); return }
|
|
1194
|
+
const at = agent.providers.findIndex((p) => p.name === name)
|
|
1195
|
+
if (at < 0) { pushLine(`未找到 provider "${name}"`, C.error); return }
|
|
1196
|
+
if (name === agent.activeProvider) { pushLine(`"${name}" 正在使用中,先 /model 切换到别的 provider 再删`, C.warn); return }
|
|
1197
|
+
agent.providers.splice(at, 1)
|
|
1198
|
+
await persistRaw((raw) => { raw.providers = agent.providers })
|
|
1199
|
+
pushLabel(`❯ Provider`, ansi.bold + C.tool)
|
|
1200
|
+
pushLine(`已删除 ${name}`, C.tool)
|
|
1201
|
+
return
|
|
702
1202
|
}
|
|
1203
|
+
// ---- /provider key [名称] <apikey> ----
|
|
1204
|
+
if (sub === "key") {
|
|
1205
|
+
let name = agent.activeProvider
|
|
1206
|
+
let keyParts = rest.slice(1)
|
|
1207
|
+
if (rest[1] && agent.providers.some((p) => p.name === rest[1])) {
|
|
1208
|
+
name = rest[1]
|
|
1209
|
+
keyParts = rest.slice(2)
|
|
1210
|
+
}
|
|
1211
|
+
const key = keyParts.join(" ")
|
|
1212
|
+
if (!key) { pushLine("用法: /provider key [名称] <apikey>(不填名称配当前 provider)", C.error); return }
|
|
1213
|
+
await setProviderKey(name, key)
|
|
1214
|
+
return
|
|
1215
|
+
}
|
|
1216
|
+
if (sub) { pushLine(`未知: ${sub}(/provider add | remove | key)`, C.error); return }
|
|
1217
|
+
// ---- /provider(无参): 列表 ----
|
|
1218
|
+
pushLabel(`❯ Providers (${agent.providers.length})`, ansi.bold + C.tool)
|
|
1219
|
+
for (const p of agent.providers) {
|
|
1220
|
+
const active = p.name === agent.activeProvider
|
|
1221
|
+
pushLine(
|
|
1222
|
+
`${active ? " ▸" : " "} ${p.name.padEnd(12)} ${p.model.padEnd(20)} ${p.baseURL}${p.apiKey ? " ●key" : " ○无key"}${active ? " ← 当前" : ""}`,
|
|
1223
|
+
active ? C.tool : C.dim,
|
|
1224
|
+
)
|
|
1225
|
+
}
|
|
1226
|
+
pushLabel(`❯ 操作`, ansi.bold + C.tool)
|
|
1227
|
+
pushLine(`/provider add <名称|预设> <url> <模型> 添加(预设: ${Object.keys(PRESETS).join(" ")})`, C.dim)
|
|
1228
|
+
pushLine(`/provider remove <名称> 删除`, C.dim)
|
|
1229
|
+
if (!agent.provider.apiKey) pushLine("⚡ /provider key <apikey> ← 当前 provider 还没配 key", C.warn)
|
|
1230
|
+
else pushLine("/provider key [名称] <apikey> 设置/更换 key", C.dim)
|
|
703
1231
|
return
|
|
704
1232
|
}
|
|
705
1233
|
case "/config": {
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
1234
|
+
const sub = rest[0]
|
|
1235
|
+
// ---- /config embedkey <apikey>:embedding 服务的 key ----
|
|
1236
|
+
if (sub === "embedkey") {
|
|
1237
|
+
const key = rest.slice(1).join(" ")
|
|
1238
|
+
if (!key) { pushLine("用法: /config embedkey <apikey>(embedding 服务,默认 SiliconFlow bge-m3)", C.error); return }
|
|
1239
|
+
agent.config.embedding ??= {}
|
|
1240
|
+
agent.config.embedding.apiKey = key
|
|
1241
|
+
await persistRaw((raw) => { raw.embedding = { ...(raw.embedding ?? {}), apiKey: key } })
|
|
1242
|
+
if (agent.memory) {
|
|
1243
|
+
const { createEmbedder } = await import("./embedding.mjs")
|
|
1244
|
+
agent.memory.embedder = createEmbedder(agent.config.embedding)
|
|
1245
|
+
}
|
|
1246
|
+
pushLabel(`❯ Config`, ansi.bold + C.tool)
|
|
1247
|
+
pushLine(`embedding key 已保存,向量检索已启用`, C.tool)
|
|
1248
|
+
return
|
|
1249
|
+
}
|
|
1250
|
+
// ---- /config set <path> <value> (高级) ----
|
|
1251
|
+
if (sub === "set") {
|
|
1252
|
+
const [path, value] = [rest[1], rest.slice(2).join(" ")]
|
|
1253
|
+
if (!path || !value) { pushLine("用法: /config set <path> <value> 如 /config set agent.maxTurns 80", C.error); return }
|
|
1254
|
+
try {
|
|
1255
|
+
const { configPath, loadConfig, saveConfig } = await import("./config.mjs")
|
|
1256
|
+
const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
|
|
1257
|
+
// 支持 a.b 形式的嵌套 key
|
|
1258
|
+
const keys = path.split(".")
|
|
1259
|
+
let obj = raw
|
|
1260
|
+
for (let i = 0; i < keys.length - 1; i++) { obj[keys[i]] ??= {}; obj = obj[keys[i]] }
|
|
1261
|
+
obj[keys[keys.length - 1]] = isNaN(value) ? value : Number(value)
|
|
1262
|
+
saveConfig(raw)
|
|
1263
|
+
const cfg = loadConfig()
|
|
1264
|
+
agent.provider = cfg.provider
|
|
1265
|
+
agent.providers = cfg.providersList
|
|
1266
|
+
agent.activeProvider = cfg.activeProvider
|
|
1267
|
+
agent.config = cfg
|
|
1268
|
+
pushLabel(`❯ Config`, ansi.bold + C.tool)
|
|
1269
|
+
pushLine(`已保存: ${path} = ${value}`, C.tool)
|
|
1270
|
+
} catch (error) {
|
|
1271
|
+
pushLine(`保存失败: ${error.message}`, C.error)
|
|
1272
|
+
}
|
|
1273
|
+
return
|
|
1274
|
+
}
|
|
1275
|
+
if (sub) { pushLine(`未知: ${sub}(可用: embedkey / set)`, C.error); return }
|
|
1276
|
+
// ---- /config(无参): 查看 ----
|
|
1277
|
+
const { configPath: cp } = await import("./config.mjs")
|
|
1278
|
+
pushLabel(`❯ 配置`, ansi.bold + C.tool)
|
|
1279
|
+
pushLine(`激活: ${agent.activeProvider} / ${agent.provider.model}`, C.dim)
|
|
1280
|
+
pushLine(`Key: ${maskKey(agent.provider.apiKey)}`, C.dim)
|
|
709
1281
|
const ac = agent.config?.agent ?? {}
|
|
710
|
-
const
|
|
711
|
-
pushLine(`agent:
|
|
712
|
-
pushLine(`
|
|
1282
|
+
const tn = `${ac.compactThreshold ?? 100000}${ac.compactThresholdAuto ? " (auto)" : ""}`
|
|
1283
|
+
pushLine(`agent: maxTurns=${ac.maxTurns ?? 100} | compactThreshold=${tn}`, C.dim)
|
|
1284
|
+
pushLine(`embedding: ${agent.memory?.embedder ? `enabled (${agent.config?.embedding?.model ?? ""})` : "disabled(纯 FTS 检索)"}`, C.dim)
|
|
1285
|
+
pushLabel(`❯ 管理`, ansi.bold + C.tool)
|
|
1286
|
+
pushLine(`/provider 管理 provider(添加/删除/配 key)`, C.dim)
|
|
1287
|
+
if (!agent.memory?.embedder) pushLine(`/config embedkey <k> 开启向量检索`, C.dim)
|
|
1288
|
+
pushLine(`/config set <k> <v> 修改任意配置项`, C.dim)
|
|
1289
|
+
pushLine(`配置文件: ${cp}`, C.dim)
|
|
713
1290
|
return
|
|
714
1291
|
}
|
|
715
1292
|
case "/help": {
|
|
716
|
-
|
|
717
|
-
|
|
1293
|
+
const order = ["Agent", "Session", "Tools", "Config"]
|
|
1294
|
+
const byGroup = new Map()
|
|
1295
|
+
for (const c of SLASH_COMMANDS) {
|
|
1296
|
+
if (!c.group) continue
|
|
1297
|
+
if (!byGroup.has(c.group)) byGroup.set(c.group, [])
|
|
1298
|
+
byGroup.get(c.group).push(c)
|
|
1299
|
+
}
|
|
1300
|
+
const maxW = Math.max(...SLASH_COMMANDS.map((c) => c.name.length))
|
|
1301
|
+
for (const g of order) {
|
|
1302
|
+
const cmds = byGroup.get(g)
|
|
1303
|
+
if (!cmds?.length) continue
|
|
1304
|
+
byGroup.delete(g)
|
|
1305
|
+
pushLabel(`❯ ${g}`, ansi.bold + C.tool)
|
|
1306
|
+
for (const c of cmds) pushLine(` ${c.name.padEnd(maxW + 1)} ${c.desc}`, C.dim)
|
|
1307
|
+
}
|
|
1308
|
+
for (const [g, cmds] of byGroup) {
|
|
1309
|
+
pushLabel(`❯ ${g}`, ansi.bold + C.tool)
|
|
1310
|
+
for (const c of cmds) pushLine(` ${c.name.padEnd(maxW + 1)} ${c.desc}`, C.dim)
|
|
1311
|
+
}
|
|
718
1312
|
return
|
|
719
1313
|
}
|
|
720
1314
|
default:
|
|
@@ -729,6 +1323,351 @@ export async function startTUI(agent, opts = {}) {
|
|
|
729
1323
|
return `${key.slice(0, 5)}…${key.slice(-4)}`
|
|
730
1324
|
}
|
|
731
1325
|
|
|
1326
|
+
/** Tab 补全候选:命令名 / 子命令 / provider 名 / 预设名 / think 参数 */
|
|
1327
|
+
function completions(input) {
|
|
1328
|
+
if (!input.startsWith("/")) return []
|
|
1329
|
+
const parts = input.split(/\s+/)
|
|
1330
|
+
// 还在敲第一个 token:补命令名
|
|
1331
|
+
if (parts.length === 1) {
|
|
1332
|
+
return SLASH_COMMANDS.filter((c) => c.name.startsWith(parts[0])).map((c) => c.name)
|
|
1333
|
+
}
|
|
1334
|
+
const cmd = parts[0]
|
|
1335
|
+
const last = parts.at(-1) // 结尾是空格时为 "",即列出全部候选
|
|
1336
|
+
const head = parts.slice(0, -1).join(" ")
|
|
1337
|
+
const argIndex = parts.length - 2 // 正在敲第几个参数(0 基)
|
|
1338
|
+
const match = (cands) => cands.filter((c) => c.startsWith(last)).map((c) => `${head} ${c}`)
|
|
1339
|
+
if (cmd === "/model" && argIndex === 0) return match(agent.providers.map((p) => p.name))
|
|
1340
|
+
if (cmd === "/provider") {
|
|
1341
|
+
if (argIndex === 0) return match(["add", "remove", "key"])
|
|
1342
|
+
if (argIndex === 1 && parts[1] === "add") return match(Object.keys(PRESETS))
|
|
1343
|
+
if (argIndex === 1 && (parts[1] === "remove" || parts[1] === "key")) return match(agent.providers.map((p) => p.name))
|
|
1344
|
+
}
|
|
1345
|
+
if (cmd === "/think") {
|
|
1346
|
+
if (argIndex === 0) return match(["on", "off", "effort"])
|
|
1347
|
+
if (argIndex === 1 && parts[1] === "effort") return match(["low", "high", "max"])
|
|
1348
|
+
}
|
|
1349
|
+
if (cmd === "/config" && argIndex === 0) return match(["embedkey", "set"])
|
|
1350
|
+
if (cmd === "/goal" && argIndex === 0) return match(["set", "cancel"])
|
|
1351
|
+
if (cmd === "/mcp") {
|
|
1352
|
+
if (argIndex === 0) return match(["add", "url", "remove", "connect", "list"])
|
|
1353
|
+
if (argIndex === 1 && (parts[1] === "remove" || parts[1] === "connect")) return match((agent.config?.mcp?.servers ?? []).map((s) => s.name))
|
|
1354
|
+
}
|
|
1355
|
+
return []
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/** Tab:计算候选并循环替换输入 */
|
|
1359
|
+
function handleTab() {
|
|
1360
|
+
const input = state.input.join("")
|
|
1361
|
+
if (state.completion && input === state.completion.candidates[state.completion.index]) {
|
|
1362
|
+
// 上一次的候选还在输入框:循环到下一个
|
|
1363
|
+
state.completion.index = (state.completion.index + 1) % state.completion.candidates.length
|
|
1364
|
+
} else {
|
|
1365
|
+
const candidates = completions(input)
|
|
1366
|
+
if (candidates.length === 0) return
|
|
1367
|
+
state.completion = { candidates, index: 0 }
|
|
1368
|
+
}
|
|
1369
|
+
const text = state.completion.candidates[state.completion.index]
|
|
1370
|
+
state.input = [...text]
|
|
1371
|
+
state.cursor = state.input.length
|
|
1372
|
+
render()
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/** 读配置文件 → 修改 → 写回;文件不存在时从空对象开始 */
|
|
1376
|
+
async function persistRaw(mutate) {
|
|
1377
|
+
const { saveConfig, configPath } = await import("./config.mjs")
|
|
1378
|
+
const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
|
|
1379
|
+
mutate(raw)
|
|
1380
|
+
saveConfig(raw)
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/** 把当前激活 provider 的某个字段同步到 providers 列表并持久化 */
|
|
1384
|
+
async function syncProviderField(field, value) {
|
|
1385
|
+
const target = agent.providers.find((p) => p.name === agent.activeProvider)
|
|
1386
|
+
if (!target) return
|
|
1387
|
+
if (value === undefined) delete target[field]
|
|
1388
|
+
else target[field] = value
|
|
1389
|
+
// 全量写回:raw 里的 providers 顺序/内容可能与运行时列表不一致,逐字段改容易写错位
|
|
1390
|
+
await persistRaw((raw) => {
|
|
1391
|
+
raw.providers = agent.providers
|
|
1392
|
+
})
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// ---------------------------------------------------------- 模型选择器(/model)
|
|
1396
|
+
|
|
1397
|
+
const pickerItems = () => state.picker.entries.filter((e) => e.type === "item")
|
|
1398
|
+
|
|
1399
|
+
/** 按 entries 重建显示行并刷新;高亮选中项、标注当前模型 */
|
|
1400
|
+
function renderPickerLines() {
|
|
1401
|
+
const p = state.picker
|
|
1402
|
+
if (!p) return
|
|
1403
|
+
const lines = []
|
|
1404
|
+
let row = 0
|
|
1405
|
+
let selectedLine = 0
|
|
1406
|
+
for (const e of p.entries) {
|
|
1407
|
+
if (e.type === "header") {
|
|
1408
|
+
lines.push({ text: ` ${e.name}${e.note ? ` ${e.note}` : ""}`, color: ansi.bold + C.tool })
|
|
1409
|
+
} else {
|
|
1410
|
+
const selected = row === p.index
|
|
1411
|
+
if (selected) selectedLine = lines.length
|
|
1412
|
+
const current = e.provider === agent.activeProvider && e.model === agent.provider.model
|
|
1413
|
+
lines.push({
|
|
1414
|
+
text: `${selected ? " ▸ " : " "}${e.model}${current ? " ← 当前" : ""}`,
|
|
1415
|
+
color: selected ? ansi.bold + C.text : C.dim,
|
|
1416
|
+
})
|
|
1417
|
+
row++
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
p.lines = lines
|
|
1421
|
+
p.selectedLine = selectedLine
|
|
1422
|
+
render()
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/** 打开选择器:先列出各 provider 已配置的模型,再并发拉取各端点的全部模型展开进去 */
|
|
1426
|
+
async function openModelPicker() {
|
|
1427
|
+
const entries = []
|
|
1428
|
+
for (const p of agent.providers) {
|
|
1429
|
+
entries.push({ type: "header", name: p.name, note: `${p.baseURL}${p.apiKey ? "" : "(未配 key)"} 加载中...` })
|
|
1430
|
+
entries.push({ type: "item", provider: p.name, model: p.model })
|
|
1431
|
+
}
|
|
1432
|
+
state.picker = { entries, lines: [], index: 0, scroll: 0, selectedLine: 0 }
|
|
1433
|
+
// 默认选中当前在用的模型
|
|
1434
|
+
const current = pickerItems().findIndex(
|
|
1435
|
+
(e) => e.provider === agent.activeProvider && e.model === agent.provider.model,
|
|
1436
|
+
)
|
|
1437
|
+
if (current >= 0) state.picker.index = current
|
|
1438
|
+
renderPickerLines()
|
|
1439
|
+
|
|
1440
|
+
const { listModels } = await import("./provider.mjs")
|
|
1441
|
+
await Promise.all(
|
|
1442
|
+
agent.providers.map(async (p) => {
|
|
1443
|
+
const header = entries.find((e) => e.type === "header" && e.name === p.name)
|
|
1444
|
+
const noteBase = `${p.baseURL}${p.apiKey ? "" : "(未配 key)"}`
|
|
1445
|
+
try {
|
|
1446
|
+
// key 的环境变量兜底和 loadConfig 保持一致(提供商专用变量只对同名生效)
|
|
1447
|
+
const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[p.name]
|
|
1448
|
+
let apiKey = p.apiKey
|
|
1449
|
+
if (!apiKey && envKey && process.env[envKey]) apiKey = process.env[envKey]
|
|
1450
|
+
if (!apiKey) apiKey = process.env.THINCODER_API_KEY
|
|
1451
|
+
const models = await listModels(
|
|
1452
|
+
{ baseURL: p.baseURL, apiKey: apiKey ?? "" },
|
|
1453
|
+
{ signal: AbortSignal.timeout(10000) },
|
|
1454
|
+
)
|
|
1455
|
+
// 展开到该 provider 已配置模型的后面(去重)
|
|
1456
|
+
const at = entries.findIndex((e) => e.type === "item" && e.provider === p.name && e.model === p.model)
|
|
1457
|
+
entries.splice(
|
|
1458
|
+
at + 1,
|
|
1459
|
+
0,
|
|
1460
|
+
...models.filter((m) => m !== p.model).map((m) => ({ type: "item", provider: p.name, model: m })),
|
|
1461
|
+
)
|
|
1462
|
+
header.note = noteBase
|
|
1463
|
+
} catch (error) {
|
|
1464
|
+
header.note = `${noteBase} (拉取失败: ${sliceByWidth(error.message, 60)})`
|
|
1465
|
+
}
|
|
1466
|
+
if (state.picker?.entries === entries) renderPickerLines() // 已关闭就不再刷新
|
|
1467
|
+
}),
|
|
1468
|
+
)
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function closeModelPicker() {
|
|
1472
|
+
state.picker = null
|
|
1473
|
+
render()
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/** 给指定 provider 写 key(内存 + 配置文件);若它是当前激活的,同步运行时 */
|
|
1477
|
+
async function setProviderKey(name, key) {
|
|
1478
|
+
const target = agent.providers.find((p) => p.name === name)
|
|
1479
|
+
if (!target) {
|
|
1480
|
+
pushLine(`未找到 provider "${name}"`, C.error)
|
|
1481
|
+
return
|
|
1482
|
+
}
|
|
1483
|
+
target.apiKey = key
|
|
1484
|
+
if (name === agent.activeProvider) agent.provider.apiKey = key
|
|
1485
|
+
await persistRaw((raw) => { raw.providers = agent.providers })
|
|
1486
|
+
pushLabel(`❯ Provider`, ansi.bold + C.tool)
|
|
1487
|
+
pushLine(`apiKey 已保存到 ${name}`, C.tool)
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// ---------------------------------------------------------- 初始配置向导(首次启动)
|
|
1491
|
+
|
|
1492
|
+
/** 菜单步的候选项:已有 provider(未配 key 的标注)+ 未添加的预设 + 自定义 */
|
|
1493
|
+
function wizardProviderItems() {
|
|
1494
|
+
const items = []
|
|
1495
|
+
for (const p of agent.providers) {
|
|
1496
|
+
items.push({ kind: "existing", name: p.name, baseURL: p.baseURL, model: p.model, label: `${p.name}(已添加${p.apiKey ? "" : ",未配 key"})` })
|
|
1497
|
+
}
|
|
1498
|
+
for (const [name, p] of Object.entries(PRESETS)) {
|
|
1499
|
+
if (!agent.providers.some((x) => x.name === name)) {
|
|
1500
|
+
items.push({ kind: "preset", name, baseURL: p.baseURL, model: p.model, label: `${name}(${p.desc})` })
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
items.push({ kind: "custom", name: null, label: "自定义端点…" })
|
|
1504
|
+
return items
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
/** 文本步骤定义:提示语 + 校验(通过返回 true,否则返回错误文案) */
|
|
1508
|
+
const WIZARD_STEPS = {
|
|
1509
|
+
name: {
|
|
1510
|
+
prompt: "给这个 provider 起个名字(字母/数字/-/_,如 my-openai)",
|
|
1511
|
+
validate: (v) =>
|
|
1512
|
+
(/^[\w-]+$/.test(v) && !agent.providers.some((p) => p.name === v)) || "名字需为字母/数字/-/_,且不与已有 provider 重名",
|
|
1513
|
+
},
|
|
1514
|
+
baseURL: {
|
|
1515
|
+
prompt: "输入 baseURL(如 https://api.openai.com/v1)",
|
|
1516
|
+
validate: (v) => /^https?:\/\/.+/.test(v) || "baseURL 应以 http(s):// 开头",
|
|
1517
|
+
},
|
|
1518
|
+
model: {
|
|
1519
|
+
prompt: "输入模型名(如 gpt-4o)",
|
|
1520
|
+
validate: (v) => v.length > 0 || "模型名不能为空",
|
|
1521
|
+
},
|
|
1522
|
+
key: {
|
|
1523
|
+
prompt: "输入 API key",
|
|
1524
|
+
validate: (v) => v.length > 0 || "key 不能为空",
|
|
1525
|
+
},
|
|
1526
|
+
embedkey: {
|
|
1527
|
+
prompt: "可选:embedding API key(SiliconFlow,记忆向量检索用;直接回车跳过)",
|
|
1528
|
+
validate: () => true, // 可跳过
|
|
1529
|
+
},
|
|
1530
|
+
}
|
|
1531
|
+
const WIZARD_NEXT = { name: "baseURL", baseURL: "model", model: "key", key: "embedkey", embedkey: null }
|
|
1532
|
+
|
|
1533
|
+
function startWizard() {
|
|
1534
|
+
state.wizard = { step: "provider", index: 0, scroll: 0, selectedLine: 0, fields: {}, error: null, lines: [] }
|
|
1535
|
+
renderWizard()
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
function renderWizard() {
|
|
1539
|
+
const w = state.wizard
|
|
1540
|
+
if (!w) return
|
|
1541
|
+
const lines = []
|
|
1542
|
+
if (w.step === "provider") {
|
|
1543
|
+
lines.push({ text: " 选择一个模型提供商:", color: C.text })
|
|
1544
|
+
wizardProviderItems().forEach((it, i) => {
|
|
1545
|
+
if (i === w.index) w.selectedLine = lines.length
|
|
1546
|
+
lines.push({
|
|
1547
|
+
text: `${i === w.index ? " ▸ " : " "}${it.label}`,
|
|
1548
|
+
color: i === w.index ? ansi.bold + C.text : C.dim,
|
|
1549
|
+
})
|
|
1550
|
+
})
|
|
1551
|
+
} else {
|
|
1552
|
+
const f = w.fields
|
|
1553
|
+
if (f.name) lines.push({ text: ` 提供商: ${f.name}`, color: C.dim })
|
|
1554
|
+
if (f.baseURL) lines.push({ text: ` baseURL: ${f.baseURL}`, color: C.dim })
|
|
1555
|
+
if (f.model) lines.push({ text: ` 模型: ${f.model}`, color: C.dim })
|
|
1556
|
+
lines.push({ text: ` ❯ ${WIZARD_STEPS[w.step].prompt}`, color: ansi.bold + C.text })
|
|
1557
|
+
lines.push({ text: " (在下方输入框输入)", color: C.dim })
|
|
1558
|
+
w.selectedLine = 0
|
|
1559
|
+
}
|
|
1560
|
+
if (w.error) lines.push({ text: ` ${w.error}`, color: C.error })
|
|
1561
|
+
w.lines = lines
|
|
1562
|
+
render()
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
function wizardChooseProvider(item) {
|
|
1566
|
+
const w = state.wizard
|
|
1567
|
+
if (item.kind === "custom") {
|
|
1568
|
+
w.step = "name"
|
|
1569
|
+
} else {
|
|
1570
|
+
w.fields = { name: item.name, baseURL: item.baseURL, model: item.model }
|
|
1571
|
+
w.step = "key"
|
|
1572
|
+
}
|
|
1573
|
+
renderWizard()
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
function wizardSubmitText() {
|
|
1577
|
+
const w = state.wizard
|
|
1578
|
+
const value = state.input.join("").trim()
|
|
1579
|
+
const ok = WIZARD_STEPS[w.step].validate(value)
|
|
1580
|
+
if (ok !== true) {
|
|
1581
|
+
w.error = ok
|
|
1582
|
+
renderWizard()
|
|
1583
|
+
return
|
|
1584
|
+
}
|
|
1585
|
+
w.error = null
|
|
1586
|
+
state.input = []
|
|
1587
|
+
state.cursor = 0
|
|
1588
|
+
w.fields[w.step === "key" ? "key" : w.step] = w.step === "baseURL" ? value.replace(/\/+$/, "") : value
|
|
1589
|
+
const next = WIZARD_NEXT[w.step]
|
|
1590
|
+
if (next) {
|
|
1591
|
+
w.step = next
|
|
1592
|
+
renderWizard()
|
|
1593
|
+
} else {
|
|
1594
|
+
finishWizard().catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
function cancelWizard() {
|
|
1599
|
+
state.wizard = null
|
|
1600
|
+
pushLine("已跳过初始配置。之后随时可用 /provider add 添加提供商、/provider key 配 key。", C.dim)
|
|
1601
|
+
render()
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
/** 向导完成:写入 provider(有则更新)、设为激活、持久化,然后接模型选择器 */
|
|
1605
|
+
async function finishWizard() {
|
|
1606
|
+
const f = state.wizard.fields
|
|
1607
|
+
state.wizard = null
|
|
1608
|
+
const existing = agent.providers.find((p) => p.name === f.name)
|
|
1609
|
+
if (existing) Object.assign(existing, { baseURL: f.baseURL, model: f.model, apiKey: f.key })
|
|
1610
|
+
else agent.providers.push({ name: f.name, baseURL: f.baseURL, model: f.model, apiKey: f.key })
|
|
1611
|
+
agent.activeProvider = f.name
|
|
1612
|
+
agent.provider = { ...agent.providers.find((p) => p.name === f.name) }
|
|
1613
|
+
if (agent.config?.agent?.compactThresholdAuto) {
|
|
1614
|
+
const { resolveCompactThreshold } = await import("./config.mjs")
|
|
1615
|
+
agent.config.agent.compactThreshold = resolveCompactThreshold(null, f.model).value
|
|
1616
|
+
}
|
|
1617
|
+
await persistRaw((raw) => {
|
|
1618
|
+
raw.providers = agent.providers
|
|
1619
|
+
raw.activeProvider = f.name
|
|
1620
|
+
})
|
|
1621
|
+
agent.config.activeProvider = f.name
|
|
1622
|
+
pushLabel(`❯ Setup`, ansi.bold + C.tool)
|
|
1623
|
+
pushLine(`配置完成:${f.name} / ${f.model}(已写入配置文件)`, C.tool)
|
|
1624
|
+
// embedding key:配了就启用向量检索,没配提示事后通道
|
|
1625
|
+
if (f.embedkey) {
|
|
1626
|
+
agent.config.embedding ??= {}
|
|
1627
|
+
agent.config.embedding.apiKey = f.embedkey
|
|
1628
|
+
await persistRaw((raw) => { raw.embedding = { ...(raw.embedding ?? {}), apiKey: f.embedkey } })
|
|
1629
|
+
if (agent.memory && !agent.memory.embedder) {
|
|
1630
|
+
const { createEmbedder } = await import("./embedding.mjs")
|
|
1631
|
+
agent.memory.embedder = createEmbedder(agent.config.embedding)
|
|
1632
|
+
}
|
|
1633
|
+
pushLine(`向量检索已启用(${agent.config.embedding.model ?? "BAAI/bge-m3"})`, C.tool)
|
|
1634
|
+
} else {
|
|
1635
|
+
pushLine(`向量检索未启用(记忆退化为纯文本检索);之后可 /config embedkey <key> 开启`, C.dim)
|
|
1636
|
+
}
|
|
1637
|
+
pushLine(`选择要用的模型(Esc 保持 ${f.model})`, C.dim)
|
|
1638
|
+
openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
/** 选中:切换 provider + 模型,持久化,阈值随模型走 */
|
|
1642
|
+
async function selectModel(item) {
|
|
1643
|
+
closeModelPicker()
|
|
1644
|
+
const target = agent.providers.find((pp) => pp.name === item.provider)
|
|
1645
|
+
if (!target) return
|
|
1646
|
+
target.model = item.model
|
|
1647
|
+
agent.activeProvider = item.provider
|
|
1648
|
+
agent.provider = { ...target }
|
|
1649
|
+
if (!agent.provider.apiKey) {
|
|
1650
|
+
const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[item.provider]
|
|
1651
|
+
if (envKey && process.env[envKey]) agent.provider.apiKey = process.env[envKey]
|
|
1652
|
+
}
|
|
1653
|
+
if (!agent.provider.apiKey) agent.provider.apiKey = process.env.THINCODER_API_KEY
|
|
1654
|
+
let thresholdNote = ""
|
|
1655
|
+
if (agent.config?.agent?.compactThresholdAuto) {
|
|
1656
|
+
const { resolveCompactThreshold } = await import("./config.mjs")
|
|
1657
|
+
const { value } = resolveCompactThreshold(null, item.model)
|
|
1658
|
+
agent.config.agent.compactThreshold = value
|
|
1659
|
+
thresholdNote = `,压缩阈值随模型调整为 ${value}`
|
|
1660
|
+
}
|
|
1661
|
+
await persistRaw((raw) => {
|
|
1662
|
+
raw.providers = agent.providers
|
|
1663
|
+
raw.activeProvider = item.provider
|
|
1664
|
+
})
|
|
1665
|
+
agent.config.activeProvider = item.provider
|
|
1666
|
+
pushLabel(`❯ Model`, ansi.bold + C.tool)
|
|
1667
|
+
pushLine(`已切换到 ${item.provider} / ${item.model}${thresholdNote}(已持久化)`, C.tool)
|
|
1668
|
+
if (!agent.provider.apiKey) pushLine(`该 provider 还没配 key: /config key <apikey>`, C.warn)
|
|
1669
|
+
}
|
|
1670
|
+
|
|
732
1671
|
/** /distill:从当前会话提取候选,逐条 y/n 确认后入库 */
|
|
733
1672
|
async function runDistill() {
|
|
734
1673
|
if (agent.history.length === 0) {
|
|
@@ -774,22 +1713,130 @@ export async function startTUI(agent, opts = {}) {
|
|
|
774
1713
|
|
|
775
1714
|
// keypress 挂在过滤后的 keyStream 上:鼠标序列已在上游滤网中处理并剥除
|
|
776
1715
|
keyStream.on("keypress", (str, key = {}) => {
|
|
777
|
-
//
|
|
1716
|
+
// 权限确认态:y 批准 / n 拒绝 / a 批准并开启 AUTO(后续不再询问)
|
|
778
1717
|
if (state.permission) {
|
|
779
1718
|
const answer = (str || "").toLowerCase()
|
|
780
|
-
|
|
1719
|
+
const isContinue = state.permission.name === "continue"
|
|
1720
|
+
const validKeys = isContinue ? ["y", "n"] : ["y", "n", "a"]
|
|
1721
|
+
if (validKeys.includes(answer) || key.name === "escape") {
|
|
781
1722
|
const { resolve } = state.permission
|
|
782
1723
|
state.permission = null
|
|
783
1724
|
state.status = "Processing..."
|
|
784
|
-
|
|
1725
|
+
if (answer === "a" && !isContinue) {
|
|
1726
|
+
agent.autoApprove = true
|
|
1727
|
+
agent._pendingReminders = agent._pendingReminders ?? []
|
|
1728
|
+
agent._pendingReminders.push("[System reminder: AUTO mode is now ON. All tool calls are automatically approved. Use /auto to disable.]")
|
|
1729
|
+
pushLine(` [auto] AUTO 已开启:后续工具调用不再询问(/auto 关闭)`, C.warn)
|
|
1730
|
+
}
|
|
1731
|
+
resolve(answer === "y" || (answer === "a" && !isContinue))
|
|
785
1732
|
render()
|
|
786
1733
|
}
|
|
787
1734
|
return
|
|
788
1735
|
}
|
|
789
1736
|
|
|
1737
|
+
// question 工具回调:自由文本 / 选项选择
|
|
1738
|
+
if (state.question) {
|
|
1739
|
+
const q = state.question
|
|
1740
|
+
if (q.options.length > 0) {
|
|
1741
|
+
// 选项模式:↑↓ 选择,Enter 确认,Esc 取消
|
|
1742
|
+
if (key.name === "escape") {
|
|
1743
|
+
q.resolve("(cancelled)")
|
|
1744
|
+
state.question = null
|
|
1745
|
+
state.status = "Processing..."
|
|
1746
|
+
render()
|
|
1747
|
+
} else if (key.name === "up") {
|
|
1748
|
+
q.selected = Math.max(0, (q.selected ?? 0) - 1)
|
|
1749
|
+
render()
|
|
1750
|
+
} else if (key.name === "down") {
|
|
1751
|
+
q.selected = Math.min(q.options.length - 1, (q.selected ?? 0) + 1)
|
|
1752
|
+
render()
|
|
1753
|
+
} else if (key.name === "return") {
|
|
1754
|
+
const answer = q.options[q.selected ?? 0]
|
|
1755
|
+
q.resolve(answer)
|
|
1756
|
+
state.question = null
|
|
1757
|
+
state.status = "Processing..."
|
|
1758
|
+
pushLine(` → ${answer}`, C.tool)
|
|
1759
|
+
render()
|
|
1760
|
+
}
|
|
1761
|
+
} else {
|
|
1762
|
+
// 自由文本:键入答案,Enter 提交,Esc 取消
|
|
1763
|
+
if (key.name === "escape") {
|
|
1764
|
+
q.resolve("(cancelled)")
|
|
1765
|
+
state.question = null
|
|
1766
|
+
state.status = "Processing..."
|
|
1767
|
+
render()
|
|
1768
|
+
} else if (key.name === "return") {
|
|
1769
|
+
const answer = (q.answer ?? "").trim()
|
|
1770
|
+
q.resolve(answer || "(empty answer)")
|
|
1771
|
+
state.question = null
|
|
1772
|
+
state.status = "Processing..."
|
|
1773
|
+
pushLine(` → ${answer || "(empty)"}`, C.tool)
|
|
1774
|
+
render()
|
|
1775
|
+
} else if (key.name === "backspace") {
|
|
1776
|
+
q.answer = (q.answer ?? "").slice(0, -1)
|
|
1777
|
+
render()
|
|
1778
|
+
} else if (str && !key.ctrl && !key.meta) {
|
|
1779
|
+
q.answer = (q.answer ?? "") + str
|
|
1780
|
+
render()
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
return
|
|
1784
|
+
}
|
|
1785
|
+
|
|
790
1786
|
if (key.ctrl && key.name === "c") {
|
|
1787
|
+
if (state.processing && state.controller) {
|
|
1788
|
+
state.controller.abort()
|
|
1789
|
+
pushLine("[中止中…]", C.warn)
|
|
1790
|
+
render()
|
|
1791
|
+
return
|
|
1792
|
+
}
|
|
791
1793
|
cleanup()
|
|
792
|
-
process.exit(0)
|
|
1794
|
+
setTimeout(() => process.exit(0), 100)
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
// 模型选择器:↑↓ 移动,Enter 确认,Esc 取消,其余按键吞掉
|
|
1798
|
+
if (state.picker) {
|
|
1799
|
+
const items = pickerItems()
|
|
1800
|
+
if (key.name === "escape") {
|
|
1801
|
+
closeModelPicker()
|
|
1802
|
+
} else if (key.name === "up" && items.length) {
|
|
1803
|
+
state.picker.index = (state.picker.index - 1 + items.length) % items.length
|
|
1804
|
+
renderPickerLines()
|
|
1805
|
+
} else if (key.name === "down" && items.length) {
|
|
1806
|
+
state.picker.index = (state.picker.index + 1) % items.length
|
|
1807
|
+
renderPickerLines()
|
|
1808
|
+
} else if (key.name === "return" && items.length) {
|
|
1809
|
+
selectModel(items[state.picker.index]).catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
1810
|
+
}
|
|
1811
|
+
return
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
// 初始配置向导:菜单步 ↑↓/Enter/Esc;文本步 Enter 提交、Esc 取消,编辑键落到正常输入
|
|
1815
|
+
if (state.wizard) {
|
|
1816
|
+
const w = state.wizard
|
|
1817
|
+
if (key.name === "escape") {
|
|
1818
|
+
cancelWizard()
|
|
1819
|
+
return
|
|
1820
|
+
}
|
|
1821
|
+
if (w.step === "provider") {
|
|
1822
|
+
const items = wizardProviderItems()
|
|
1823
|
+
if (key.name === "up" && items.length) {
|
|
1824
|
+
w.index = (w.index - 1 + items.length) % items.length
|
|
1825
|
+
renderWizard()
|
|
1826
|
+
} else if (key.name === "down" && items.length) {
|
|
1827
|
+
w.index = (w.index + 1) % items.length
|
|
1828
|
+
renderWizard()
|
|
1829
|
+
} else if (key.name === "return" && items.length) {
|
|
1830
|
+
wizardChooseProvider(items[w.index])
|
|
1831
|
+
}
|
|
1832
|
+
return
|
|
1833
|
+
}
|
|
1834
|
+
if (key.name === "return") {
|
|
1835
|
+
wizardSubmitText()
|
|
1836
|
+
return
|
|
1837
|
+
}
|
|
1838
|
+
// 文本步骤屏蔽翻页/历史,其余编辑键放行到下面的普通输入逻辑
|
|
1839
|
+
if (key.name === "up" || key.name === "down" || key.name === "pageup" || key.name === "pagedown") return
|
|
793
1840
|
}
|
|
794
1841
|
|
|
795
1842
|
// 翻页
|
|
@@ -806,6 +1853,12 @@ export async function startTUI(agent, opts = {}) {
|
|
|
806
1853
|
|
|
807
1854
|
if (state.processing) return // 处理中锁定输入
|
|
808
1855
|
|
|
1856
|
+
// Tab:斜杠命令补全(循环候选);其余输入忽略(\t 会顶破输入框,永不直接插入)
|
|
1857
|
+
if (key.name === "tab") {
|
|
1858
|
+
handleTab()
|
|
1859
|
+
return
|
|
1860
|
+
}
|
|
1861
|
+
|
|
809
1862
|
// 输入历史
|
|
810
1863
|
if (key.name === "up") {
|
|
811
1864
|
if (state.history.length) {
|
|
@@ -874,9 +1927,9 @@ export async function startTUI(agent, opts = {}) {
|
|
|
874
1927
|
return
|
|
875
1928
|
}
|
|
876
1929
|
|
|
877
|
-
// 可打印字符 / 粘贴(str
|
|
1930
|
+
// 可打印字符 / 粘贴(str 可能一次多个字符);Tab 一律转成两个空格(\t 显示宽度不定,会顶破输入框)
|
|
878
1931
|
if (str && !key.ctrl && !key.meta) {
|
|
879
|
-
const chars = [...str.replace(/\r/g, "")]
|
|
1932
|
+
const chars = [...str.replace(/\r/g, "").replace(/\t/g, " ")]
|
|
880
1933
|
state.input.splice(state.cursor, 0, ...chars)
|
|
881
1934
|
state.cursor += chars.length
|
|
882
1935
|
render()
|
|
@@ -884,7 +1937,13 @@ export async function startTUI(agent, opts = {}) {
|
|
|
884
1937
|
})
|
|
885
1938
|
|
|
886
1939
|
// 启动画面
|
|
887
|
-
|
|
1940
|
+
if (!agent.provider.apiKey) {
|
|
1941
|
+
pushLabel(`欢迎使用 ThinCoder!`, ansi.bold + C.tool)
|
|
1942
|
+
pushLine("检测到还没配置 API key,进入初始配置(Esc 可随时跳过)", C.text)
|
|
1943
|
+
startWizard()
|
|
1944
|
+
} else {
|
|
1945
|
+
pushLine(`Welcome to ThinCoder. Provider: ${agent.activeProvider} / ${agent.provider.model}`, C.dim)
|
|
1946
|
+
}
|
|
888
1947
|
pushLine(`Tools: ${agent.tools.map((t) => t.name).join(", ")}`, C.dim)
|
|
889
1948
|
// 恢复上次会话:重建对话区显示(tool 结果行省略,保持清爽)
|
|
890
1949
|
if (opts.restored?.history?.length) {
|