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