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