thincoder 0.7.2 → 0.7.4
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 +12 -3
- package/package.json +1 -1
- package/src/SYSTEM_PROMPT.md +21 -2
- package/src/agent.mjs +124 -25
- package/src/coder-overlay.md +14 -3
- package/src/config.mjs +8 -8
- package/src/context.mjs +11 -3
- package/src/main-overlay.md +7 -2
- package/src/memory.mjs +1 -1
- package/src/plan-overlay.md +1 -1
- package/src/tools/read_image.md +3 -0
- package/src/tools.mjs +60 -5
- package/src/tui.mjs +386 -310
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 ? ` ❯ ${state.picker.title} ` : " ❯
|
|
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) {
|
|
@@ -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,59 +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"
|
|
610
624
|
} else if (match?.name === "/mcp" && cmd === "/mcp") {
|
|
611
|
-
statusLine = " /mcp
|
|
625
|
+
statusLine = " /mcp open MCP management menu"
|
|
612
626
|
} else if (match?.name === "/goal" && cmd === "/goal") {
|
|
613
|
-
statusLine = " /goal
|
|
627
|
+
statusLine = " /goal open goal management menu"
|
|
614
628
|
} else if (match?.name === "/session" && cmd === "/session") {
|
|
615
|
-
statusLine = " /session
|
|
629
|
+
statusLine = " /session select archived session"
|
|
616
630
|
} else if (match?.name === "/rewind" && cmd === "/rewind") {
|
|
617
|
-
statusLine = " /rewind
|
|
631
|
+
statusLine = " /rewind select checkpoint to restore"
|
|
618
632
|
} else if (cmds.length > 0) {
|
|
619
633
|
if (cmds.length <= 4) {
|
|
620
634
|
statusLine = ` ${cmds.map((c) => `${c.name} ${c.desc}`).join(" │ ")}`
|
|
621
635
|
} else {
|
|
622
|
-
statusLine = ` ${cmds.map((c) => c.name).join(" ")} │ Tab
|
|
636
|
+
statusLine = ` ${cmds.map((c) => c.name).join(" ")} │ Tab complete`
|
|
623
637
|
}
|
|
624
638
|
} else {
|
|
625
|
-
statusLine = `
|
|
639
|
+
statusLine = ` unknown command (/help for available commands)`
|
|
626
640
|
}
|
|
627
641
|
} else {
|
|
628
642
|
const taskHint = state.tasks.length > 0
|
|
629
643
|
? ` │ ✓${state.tasks.filter((t) => t.status === "done").length}/${state.tasks.length}`
|
|
630
644
|
: ""
|
|
631
|
-
// token 用量:↑输入 ↓输出 +
|
|
645
|
+
// token 用量:↑输入 ↓输出 + 缓存命中率 (DeepSeek usage 带 prompt_cache_hit/miss_tokens)
|
|
632
646
|
const tk = state.tokens
|
|
633
647
|
const fmtK = (n) => (n >= 10000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`)
|
|
634
648
|
const cacheTotal = tk.cacheHit + tk.cacheMiss
|
|
@@ -638,7 +652,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
638
652
|
const elapsed = state.processing ? ` ${Math.floor((Date.now() - state.processingStarted) / 1000)}s` : ""
|
|
639
653
|
const toolHint = state.currentTool ? ` ${state.currentTool}…` : ""
|
|
640
654
|
const statusText = state.processing ? `${state.status}${toolHint}${elapsed}` : state.status
|
|
641
|
-
//
|
|
655
|
+
// 上下文利用率:占压缩阈值百分比 (到 100% 触发压缩;≥80% 变黄提醒该收尾或 /new)
|
|
642
656
|
if (state.ctxCache.len !== agent.history.length) {
|
|
643
657
|
state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
|
|
644
658
|
}
|
|
@@ -665,12 +679,12 @@ export async function startTUI(agent, opts = {}) {
|
|
|
665
679
|
process.stdout.write(frame)
|
|
666
680
|
}
|
|
667
681
|
|
|
668
|
-
//
|
|
682
|
+
// 光标:输入态定位到输入框内 (IME 候选框跟随真实光标);处理中/权限确认/菜单态时隐藏
|
|
669
683
|
if (state.processing || state.permission || state.question || state.picker || state.wizard?.step === "provider") {
|
|
670
684
|
process.stdout.write(ansi.hideCursor)
|
|
671
685
|
} else {
|
|
672
686
|
const cursorRow = 1 + convH + pickerH + taskPanelH + 2 + (layout.cursorLine - inputOffset) // header + 对话区 + todo 面板 + 上边框 + 行偏移
|
|
673
|
-
const cursorCol = 3 + layout.cursorCol // 左边框 + 空格 +
|
|
687
|
+
const cursorCol = 3 + layout.cursorCol // 左边框 + 空格 + 文本偏移 (1 基)
|
|
674
688
|
process.stdout.write(`${ESC}[${cursorRow};${cursorCol}H${ansi.showCursor}`)
|
|
675
689
|
}
|
|
676
690
|
}
|
|
@@ -688,7 +702,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
688
702
|
state.historyIndex = -1
|
|
689
703
|
state.scroll = 0
|
|
690
704
|
|
|
691
|
-
//
|
|
705
|
+
// 斜杠Commands:本地处理,不进入 agent
|
|
692
706
|
if (text.startsWith("/")) {
|
|
693
707
|
await handleSlash(text)
|
|
694
708
|
return
|
|
@@ -697,7 +711,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
697
711
|
pushLabel(`❯ You:`, ansi.bold + C.user)
|
|
698
712
|
pushLine(text, C.text)
|
|
699
713
|
|
|
700
|
-
//
|
|
714
|
+
// 任务开始前自动打存档点 (git 仓库内;失败静默,不挡任务)
|
|
701
715
|
try {
|
|
702
716
|
const { createCheckpoint } = await import("./checkpoint.mjs")
|
|
703
717
|
await createCheckpoint(agent.cwd)
|
|
@@ -710,10 +724,11 @@ export async function startTUI(agent, opts = {}) {
|
|
|
710
724
|
state.status = "Processing..."
|
|
711
725
|
state.streaming = ""
|
|
712
726
|
state.reasoning = ""
|
|
727
|
+
state.subTasks = {}
|
|
713
728
|
state.currentTool = null
|
|
714
729
|
state.processingStarted = Date.now()
|
|
715
730
|
state.controller = new AbortController()
|
|
716
|
-
//
|
|
731
|
+
// 处理中每秒刷新一次状态栏 (运行计时)
|
|
717
732
|
const ticker = setInterval(() => {
|
|
718
733
|
if (state.processing) render()
|
|
719
734
|
}, 1000)
|
|
@@ -721,11 +736,12 @@ export async function startTUI(agent, opts = {}) {
|
|
|
721
736
|
|
|
722
737
|
const callbacks = {
|
|
723
738
|
onToken: (t) => {
|
|
724
|
-
// 子 agent 流式输出:前缀匹配 explore/coder/plan/sub
|
|
739
|
+
// 子 agent 流式输出:前缀匹配 explore/coder/plan/sub 的 token 进 subTasks 面板
|
|
725
740
|
const subMatch = t.match(/^(explore|coder|plan|sub)\//)
|
|
726
741
|
if (subMatch) {
|
|
727
|
-
|
|
728
|
-
|
|
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)
|
|
729
745
|
scheduleRender()
|
|
730
746
|
return
|
|
731
747
|
}
|
|
@@ -734,11 +750,11 @@ export async function startTUI(agent, opts = {}) {
|
|
|
734
750
|
scheduleRender()
|
|
735
751
|
},
|
|
736
752
|
onReasoning: (t) => {
|
|
737
|
-
// 子 agent 的思考 token 同样带 role/ 前缀,进
|
|
753
|
+
// 子 agent 的思考 token 同样带 role/ 前缀,进 subTasks 面板,不污染主思考流
|
|
738
754
|
const subMatch = t.match(/^(explore|coder|plan|sub)\//)
|
|
739
755
|
if (subMatch) {
|
|
740
|
-
|
|
741
|
-
|
|
756
|
+
const role = subMatch[1]
|
|
757
|
+
if (!state.subTasks[role]) state.subTasks[role] = { role, text: "", done: false }
|
|
742
758
|
scheduleRender()
|
|
743
759
|
return
|
|
744
760
|
}
|
|
@@ -754,17 +770,25 @@ export async function startTUI(agent, opts = {}) {
|
|
|
754
770
|
},
|
|
755
771
|
onToolResult: (name, result) => {
|
|
756
772
|
state.currentTool = null
|
|
757
|
-
// 子 agent
|
|
758
|
-
// 注意只能用精确匹配——子 agent 内部工具调用不 relay 到 TUI(刷了满屏的教训)
|
|
773
|
+
// 子 agent 结束:标记 done,面板保留片刻后清除
|
|
759
774
|
const isSubagent = name === "subagent"
|
|
760
775
|
if (isSubagent) {
|
|
761
|
-
|
|
762
|
-
state.
|
|
763
|
-
|
|
776
|
+
// 所有活跃子 agent 标记 done
|
|
777
|
+
for (const key of Object.keys(state.subTasks)) {
|
|
778
|
+
state.subTasks[key].done = true
|
|
779
|
+
}
|
|
780
|
+
// 子 agent 报告摘要 (最多 8 行)直接展示在对话区
|
|
764
781
|
const lines = result.split("\n")
|
|
765
782
|
const preview = lines.slice(0, 8).map((l) => l.slice(0, 120)).join("\n")
|
|
766
783
|
if (preview) pushLine(preview, C.dim)
|
|
767
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)
|
|
768
792
|
}
|
|
769
793
|
const stream = state.toolStreams[name]
|
|
770
794
|
if (stream) {
|
|
@@ -784,7 +808,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
784
808
|
onPermissionRequest: (name, args) => askPermission(name, args),
|
|
785
809
|
onQuestion: (text, options) => askQuestion(text, options),
|
|
786
810
|
onCompress: () => {
|
|
787
|
-
pushLine(" [context]
|
|
811
|
+
pushLine(" [context] Context too long, auto-compacted (early conversation summarized by LLM, task state preserved)", C.warn)
|
|
788
812
|
},
|
|
789
813
|
onUsage: (usage) => {
|
|
790
814
|
state.tokens.prompt += usage.prompt_tokens ?? 0
|
|
@@ -792,7 +816,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
792
816
|
state.tokens.cacheHit += usage.prompt_cache_hit_tokens ?? 0
|
|
793
817
|
state.tokens.cacheMiss += usage.prompt_cache_miss_tokens ?? 0
|
|
794
818
|
},
|
|
795
|
-
//
|
|
819
|
+
// 节流等待 (主动闸门 / 429 退避):状态栏明示,防用户以为卡死
|
|
796
820
|
onWait: ({ phase, seconds }) => {
|
|
797
821
|
state.status = phase === "gate" ? `TPM 节流等待 ~${seconds}s` : `限流 429,${seconds}s 后重试`
|
|
798
822
|
render()
|
|
@@ -800,7 +824,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
800
824
|
onTaskUpdate: (items) => {
|
|
801
825
|
state.tasks = items
|
|
802
826
|
const done = items.filter((i) => i.status === "done").length
|
|
803
|
-
//
|
|
827
|
+
// 留痕带上current任务标题:回看历史时知道进行到哪一项
|
|
804
828
|
const current = items.find((i) => i.status === "in_progress")
|
|
805
829
|
pushLine(` [task] ${done}/${items.length}${current ? ` ▶ ${current.title}` : ""}`, C.dim)
|
|
806
830
|
render()
|
|
@@ -823,12 +847,12 @@ export async function startTUI(agent, opts = {}) {
|
|
|
823
847
|
} catch (error) {
|
|
824
848
|
flushStream()
|
|
825
849
|
if (error.name === "AbortError" || state.controller?.signal.aborted) {
|
|
826
|
-
pushLine("[
|
|
850
|
+
pushLine("[stopped]", C.warn)
|
|
827
851
|
break
|
|
828
852
|
}
|
|
829
853
|
if (error instanceof ContinueError) {
|
|
830
854
|
pushLabel(`❯ Continue`, ansi.bold + C.warn)
|
|
831
|
-
pushLine(
|
|
855
|
+
pushLine(`Ran ${error.turn} turns (limit ${error.turn}). Continue?`, C.warn)
|
|
832
856
|
// 暂停询问:复用 permission 机制
|
|
833
857
|
const willContinue = await new Promise((resolve) => {
|
|
834
858
|
state.permission = {
|
|
@@ -841,11 +865,11 @@ export async function startTUI(agent, opts = {}) {
|
|
|
841
865
|
})
|
|
842
866
|
state.permission = null
|
|
843
867
|
if (!willContinue) {
|
|
844
|
-
pushLine("[
|
|
868
|
+
pushLine("[continue cancelled]", C.warn)
|
|
845
869
|
break
|
|
846
870
|
}
|
|
847
|
-
pushLine("[
|
|
848
|
-
// 重创新 AbortController:旧 signal 一旦 abort 过,resume
|
|
871
|
+
pushLine("[continuing…]", C.tool)
|
|
872
|
+
// 重创新 AbortController:旧 signal 一旦 abort 过,resume 会立即失败 (防御性,current路径不可达但耦合紧)
|
|
849
873
|
state.controller = new AbortController()
|
|
850
874
|
continue
|
|
851
875
|
}
|
|
@@ -856,13 +880,14 @@ export async function startTUI(agent, opts = {}) {
|
|
|
856
880
|
|
|
857
881
|
clearInterval(ticker)
|
|
858
882
|
state.processing = false
|
|
883
|
+
state.subTasks = {}
|
|
859
884
|
state.controller = null
|
|
860
885
|
state.status = "Ready"
|
|
861
|
-
// 全部完成时自动收起 todo
|
|
886
|
+
// 全部完成时自动收起 todo 面板 (对齐 kimi-code TUI;agent.tasks 本身保留)
|
|
862
887
|
if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
|
|
863
888
|
state.tasks = []
|
|
864
889
|
}
|
|
865
|
-
//
|
|
890
|
+
// 每轮结束后保存会话 (崩溃也不丢)
|
|
866
891
|
try {
|
|
867
892
|
saveSession(agent, state.lines)
|
|
868
893
|
} catch {
|
|
@@ -897,14 +922,14 @@ export async function startTUI(agent, opts = {}) {
|
|
|
897
922
|
})
|
|
898
923
|
}
|
|
899
924
|
|
|
900
|
-
/**
|
|
925
|
+
/** 权限请求的关键信息 (按工具定制),返回行数组。name 可能带子 agent 前缀 ("coder/bash"),取基名匹配 */
|
|
901
926
|
function formatPermission(name, args) {
|
|
902
|
-
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)
|
|
903
928
|
const base = name.includes("/") ? name.split("/").pop() : name
|
|
904
929
|
if (base === "bash") return cap(args.command ?? "").split("\n")
|
|
905
930
|
if (base === "write") {
|
|
906
931
|
// 批准写文件必须看得到要写什么:路径 + 内容预览
|
|
907
|
-
return [`${args.path}
|
|
932
|
+
return [`${args.path} (write ${(args.content ?? "").length} chars)`, ...cap(args.content ?? "", 1000).split("\n")]
|
|
908
933
|
}
|
|
909
934
|
if (base === "edit") {
|
|
910
935
|
// 简易 diff:- 旧内容 / + 新内容
|
|
@@ -919,7 +944,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
919
944
|
// 补丁本身就是可读的 diff,直接预览
|
|
920
945
|
return cap(args.patch ?? "", 1500).split("\n")
|
|
921
946
|
}
|
|
922
|
-
if (base === "delete") return [`${args.path}${args.force ? "
|
|
947
|
+
if (base === "delete") return [`${args.path}${args.force ? " (force: also delete tracked files)" : ""}`]
|
|
923
948
|
if (base === "subagent") return cap(args.task ?? "", 500).split("\n")
|
|
924
949
|
if (base === "memory_put") return [`[${args.type ?? ""}] ${args.title ?? ""}`, ...cap(args.content ?? "", 500).split("\n")]
|
|
925
950
|
return [cap(summarize(args), 300)]
|
|
@@ -927,9 +952,9 @@ export async function startTUI(agent, opts = {}) {
|
|
|
927
952
|
|
|
928
953
|
function askQuestion(text, options = []) {
|
|
929
954
|
// 一次只能问一个:question 是只读工具走并行通道,同批第二个直接驳回,
|
|
930
|
-
// 否则后到的会覆盖 state.question,先到的 Promise
|
|
955
|
+
// 否则后到的会覆盖 state.question,先到的 Promise 永远悬挂 (agent 死等)
|
|
931
956
|
if (state.question) {
|
|
932
|
-
return Promise.resolve("(error:
|
|
957
|
+
return Promise.resolve("(error: another question is pending; ask one at a time and wait for the answer)")
|
|
933
958
|
}
|
|
934
959
|
if (!options.length) {
|
|
935
960
|
// 自由文本:打开输入态让用户打字,Enter 提交
|
|
@@ -951,27 +976,71 @@ export async function startTUI(agent, opts = {}) {
|
|
|
951
976
|
})
|
|
952
977
|
}
|
|
953
978
|
|
|
954
|
-
|
|
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
|
|
955
1024
|
|
|
956
1025
|
const SLASH_COMMANDS = [
|
|
957
|
-
{ name: "/plan", group: "Agent", desc: "
|
|
958
|
-
{ name: "/auto", group: "Agent", desc: "
|
|
959
|
-
{ name: "/model", group: "Agent", desc: "
|
|
960
|
-
{ name: "/goal", group: "Agent", desc: "
|
|
961
|
-
{ name: "/think", group: "Agent", desc: "
|
|
962
|
-
{ name: "/init", group: "Tools", desc: "
|
|
963
|
-
{ name: "/skills", group: "Tools", desc: "
|
|
964
|
-
{ name: "/mcp", group: "Tools", desc: "
|
|
965
|
-
{ name: "/provider", group: "Config", desc: "
|
|
966
|
-
{ name: "/config", group: "Config", desc: "
|
|
967
|
-
{ name: "/reindex", group: "Config", desc: "
|
|
968
|
-
{ name: "/new", group: "Session", desc: "
|
|
969
|
-
{ name: "/session", group: "Session", desc: "
|
|
970
|
-
{ name: "/clear", group: "Session", desc: "
|
|
971
|
-
{ name: "/distill", group: "Session", desc: "
|
|
972
|
-
{ name: "/rewind", group: "Session", desc: "
|
|
973
|
-
{ name: "/exit", group: "Session", desc: "
|
|
974
|
-
{ 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" },
|
|
975
1044
|
]
|
|
976
1045
|
|
|
977
1046
|
async function handleSlash(text) {
|
|
@@ -992,7 +1061,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
992
1061
|
state.lines = []
|
|
993
1062
|
state.streaming = ""
|
|
994
1063
|
clearSession(agent.cwd)
|
|
995
|
-
pushLine("
|
|
1064
|
+
pushLine("New session started (old session archived to slot; /session to view)", C.dim)
|
|
996
1065
|
return
|
|
997
1066
|
case "/exit":
|
|
998
1067
|
cleanup()
|
|
@@ -1001,19 +1070,19 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1001
1070
|
case "/session": {
|
|
1002
1071
|
const slots = listSlots(agent.cwd)
|
|
1003
1072
|
if (slots.length === 0) {
|
|
1004
|
-
pushLine("
|
|
1073
|
+
pushLine("No archived sessions (use /new and old sessions auto-archive to slots)", C.dim)
|
|
1005
1074
|
} else {
|
|
1006
1075
|
const entries = [
|
|
1007
|
-
{ type: "header", text: "
|
|
1008
|
-
...slots.map((s) => ({ type: "item", text:
|
|
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 })),
|
|
1009
1078
|
]
|
|
1010
1079
|
openPicker({
|
|
1011
|
-
title: "
|
|
1080
|
+
title: "Switch Session",
|
|
1012
1081
|
entries,
|
|
1013
1082
|
onSelect: (e) => {
|
|
1014
1083
|
const data = switchToSlot(agent.cwd, e.slot)
|
|
1015
1084
|
if (!data) {
|
|
1016
|
-
pushLine(
|
|
1085
|
+
pushLine(`Slot ${e.slot} not found`, C.dim)
|
|
1017
1086
|
return
|
|
1018
1087
|
}
|
|
1019
1088
|
applySession(agent, data)
|
|
@@ -1024,7 +1093,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1024
1093
|
if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
|
|
1025
1094
|
state.tasks = []
|
|
1026
1095
|
}
|
|
1027
|
-
pushLabel(`──
|
|
1096
|
+
pushLabel(`── Switched to slot ${e.slot} (${data.history.length} messages) ──`, C.warn)
|
|
1028
1097
|
render()
|
|
1029
1098
|
},
|
|
1030
1099
|
})
|
|
@@ -1033,7 +1102,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1033
1102
|
}
|
|
1034
1103
|
case "/reindex": {
|
|
1035
1104
|
const { syncDir, codeSync, docSync } = await import("./memory.mjs")
|
|
1036
|
-
pushLine("[reindex]
|
|
1105
|
+
pushLine("[reindex] Rebuilding index...", C.tool)
|
|
1037
1106
|
agent.memory.db.prepare("DELETE FROM files").run()
|
|
1038
1107
|
agent.memory.db.prepare("DELETE FROM code_chunks").run()
|
|
1039
1108
|
agent.memory.db.prepare("DELETE FROM doc_chunks").run()
|
|
@@ -1049,26 +1118,26 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1049
1118
|
pushLine(` team: +${s.added} ~${s.updated} -${s.removed}`, C.dim)
|
|
1050
1119
|
}
|
|
1051
1120
|
// 重建代码索引
|
|
1052
|
-
pushLine(` [code]
|
|
1121
|
+
pushLine(` [code] Rebuilding code index...`, C.tool)
|
|
1053
1122
|
const cr = await codeSync(agent.memory, agent.cwd, {
|
|
1054
1123
|
onProgress: (p) => {
|
|
1055
1124
|
if (p.phase === "index" && p.current % 20 === 0) {
|
|
1056
|
-
pushLine(`
|
|
1125
|
+
pushLine(` Indexing... ${p.current}/${p.total}`, C.dim)
|
|
1057
1126
|
}
|
|
1058
1127
|
}
|
|
1059
1128
|
})
|
|
1060
|
-
pushLine(` code: ${cr.total}
|
|
1129
|
+
pushLine(` code: ${cr.total} files, +${cr.updated} ~${cr.skipped} -${cr.removed}`, C.dim)
|
|
1061
1130
|
// 重建文档索引
|
|
1062
|
-
pushLine(` [doc]
|
|
1131
|
+
pushLine(` [doc] Rebuilding doc index...`, C.tool)
|
|
1063
1132
|
const dr = await docSync(agent.memory, agent.cwd, {
|
|
1064
1133
|
onProgress: (p) => {
|
|
1065
1134
|
if (p.phase === "index" && p.current % 5 === 0) {
|
|
1066
|
-
pushLine(`
|
|
1135
|
+
pushLine(` Indexing... ${p.current}/${p.total}`, C.dim)
|
|
1067
1136
|
}
|
|
1068
1137
|
}
|
|
1069
1138
|
})
|
|
1070
|
-
pushLine(` doc: ${dr.total}
|
|
1071
|
-
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)
|
|
1072
1141
|
return
|
|
1073
1142
|
}
|
|
1074
1143
|
case "/distill":
|
|
@@ -1080,7 +1149,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1080
1149
|
const { join, basename } = await import("node:path")
|
|
1081
1150
|
const agPath = join(agent.cwd, "AGENTS.md")
|
|
1082
1151
|
if (existsSync(agPath)) {
|
|
1083
|
-
pushLine(`AGENTS.md
|
|
1152
|
+
pushLine(`AGENTS.md already exists: ${agPath}`, C.warn)
|
|
1084
1153
|
return
|
|
1085
1154
|
}
|
|
1086
1155
|
|
|
@@ -1131,45 +1200,45 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1131
1200
|
|
|
1132
1201
|
const lines = [`# ${name}`, ""]
|
|
1133
1202
|
if (lang) {
|
|
1134
|
-
lines.push(`##
|
|
1135
|
-
if (cmds) lines.push(`##
|
|
1203
|
+
lines.push(`## Tech Stack`, "", lang, "")
|
|
1204
|
+
if (cmds) lines.push(`## Commands`, "", cmds, "")
|
|
1136
1205
|
}
|
|
1137
1206
|
|
|
1138
1207
|
const template = lines.join("\n")
|
|
1139
1208
|
await writeFile(agPath, template, "utf8")
|
|
1140
1209
|
pushLabel(`❯ Init`, ansi.bold + C.tool)
|
|
1141
|
-
pushLine(
|
|
1142
|
-
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)
|
|
1143
1212
|
return
|
|
1144
1213
|
}
|
|
1145
1214
|
case "/rewind": {
|
|
1146
1215
|
const { listCheckpoints, rewind, isGitRepo } = await import("./checkpoint.mjs")
|
|
1147
1216
|
if (!isGitRepo(agent.cwd)) {
|
|
1148
|
-
pushLine("[rewind]
|
|
1217
|
+
pushLine("[rewind] not a git repository, checkpoints unavailable", C.error)
|
|
1149
1218
|
return
|
|
1150
1219
|
}
|
|
1151
1220
|
const cps = await listCheckpoints(agent.cwd)
|
|
1152
1221
|
if (cps.length === 0) {
|
|
1153
|
-
pushLine("
|
|
1222
|
+
pushLine("(no checkpoints — created automatically before each task)", C.dim)
|
|
1154
1223
|
return
|
|
1155
1224
|
}
|
|
1156
1225
|
const entries = [
|
|
1157
|
-
{ type: "header", text: "
|
|
1226
|
+
{ type: "header", text: "Checkpoints (↑↓ select, Enter restore, Esc cancel)" },
|
|
1158
1227
|
...cps.slice(0, 12).map((cp) => ({
|
|
1159
1228
|
type: "item",
|
|
1160
|
-
text: `${cp.id} ${new Date(cp.time).toLocaleString()} (+${cp.untracked}
|
|
1229
|
+
text: `${cp.id} ${new Date(cp.time).toLocaleString()} (+${cp.untracked} untracked files)`,
|
|
1161
1230
|
id: cp.id,
|
|
1162
1231
|
})),
|
|
1163
1232
|
]
|
|
1164
1233
|
openPicker({
|
|
1165
|
-
title: "
|
|
1234
|
+
title: "Restore Checkpoint",
|
|
1166
1235
|
entries,
|
|
1167
1236
|
onSelect: async (e) => {
|
|
1168
1237
|
try {
|
|
1169
1238
|
const summary = await rewind(agent.cwd, e.id)
|
|
1170
1239
|
pushLabel(`❯ Rewind`, ansi.bold + C.warn)
|
|
1171
|
-
pushLine(
|
|
1172
|
-
pushLine("
|
|
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)
|
|
1173
1242
|
} catch (error) {
|
|
1174
1243
|
pushLine(`[rewind] ${error.message}`, C.error)
|
|
1175
1244
|
}
|
|
@@ -1188,50 +1257,50 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1188
1257
|
pushLabel(`❯ Plan`, ansi.bold + (agent.planMode ? C.tool : C.dim))
|
|
1189
1258
|
pushLine(
|
|
1190
1259
|
agent.planMode
|
|
1191
|
-
?
|
|
1192
|
-
:
|
|
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.`,
|
|
1193
1262
|
agent.planMode ? C.tool : C.dim,
|
|
1194
1263
|
)
|
|
1195
1264
|
return
|
|
1196
1265
|
}
|
|
1197
1266
|
case "/goal": {
|
|
1198
1267
|
const entries = [
|
|
1199
|
-
{ type: "header", text: agent.goal ?
|
|
1200
|
-
{ type: "item", text: "
|
|
1268
|
+
{ type: "header", text: agent.goal ? `Current goal: ${agent.goal.objective.slice(0, 60)}` : "Actions" },
|
|
1269
|
+
{ type: "item", text: "Set new goal", action: "set" },
|
|
1201
1270
|
]
|
|
1202
1271
|
if (agent.goal) {
|
|
1203
|
-
entries.push({ type: "item", text: "
|
|
1204
|
-
entries.push({ type: "item", text: "
|
|
1272
|
+
entries.push({ type: "item", text: "Cancel goal", action: "cancel" })
|
|
1273
|
+
entries.push({ type: "item", text: "View details", action: "view" })
|
|
1205
1274
|
}
|
|
1206
1275
|
openPicker({
|
|
1207
|
-
title: "
|
|
1276
|
+
title: "Goal",
|
|
1208
1277
|
entries,
|
|
1209
1278
|
onSelect: (e) => {
|
|
1210
1279
|
if (e.action === "view") {
|
|
1211
|
-
const statusText = { active: "
|
|
1280
|
+
const statusText = { active: "active", complete: "completed", blocked: "blocked" }[agent.goal.status] ?? agent.goal.status
|
|
1212
1281
|
pushLabel(`❯ Goal`, ansi.bold + C.warn)
|
|
1213
|
-
pushLine(
|
|
1214
|
-
if (agent.goal.criteria) pushLine(`
|
|
1215
|
-
pushLine(`
|
|
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)
|
|
1216
1285
|
return
|
|
1217
1286
|
}
|
|
1218
1287
|
if (e.action === "cancel") {
|
|
1219
1288
|
agent.goal = null
|
|
1220
1289
|
pushLabel(`❯ Goal`, ansi.bold + C.dim)
|
|
1221
|
-
pushLine(
|
|
1290
|
+
pushLine(`Goal cancelled.`, C.dim)
|
|
1222
1291
|
return
|
|
1223
1292
|
}
|
|
1224
1293
|
// set — 需要输入目标文本
|
|
1225
|
-
askQuestion("
|
|
1294
|
+
askQuestion("Enter goal description (; separates criteria)").then((text) => {
|
|
1226
1295
|
if (!text) return
|
|
1227
1296
|
const semi = text.indexOf(";") >= 0 ? ";" : text.indexOf(";") >= 0 ? ";" : null
|
|
1228
1297
|
const objective = semi ? text.slice(0, semi).trim() : text.trim()
|
|
1229
1298
|
const criteria = semi ? text.slice(semi + 1).trim() : ""
|
|
1230
1299
|
agent.goal = { objective, criteria, setAt: Date.now(), status: "active", turnsUsed: 0, _blockTally: null }
|
|
1231
1300
|
pushLabel(`❯ Goal`, ansi.bold + C.warn)
|
|
1232
|
-
pushLine(
|
|
1233
|
-
if (criteria) pushLine(`
|
|
1234
|
-
else pushLine(` ⚠
|
|
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)
|
|
1235
1304
|
})
|
|
1236
1305
|
},
|
|
1237
1306
|
})
|
|
@@ -1242,7 +1311,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1242
1311
|
const skills = await loadSkills(agent.cwd)
|
|
1243
1312
|
pushLabel(`❯ Skills`, ansi.bold + C.tool)
|
|
1244
1313
|
if (skills.length === 0) {
|
|
1245
|
-
pushLine("
|
|
1314
|
+
pushLine(" (none项目技能——在 .thincoder/skills/ 下创建 .md 文件即可添加)", C.dim)
|
|
1246
1315
|
}
|
|
1247
1316
|
for (const s of skills) {
|
|
1248
1317
|
pushLine(` ${s.name}: ${s.description.slice(0, 100)}`, C.dim)
|
|
@@ -1253,24 +1322,24 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1253
1322
|
case "/mcp": {
|
|
1254
1323
|
const servers = agent.config?.mcp?.servers ?? []
|
|
1255
1324
|
const entries = [
|
|
1256
|
-
{ type: "header", text:
|
|
1257
|
-
{ type: "item", text: "
|
|
1258
|
-
{ type: "item", text: "
|
|
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" },
|
|
1259
1328
|
]
|
|
1260
1329
|
if (servers.length > 0) {
|
|
1261
1330
|
entries.push(
|
|
1262
|
-
{ type: "item", text: "
|
|
1263
|
-
{ type: "item", text: "
|
|
1331
|
+
{ type: "item", text: "Remove server", action: "remove" },
|
|
1332
|
+
{ type: "item", text: "Reconnect server", action: "connect" },
|
|
1264
1333
|
)
|
|
1265
1334
|
}
|
|
1266
1335
|
openPicker({
|
|
1267
|
-
title: "MCP
|
|
1336
|
+
title: "MCP",
|
|
1268
1337
|
entries,
|
|
1269
1338
|
onSelect: async (e) => {
|
|
1270
1339
|
if (e.action === "list") {
|
|
1271
1340
|
pushLabel(`❯ MCP Servers`, ansi.bold + C.tool)
|
|
1272
1341
|
if (servers.length === 0) {
|
|
1273
|
-
pushLine("
|
|
1342
|
+
pushLine(" (none MCP server)", C.dim)
|
|
1274
1343
|
}
|
|
1275
1344
|
for (const srv of servers) {
|
|
1276
1345
|
const connected = agent.tools.some((t) => t._mcpName === srv.name)
|
|
@@ -1284,11 +1353,11 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1284
1353
|
}
|
|
1285
1354
|
if (e.action === "remove") {
|
|
1286
1355
|
const removeEntries = [
|
|
1287
|
-
{ type: "header", text: "
|
|
1356
|
+
{ type: "header", text: "Select server to remove" },
|
|
1288
1357
|
...servers.map((s) => ({ type: "item", text: s.name, name: s.name })),
|
|
1289
1358
|
]
|
|
1290
1359
|
openPicker({
|
|
1291
|
-
title: "
|
|
1360
|
+
title: "Remove MCP",
|
|
1292
1361
|
entries: removeEntries,
|
|
1293
1362
|
onSelect: async (se) => {
|
|
1294
1363
|
const { removeMcpTools } = await import("./mcp.mjs")
|
|
@@ -1296,18 +1365,18 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1296
1365
|
await persistRaw((raw) => { raw.mcp ??= { servers: [] }; raw.mcp.servers = raw.mcp.servers.filter((s) => s.name !== se.name) })
|
|
1297
1366
|
if (agent.config?.mcp?.servers) agent.config.mcp.servers = agent.config.mcp.servers.filter((s) => s.name !== se.name)
|
|
1298
1367
|
pushLabel(`❯ MCP`, ansi.bold + C.tool)
|
|
1299
|
-
pushLine(`${se.name}
|
|
1368
|
+
pushLine(`${se.name} disconnected and removed from config.`, C.tool)
|
|
1300
1369
|
},
|
|
1301
1370
|
})
|
|
1302
1371
|
return
|
|
1303
1372
|
}
|
|
1304
1373
|
if (e.action === "connect") {
|
|
1305
1374
|
const connEntries = [
|
|
1306
|
-
{ type: "header", text: "
|
|
1375
|
+
{ type: "header", text: "Select server to reconnect" },
|
|
1307
1376
|
...servers.map((s) => ({ type: "item", text: s.name, name: s.name })),
|
|
1308
1377
|
]
|
|
1309
1378
|
openPicker({
|
|
1310
|
-
title: "
|
|
1379
|
+
title: "Reconnect MCP",
|
|
1311
1380
|
entries: connEntries,
|
|
1312
1381
|
onSelect: async (se) => {
|
|
1313
1382
|
const srv = servers.find((s) => s.name === se.name)
|
|
@@ -1315,11 +1384,11 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1315
1384
|
const { removeMcpTools, connectMcpServer } = await import("./mcp.mjs")
|
|
1316
1385
|
removeMcpTools(agent, se.name)
|
|
1317
1386
|
try {
|
|
1318
|
-
pushLine(`[mcp]
|
|
1387
|
+
pushLine(`[mcp] Reconnecting ${se.name}...`, C.dim)
|
|
1319
1388
|
const tools = await connectMcpServer(srv)
|
|
1320
1389
|
agent.tools.push(...tools)
|
|
1321
1390
|
pushLabel(`❯ MCP`, ansi.bold + C.tool)
|
|
1322
|
-
pushLine(`${se.name}
|
|
1391
|
+
pushLine(`${se.name} reconnected, ${tools.length} tools available.`, C.tool)
|
|
1323
1392
|
} catch (error) {
|
|
1324
1393
|
pushLine(`[mcp] ${se.name}: ${error.message}`, C.error)
|
|
1325
1394
|
}
|
|
@@ -1328,13 +1397,13 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1328
1397
|
return
|
|
1329
1398
|
}
|
|
1330
1399
|
if (e.action === "add") {
|
|
1331
|
-
askQuestion("输入: <名称> <URL
|
|
1400
|
+
askQuestion("输入: <名称> <URL|Commands> [参数...]\nURL 自动识别: https://… → HTTP, ws://… → WebSocket, 其他 → stdio Commands").then(async (text) => {
|
|
1332
1401
|
if (!text) return
|
|
1333
1402
|
const parts = text.split(/\s+/)
|
|
1334
|
-
if (parts.length < 2) { pushLine("用法: <名称> <URL
|
|
1403
|
+
if (parts.length < 2) { pushLine("用法: <名称> <URL|Commands> [参数...]", C.error); return }
|
|
1335
1404
|
const [name, second, ...extras] = parts
|
|
1336
1405
|
const existing = (agent.config?.mcp?.servers ?? []).find((s) => s.name === name)
|
|
1337
|
-
if (existing) { pushLine(`[mcp] "${name}"
|
|
1406
|
+
if (existing) { pushLine(`[mcp] "${name}" already exists`, C.error); return }
|
|
1338
1407
|
const isWS = /^wss?:\/\//.test(second)
|
|
1339
1408
|
const isHTTP = /^https?:\/\//.test(second)
|
|
1340
1409
|
let srv
|
|
@@ -1355,7 +1424,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1355
1424
|
return
|
|
1356
1425
|
}
|
|
1357
1426
|
|
|
1358
|
-
// ---- header
|
|
1427
|
+
// ---- header 解析 (/mcp add 共享)----
|
|
1359
1428
|
function parseHeaders(pairs) {
|
|
1360
1429
|
const headers = {}
|
|
1361
1430
|
for (const pair of pairs) {
|
|
@@ -1365,7 +1434,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1365
1434
|
return headers
|
|
1366
1435
|
}
|
|
1367
1436
|
|
|
1368
|
-
// ---- /mcp 共享 helper:
|
|
1437
|
+
// ---- /mcp 共享 helper: 保存Config + Connecting ----
|
|
1369
1438
|
async function addAndConnect(srv) {
|
|
1370
1439
|
await persistRaw((raw) => {
|
|
1371
1440
|
raw.mcp ??= { servers: [] }
|
|
@@ -1379,16 +1448,16 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1379
1448
|
agent.config.mcp ??= { servers: [] }
|
|
1380
1449
|
agent.config.mcp.servers.push(srv)
|
|
1381
1450
|
try {
|
|
1382
|
-
pushLine(`[mcp]
|
|
1451
|
+
pushLine(`[mcp] Connecting ${srv.name}...`, C.dim)
|
|
1383
1452
|
const { connectMcpServer } = await import("./mcp.mjs")
|
|
1384
1453
|
const tools = await connectMcpServer(srv)
|
|
1385
1454
|
agent.tools.push(...tools)
|
|
1386
1455
|
pushLabel(`❯ MCP`, ansi.bold + C.tool)
|
|
1387
1456
|
const desc = srv.wsUrl ? srv.wsUrl : srv.url ? srv.url : `${srv.command} ${(srv.args ?? []).join(" ")}`
|
|
1388
|
-
pushLine(`${srv.name} (${desc})
|
|
1457
|
+
pushLine(`${srv.name} (${desc}) connected, ${tools.length} tools:`, C.tool)
|
|
1389
1458
|
for (const t of tools) pushLine(` ${t.name}: ${t.description.slice(0, 100)}`, C.dim)
|
|
1390
1459
|
} catch (error) {
|
|
1391
|
-
pushLine(`[mcp] ${srv.name}: ${error.message}
|
|
1460
|
+
pushLine(`[mcp] ${srv.name}: ${error.message} (config saved, retry after restart)`, C.error)
|
|
1392
1461
|
}
|
|
1393
1462
|
}
|
|
1394
1463
|
case "/auto":
|
|
@@ -1402,8 +1471,8 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1402
1471
|
pushLabel(`❯ Auto`, ansi.bold + (agent.autoApprove ? C.warn : C.tool))
|
|
1403
1472
|
pushLine(
|
|
1404
1473
|
agent.autoApprove
|
|
1405
|
-
? `AUTO
|
|
1406
|
-
: `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.`,
|
|
1407
1476
|
agent.autoApprove ? C.warn : C.dim,
|
|
1408
1477
|
)
|
|
1409
1478
|
return
|
|
@@ -1415,19 +1484,19 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1415
1484
|
const isEffortOnly = spec.thinkApi === "effort"
|
|
1416
1485
|
|
|
1417
1486
|
const entries = [
|
|
1418
|
-
{ type: "header", text: "
|
|
1419
|
-
{ type: "item", text:
|
|
1420
|
-
{ type: "item", text:
|
|
1421
|
-
{ type: "header", text: "
|
|
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" },
|
|
1422
1491
|
...["low", "high", "max"].map((l) => ({
|
|
1423
1492
|
type: "item",
|
|
1424
|
-
text: `${l}${cur.reasoningEffort === l ? " ←
|
|
1493
|
+
text: `${l}${cur.reasoningEffort === l ? " ← current" : ""}`,
|
|
1425
1494
|
action: "effort",
|
|
1426
1495
|
level: l,
|
|
1427
1496
|
})),
|
|
1428
1497
|
]
|
|
1429
1498
|
openPicker({
|
|
1430
|
-
title: "
|
|
1499
|
+
title: "Thinking mode",
|
|
1431
1500
|
entries,
|
|
1432
1501
|
defaultIndex: thinkingEnabled ? 0 : 1,
|
|
1433
1502
|
onSelect: async (e) => {
|
|
@@ -1435,7 +1504,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1435
1504
|
cur.reasoningEffort = e.level
|
|
1436
1505
|
await syncProviderField("reasoningEffort", e.level)
|
|
1437
1506
|
pushLabel(`❯ Think`, ansi.bold + C.tool)
|
|
1438
|
-
pushLine(
|
|
1507
|
+
pushLine(`Reasoning effort set to ${e.level}`, C.tool)
|
|
1439
1508
|
} else {
|
|
1440
1509
|
const enable = e.action === "on"
|
|
1441
1510
|
if (isEffortOnly) {
|
|
@@ -1452,8 +1521,8 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1452
1521
|
else await syncProviderField("reasoningEffort", cur.reasoningEffort)
|
|
1453
1522
|
}
|
|
1454
1523
|
pushLabel(`❯ Think`, ansi.bold + C.tool)
|
|
1455
|
-
pushLine(
|
|
1456
|
-
if (enable) pushLine(
|
|
1524
|
+
pushLine(`Thinking mode已${enable ? "On" : "Off"}`, C.tool)
|
|
1525
|
+
if (enable) pushLine(`Reasoning effort: ${cur.reasoningEffort}`, C.dim)
|
|
1457
1526
|
}
|
|
1458
1527
|
},
|
|
1459
1528
|
})
|
|
@@ -1465,22 +1534,22 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1465
1534
|
}
|
|
1466
1535
|
case "/provider": {
|
|
1467
1536
|
const entries = [
|
|
1468
|
-
{ type: "header", text:
|
|
1469
|
-
{ type: "item", text: "
|
|
1470
|
-
{ type: "item", text: "
|
|
1537
|
+
{ type: "header", text: `${agent.providers.length} providers` },
|
|
1538
|
+
{ type: "item", text: "View list", action: "list" },
|
|
1539
|
+
{ type: "item", text: "Add provider", action: "add" },
|
|
1471
1540
|
]
|
|
1472
1541
|
if (agent.providers.length > 0) {
|
|
1473
1542
|
entries.push(
|
|
1474
|
-
{ type: "item", text: "
|
|
1543
|
+
{ type: "item", text: "Remove provider", action: "remove" },
|
|
1475
1544
|
)
|
|
1476
1545
|
}
|
|
1477
1546
|
if (!agent.provider.apiKey) {
|
|
1478
|
-
entries.push({ type: "item", text: "
|
|
1547
|
+
entries.push({ type: "item", text: "Set API Key", action: "key" })
|
|
1479
1548
|
} else {
|
|
1480
|
-
entries.push({ type: "item", text: "
|
|
1549
|
+
entries.push({ type: "item", text: "Change API Key", action: "key" })
|
|
1481
1550
|
}
|
|
1482
1551
|
openPicker({
|
|
1483
|
-
title: "
|
|
1552
|
+
title: "Providers",
|
|
1484
1553
|
entries,
|
|
1485
1554
|
onSelect: async (e) => {
|
|
1486
1555
|
if (e.action === "list") {
|
|
@@ -1488,7 +1557,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1488
1557
|
for (const p of agent.providers) {
|
|
1489
1558
|
const active = p.name === agent.activeProvider
|
|
1490
1559
|
pushLine(
|
|
1491
|
-
`${active ? " ▸" : " "} ${p.name.padEnd(12)} ${p.model.padEnd(20)} ${p.baseURL}${p.apiKey ? " ●key" : "
|
|
1560
|
+
`${active ? " ▸" : " "} ${p.name.padEnd(12)} ${p.model.padEnd(20)} ${p.baseURL}${p.apiKey ? " ●key" : " ○nonekey"}${active ? " ← current" : ""}`,
|
|
1492
1561
|
active ? C.tool : C.dim,
|
|
1493
1562
|
)
|
|
1494
1563
|
}
|
|
@@ -1497,22 +1566,22 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1497
1566
|
if (e.action === "remove") {
|
|
1498
1567
|
const candidates = agent.providers.filter((p) => p.name !== agent.activeProvider)
|
|
1499
1568
|
if (candidates.length === 0) {
|
|
1500
|
-
pushLine("
|
|
1569
|
+
pushLine("Cannot remove current provider (switch to another with /model first)", C.warn)
|
|
1501
1570
|
return
|
|
1502
1571
|
}
|
|
1503
1572
|
const removeEntries = [
|
|
1504
|
-
{ type: "header", text: "选择要移除的 provider
|
|
1573
|
+
{ type: "header", text: "选择要移除的 provider (current使用的不可移除)" },
|
|
1505
1574
|
...candidates.map((p) => ({ type: "item", text: `${p.name} (${p.model})`, name: p.name })),
|
|
1506
1575
|
]
|
|
1507
1576
|
openPicker({
|
|
1508
|
-
title: "
|
|
1577
|
+
title: "Remove Provider",
|
|
1509
1578
|
entries: removeEntries,
|
|
1510
1579
|
onSelect: async (se) => {
|
|
1511
1580
|
const at = agent.providers.findIndex((p) => p.name === se.name)
|
|
1512
1581
|
agent.providers.splice(at, 1)
|
|
1513
1582
|
await persistRaw((raw) => { raw.providers = agent.providers })
|
|
1514
1583
|
pushLabel(`❯ Provider`, ansi.bold + C.tool)
|
|
1515
|
-
pushLine(
|
|
1584
|
+
pushLine(`Removed ${se.name}`, C.tool)
|
|
1516
1585
|
},
|
|
1517
1586
|
})
|
|
1518
1587
|
return
|
|
@@ -1520,47 +1589,47 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1520
1589
|
if (e.action === "add") {
|
|
1521
1590
|
// Add needs text input: name baseURL model
|
|
1522
1591
|
askQuestion(
|
|
1523
|
-
`输入: <名称> <baseURL>
|
|
1592
|
+
`输入: <名称> <baseURL> <model>\n预设可用: ${Object.keys(PRESETS).join(", ")}\nor just a preset name (e.g. deepseek) for auto-fill`,
|
|
1524
1593
|
).then(async (text) => {
|
|
1525
1594
|
if (!text) return
|
|
1526
1595
|
const parts = text.split(/\s+/)
|
|
1527
1596
|
const name = parts[0]
|
|
1528
1597
|
if (!name) return
|
|
1529
1598
|
if (agent.providers.some((p) => p.name === name)) {
|
|
1530
|
-
pushLine(`"${name}"
|
|
1599
|
+
pushLine(`"${name}" already exists;先 /provider → 移除`, C.warn)
|
|
1531
1600
|
return
|
|
1532
1601
|
}
|
|
1533
1602
|
const preset = PRESETS[name]
|
|
1534
1603
|
const baseURL = (parts[1] ?? preset?.baseURL)?.replace(/\/+$/, "")
|
|
1535
1604
|
const model = parts[2] ?? preset?.model
|
|
1536
1605
|
if (!baseURL || !model) {
|
|
1537
|
-
pushLine(
|
|
1606
|
+
pushLine(`Missing args: ${name} <baseURL> <model>`, C.error)
|
|
1538
1607
|
return
|
|
1539
1608
|
}
|
|
1540
|
-
if (!/^https?:\/\//.test(baseURL)) { pushLine(`baseURL
|
|
1609
|
+
if (!/^https?:\/\//.test(baseURL)) { pushLine(`baseURL must start with http(s)://`, C.error); return }
|
|
1541
1610
|
agent.providers.push({ name, baseURL, model, ...(preset?.desc ? { desc: preset.desc } : {}) })
|
|
1542
1611
|
await persistRaw((raw) => { raw.providers = agent.providers })
|
|
1543
1612
|
pushLabel(`❯ Provider`, ansi.bold + C.tool)
|
|
1544
|
-
pushLine(
|
|
1545
|
-
pushLine(
|
|
1613
|
+
pushLine(`Added ${name} (${baseURL} / ${model})`, C.tool)
|
|
1614
|
+
pushLine(`Next: /provider → Set Key`, C.dim)
|
|
1546
1615
|
})
|
|
1547
1616
|
return
|
|
1548
1617
|
}
|
|
1549
1618
|
if (e.action === "key") {
|
|
1550
1619
|
// Key: pick which provider, then prompt for key
|
|
1551
1620
|
const keyEntries = [
|
|
1552
|
-
{ type: "header", text: "
|
|
1621
|
+
{ type: "header", text: "Select provider to configure key" },
|
|
1553
1622
|
...agent.providers.map((p) => ({
|
|
1554
1623
|
type: "item",
|
|
1555
|
-
text: `${p.name}${p.name === agent.activeProvider ? " ←
|
|
1624
|
+
text: `${p.name}${p.name === agent.activeProvider ? " ← current" : ""}${p.apiKey ? " ●has key" : " ○nonekey"}`,
|
|
1556
1625
|
name: p.name,
|
|
1557
1626
|
})),
|
|
1558
1627
|
]
|
|
1559
1628
|
openPicker({
|
|
1560
|
-
title: "
|
|
1629
|
+
title: "Configure API Key",
|
|
1561
1630
|
entries: keyEntries,
|
|
1562
1631
|
onSelect: (se) => {
|
|
1563
|
-
askQuestion(
|
|
1632
|
+
askQuestion(`Enter API key for ${se.name}:`).then(async (key) => {
|
|
1564
1633
|
if (!key) return
|
|
1565
1634
|
await setProviderKey(se.name, key)
|
|
1566
1635
|
})
|
|
@@ -1573,29 +1642,29 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1573
1642
|
}
|
|
1574
1643
|
case "/config": {
|
|
1575
1644
|
const entries = [
|
|
1576
|
-
{ type: "header", text: "
|
|
1577
|
-
{ type: "item", text: "
|
|
1578
|
-
{ type: "item", text: "
|
|
1579
|
-
{ type: "item", text: "
|
|
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" },
|
|
1580
1649
|
]
|
|
1581
1650
|
openPicker({
|
|
1582
|
-
title: "
|
|
1651
|
+
title: "Config",
|
|
1583
1652
|
entries,
|
|
1584
1653
|
onSelect: async (e) => {
|
|
1585
1654
|
if (e.action === "view") {
|
|
1586
1655
|
const { configPath: cp } = await import("./config.mjs")
|
|
1587
|
-
pushLabel(`❯
|
|
1588
|
-
pushLine(
|
|
1656
|
+
pushLabel(`❯ Config`, ansi.bold + C.tool)
|
|
1657
|
+
pushLine(`Active: ${agent.activeProvider} / ${agent.provider.model}`, C.dim)
|
|
1589
1658
|
pushLine(`Key: ${maskKey(agent.provider.apiKey)}`, C.dim)
|
|
1590
1659
|
const ac = agent.config?.agent ?? {}
|
|
1591
1660
|
const tn = `${ac.compactThreshold ?? 100000}${ac.compactThresholdAuto ? " (auto)" : ""}`
|
|
1592
1661
|
pushLine(`agent: maxTurns=${ac.maxTurns ?? 100} | compactThreshold=${tn}`, C.dim)
|
|
1593
|
-
pushLine(`embedding: ${agent.memory?.embedder ? `enabled (${agent.config?.embedding?.model ?? ""})` : "disabled
|
|
1594
|
-
pushLine(
|
|
1662
|
+
pushLine(`embedding: ${agent.memory?.embedder ? `enabled (${agent.config?.embedding?.model ?? ""})` : "disabled (FTS only)"}`, C.dim)
|
|
1663
|
+
pushLine(`Config文件: ${cp}`, C.dim)
|
|
1595
1664
|
return
|
|
1596
1665
|
}
|
|
1597
1666
|
if (e.action === "embedkey") {
|
|
1598
|
-
askQuestion("
|
|
1667
|
+
askQuestion("Enter embedding API key (default: SiliconFlow bge-m3):").then(async (key) => {
|
|
1599
1668
|
if (!key) return
|
|
1600
1669
|
agent.config.embedding ??= {}
|
|
1601
1670
|
agent.config.embedding.apiKey = key
|
|
@@ -1605,16 +1674,16 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1605
1674
|
agent.memory.embedder = createEmbedder(agent.config.embedding)
|
|
1606
1675
|
}
|
|
1607
1676
|
pushLabel(`❯ Config`, ansi.bold + C.tool)
|
|
1608
|
-
pushLine(`
|
|
1677
|
+
pushLine(`Embedding key saved, vector search enabled`, C.tool)
|
|
1609
1678
|
})
|
|
1610
1679
|
return
|
|
1611
1680
|
}
|
|
1612
1681
|
if (e.action === "set") {
|
|
1613
|
-
askQuestion("
|
|
1682
|
+
askQuestion("Enter: <path> <value> (e.g. agent.maxTurns 80, supports a.b nesting):").then(async (text) => {
|
|
1614
1683
|
if (!text) return
|
|
1615
1684
|
const parts = text.split(/\s+/)
|
|
1616
1685
|
const [path, value] = [parts[0], parts.slice(1).join(" ")]
|
|
1617
|
-
if (!path || !value) { pushLine("
|
|
1686
|
+
if (!path || !value) { pushLine("Usage: <path> <value> e.g. agent.maxTurns 80", C.error); return }
|
|
1618
1687
|
try {
|
|
1619
1688
|
const { configPath, loadConfig, saveConfig } = await import("./config.mjs")
|
|
1620
1689
|
const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
|
|
@@ -1629,9 +1698,9 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1629
1698
|
agent.activeProvider = cfg.activeProvider
|
|
1630
1699
|
agent.config = cfg
|
|
1631
1700
|
pushLabel(`❯ Config`, ansi.bold + C.tool)
|
|
1632
|
-
pushLine(
|
|
1701
|
+
pushLine(`Saved: ${path} = ${value}`, C.tool)
|
|
1633
1702
|
} catch (error) {
|
|
1634
|
-
pushLine(
|
|
1703
|
+
pushLine(`Save failed: ${error.message}`, C.error)
|
|
1635
1704
|
}
|
|
1636
1705
|
})
|
|
1637
1706
|
}
|
|
@@ -1662,7 +1731,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1662
1731
|
return
|
|
1663
1732
|
}
|
|
1664
1733
|
default:
|
|
1665
|
-
pushLine(`Unknown command: ${cmd}
|
|
1734
|
+
pushLine(`Unknown command: ${cmd} (/help 查看可用Commands)`, C.error)
|
|
1666
1735
|
return
|
|
1667
1736
|
}
|
|
1668
1737
|
}
|
|
@@ -1673,18 +1742,18 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1673
1742
|
return `${key.slice(0, 5)}…${key.slice(-4)}`
|
|
1674
1743
|
}
|
|
1675
1744
|
|
|
1676
|
-
/** Tab
|
|
1745
|
+
/** Tab 补全候选:Commands名 / 子Commands / provider 名 / 预设名 / think 参数 */
|
|
1677
1746
|
function completions(input) {
|
|
1678
1747
|
if (!input.startsWith("/")) return []
|
|
1679
1748
|
const parts = input.split(/\s+/)
|
|
1680
|
-
// 还在敲第一个 token
|
|
1749
|
+
// 还在敲第一个 token:补Commands名
|
|
1681
1750
|
if (parts.length === 1) {
|
|
1682
1751
|
return SLASH_COMMANDS.filter((c) => c.name.startsWith(parts[0])).map((c) => c.name)
|
|
1683
1752
|
}
|
|
1684
1753
|
const cmd = parts[0]
|
|
1685
|
-
const last = parts.at(-1) //
|
|
1754
|
+
const last = parts.at(-1) // 结尾是空格时Enter API key for "",即列出全部候选
|
|
1686
1755
|
const head = parts.slice(0, -1).join(" ")
|
|
1687
|
-
const argIndex = parts.length - 2 //
|
|
1756
|
+
const argIndex = parts.length - 2 // 正在敲第几个参数 (0 基)
|
|
1688
1757
|
const match = (cands) => cands.filter((c) => c.startsWith(last)).map((c) => `${head} ${c}`)
|
|
1689
1758
|
if (cmd === "/model" && argIndex === 0) return match(agent.providers.map((p) => p.name))
|
|
1690
1759
|
if (cmd === "/provider") {
|
|
@@ -1722,7 +1791,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1722
1791
|
render()
|
|
1723
1792
|
}
|
|
1724
1793
|
|
|
1725
|
-
/**
|
|
1794
|
+
/** 读Config文件 → 修改 → 写回;文件not found时从空对象开始 */
|
|
1726
1795
|
async function persistRaw(mutate) {
|
|
1727
1796
|
const { saveConfig, configPath } = await import("./config.mjs")
|
|
1728
1797
|
const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
|
|
@@ -1730,7 +1799,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1730
1799
|
saveConfig(raw)
|
|
1731
1800
|
}
|
|
1732
1801
|
|
|
1733
|
-
/**
|
|
1802
|
+
/** 把current激活 provider 的某个字段同步到 providers 列表并持久化 */
|
|
1734
1803
|
async function syncProviderField(field, value) {
|
|
1735
1804
|
const target = agent.providers.find((p) => p.name === agent.activeProvider)
|
|
1736
1805
|
if (!target) return
|
|
@@ -1742,12 +1811,12 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1742
1811
|
})
|
|
1743
1812
|
}
|
|
1744
1813
|
|
|
1745
|
-
// ----------------------------------------------------------
|
|
1814
|
+
// ---------------------------------------------------------- 模型选择器 (/model)
|
|
1746
1815
|
|
|
1747
1816
|
const pickerItems = () => state.picker?.entries.filter((e) => e.type === "item") ?? []
|
|
1748
1817
|
|
|
1749
1818
|
/** 打开通用列表选择器。entries 含 { type: "header"|"item", text, note?, ...extra },
|
|
1750
|
-
* onSelect
|
|
1819
|
+
* onSelect 拿到选中条目 (含 extra 字段透传),onCancel 在 Esc 时调。 */
|
|
1751
1820
|
function openPicker({ title, entries, onSelect, onCancel, defaultIndex = 0 }) {
|
|
1752
1821
|
state.picker = { title, entries, lines: [], index: defaultIndex, scroll: 0, selectedLine: 0, onSelect, onCancel }
|
|
1753
1822
|
renderPickerLines()
|
|
@@ -1785,17 +1854,17 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1785
1854
|
render()
|
|
1786
1855
|
}
|
|
1787
1856
|
|
|
1788
|
-
// ==========
|
|
1857
|
+
// ========== 模型选择器 (基于通用 picker,异步拉取远端模型列表) ==========
|
|
1789
1858
|
|
|
1790
1859
|
async function openModelPicker() {
|
|
1791
1860
|
const entries = []
|
|
1792
1861
|
for (const p of agent.providers) {
|
|
1793
|
-
entries.push({ type: "header", text: p.name, note: `${p.baseURL}${p.apiKey ? "" : "
|
|
1862
|
+
entries.push({ type: "header", text: p.name, note: `${p.baseURL}${p.apiKey ? "" : " (no key)"} loading...` })
|
|
1794
1863
|
entries.push({ type: "item", text: p.model, provider: p.name, model: p.model })
|
|
1795
1864
|
}
|
|
1796
1865
|
const onSelect = (e) => selectModel(e).catch((err) => pushLine(`[error] ${err.message}`, C.error))
|
|
1797
|
-
openPicker({ title: "
|
|
1798
|
-
//
|
|
1866
|
+
openPicker({ title: "Select Model", entries, onSelect })
|
|
1867
|
+
// 默认选中current在用的模型
|
|
1799
1868
|
const current = pickerItems().findIndex(
|
|
1800
1869
|
(e) => e.provider === agent.activeProvider && e.model === agent.provider.model,
|
|
1801
1870
|
)
|
|
@@ -1806,7 +1875,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1806
1875
|
await Promise.all(
|
|
1807
1876
|
agent.providers.map(async (p) => {
|
|
1808
1877
|
const header = entries.find((e) => e.type === "header" && e.provider === undefined && e.text === p.name)
|
|
1809
|
-
const noteBase = `${p.baseURL}${p.apiKey ? "" : "
|
|
1878
|
+
const noteBase = `${p.baseURL}${p.apiKey ? "" : " (no key)"}`
|
|
1810
1879
|
try {
|
|
1811
1880
|
const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[p.name]
|
|
1812
1881
|
let apiKey = p.apiKey
|
|
@@ -1824,65 +1893,65 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1824
1893
|
)
|
|
1825
1894
|
if (header) header.note = noteBase
|
|
1826
1895
|
} catch (error) {
|
|
1827
|
-
if (header) header.note = `${noteBase}
|
|
1896
|
+
if (header) header.note = `${noteBase} (fetch failed: ${sliceByWidth(error.message, 60)})`
|
|
1828
1897
|
}
|
|
1829
1898
|
if (state.picker?.entries === entries) renderPickerLines()
|
|
1830
1899
|
}),
|
|
1831
1900
|
)
|
|
1832
1901
|
}
|
|
1833
1902
|
|
|
1834
|
-
/** 给指定 provider 写 key
|
|
1903
|
+
/** 给指定 provider 写 key (内存 + Config文件);若它是current激活的,同步运行时 */
|
|
1835
1904
|
async function setProviderKey(name, key) {
|
|
1836
1905
|
const target = agent.providers.find((p) => p.name === name)
|
|
1837
1906
|
if (!target) {
|
|
1838
|
-
pushLine(
|
|
1907
|
+
pushLine(`Provider "${name}"`, C.error)
|
|
1839
1908
|
return
|
|
1840
1909
|
}
|
|
1841
1910
|
target.apiKey = key
|
|
1842
1911
|
if (name === agent.activeProvider) agent.provider.apiKey = key
|
|
1843
1912
|
await persistRaw((raw) => { raw.providers = agent.providers })
|
|
1844
1913
|
pushLabel(`❯ Provider`, ansi.bold + C.tool)
|
|
1845
|
-
pushLine(`
|
|
1914
|
+
pushLine(`API key saved to ${name}`, C.tool)
|
|
1846
1915
|
}
|
|
1847
1916
|
|
|
1848
|
-
// ----------------------------------------------------------
|
|
1917
|
+
// ---------------------------------------------------------- 初始Config向导 (首次启动)
|
|
1849
1918
|
|
|
1850
|
-
/** 菜单步的候选项:已有 provider
|
|
1919
|
+
/** 菜单步的候选项:已有 provider (no key 的标注)+ 未添加的预设 + 自定义 */
|
|
1851
1920
|
function wizardProviderItems() {
|
|
1852
1921
|
const items = []
|
|
1853
1922
|
for (const p of agent.providers) {
|
|
1854
|
-
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"})` })
|
|
1855
1924
|
}
|
|
1856
1925
|
for (const [name, p] of Object.entries(PRESETS)) {
|
|
1857
1926
|
if (!agent.providers.some((x) => x.name === name)) {
|
|
1858
|
-
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})` })
|
|
1859
1928
|
}
|
|
1860
1929
|
}
|
|
1861
|
-
items.push({ kind: "custom", name: null, label: "
|
|
1930
|
+
items.push({ kind: "custom", name: null, label: "Custom endpoint…" })
|
|
1862
1931
|
return items
|
|
1863
1932
|
}
|
|
1864
1933
|
|
|
1865
|
-
/** 文本步骤定义:提示语 +
|
|
1934
|
+
/** 文本步骤定义:提示语 + 校验 (通过返回 true,否则返回错误文案) */
|
|
1866
1935
|
const WIZARD_STEPS = {
|
|
1867
1936
|
name: {
|
|
1868
|
-
prompt: "给这个 provider
|
|
1937
|
+
prompt: "给这个 provider 起个名字 (字母/数字/-/_,如 my-openai)",
|
|
1869
1938
|
validate: (v) =>
|
|
1870
|
-
(/^[\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",
|
|
1871
1940
|
},
|
|
1872
1941
|
baseURL: {
|
|
1873
|
-
prompt: "输入 baseURL
|
|
1874
|
-
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)://",
|
|
1875
1944
|
},
|
|
1876
1945
|
model: {
|
|
1877
|
-
prompt: "
|
|
1878
|
-
validate: (v) => v.length > 0 || "
|
|
1946
|
+
prompt: "输入模型名 (如 gpt-4o)",
|
|
1947
|
+
validate: (v) => v.length > 0 || "Model name required",
|
|
1879
1948
|
},
|
|
1880
1949
|
key: {
|
|
1881
1950
|
prompt: "输入 API key",
|
|
1882
1951
|
validate: (v) => v.length > 0 || "key 不能为空",
|
|
1883
1952
|
},
|
|
1884
1953
|
embedkey: {
|
|
1885
|
-
prompt: "可选:embedding API key
|
|
1954
|
+
prompt: "可选:embedding API key (SiliconFlow,记忆向量检索用;直接回车跳过)",
|
|
1886
1955
|
validate: () => true, // 可跳过
|
|
1887
1956
|
},
|
|
1888
1957
|
}
|
|
@@ -1898,7 +1967,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1898
1967
|
if (!w) return
|
|
1899
1968
|
const lines = []
|
|
1900
1969
|
if (w.step === "provider") {
|
|
1901
|
-
lines.push({ text: "
|
|
1970
|
+
lines.push({ text: " Choose a model provider:", color: C.text })
|
|
1902
1971
|
wizardProviderItems().forEach((it, i) => {
|
|
1903
1972
|
if (i === w.index) w.selectedLine = lines.length
|
|
1904
1973
|
lines.push({
|
|
@@ -1908,11 +1977,11 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1908
1977
|
})
|
|
1909
1978
|
} else {
|
|
1910
1979
|
const f = w.fields
|
|
1911
|
-
if (f.name) lines.push({ text: `
|
|
1980
|
+
if (f.name) lines.push({ text: ` Provider: ${f.name}`, color: C.dim })
|
|
1912
1981
|
if (f.baseURL) lines.push({ text: ` baseURL: ${f.baseURL}`, color: C.dim })
|
|
1913
1982
|
if (f.model) lines.push({ text: ` 模型: ${f.model}`, color: C.dim })
|
|
1914
1983
|
lines.push({ text: ` ❯ ${WIZARD_STEPS[w.step].prompt}`, color: ansi.bold + C.text })
|
|
1915
|
-
lines.push({ text: "
|
|
1984
|
+
lines.push({ text: " (type in input box below)", color: C.dim })
|
|
1916
1985
|
w.selectedLine = 0
|
|
1917
1986
|
}
|
|
1918
1987
|
if (w.error) lines.push({ text: ` ${w.error}`, color: C.error })
|
|
@@ -1955,11 +2024,11 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1955
2024
|
|
|
1956
2025
|
function cancelWizard() {
|
|
1957
2026
|
state.wizard = null
|
|
1958
|
-
pushLine("
|
|
2027
|
+
pushLine("已跳过初始Config。之后随时可用 /provider add 添加Provider、/provider key 配 key。", C.dim)
|
|
1959
2028
|
render()
|
|
1960
2029
|
}
|
|
1961
2030
|
|
|
1962
|
-
/** 向导完成:写入 provider
|
|
2031
|
+
/** 向导完成:写入 provider (有则更新)、设为激活、持久化,然后接模型选择器 */
|
|
1963
2032
|
async function finishWizard() {
|
|
1964
2033
|
const f = state.wizard.fields
|
|
1965
2034
|
state.wizard = null
|
|
@@ -1978,7 +2047,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1978
2047
|
})
|
|
1979
2048
|
agent.config.activeProvider = f.name
|
|
1980
2049
|
pushLabel(`❯ Setup`, ansi.bold + C.tool)
|
|
1981
|
-
pushLine(
|
|
2050
|
+
pushLine(`Setup complete: ${f.name} / ${f.model} (saved to config)`, C.tool)
|
|
1982
2051
|
// embedding key:配了就启用向量检索,没配提示事后通道
|
|
1983
2052
|
if (f.embedkey) {
|
|
1984
2053
|
agent.config.embedding ??= {}
|
|
@@ -1988,11 +2057,11 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1988
2057
|
const { createEmbedder } = await import("./embedding.mjs")
|
|
1989
2058
|
agent.memory.embedder = createEmbedder(agent.config.embedding)
|
|
1990
2059
|
}
|
|
1991
|
-
pushLine(
|
|
2060
|
+
pushLine(`Vector search enabled (${agent.config.embedding.model ?? "BAAI/bge-m3"})`, C.tool)
|
|
1992
2061
|
} else {
|
|
1993
|
-
pushLine(
|
|
2062
|
+
pushLine(`向量检索未启用 (记忆退化为纯文本检索);之后可 /config embedkey <key> On`, C.dim)
|
|
1994
2063
|
}
|
|
1995
|
-
pushLine(
|
|
2064
|
+
pushLine(`Select model (Esc to keep ${f.model})`, C.dim)
|
|
1996
2065
|
openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
1997
2066
|
}
|
|
1998
2067
|
|
|
@@ -2014,7 +2083,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
2014
2083
|
const { resolveCompactThreshold } = await import("./config.mjs")
|
|
2015
2084
|
const { value } = resolveCompactThreshold(null, item.model)
|
|
2016
2085
|
agent.config.agent.compactThreshold = value
|
|
2017
|
-
thresholdNote =
|
|
2086
|
+
thresholdNote = `, compact threshold adjusted to ${value}`
|
|
2018
2087
|
}
|
|
2019
2088
|
await persistRaw((raw) => {
|
|
2020
2089
|
raw.providers = agent.providers
|
|
@@ -2022,14 +2091,14 @@ export async function startTUI(agent, opts = {}) {
|
|
|
2022
2091
|
})
|
|
2023
2092
|
agent.config.activeProvider = item.provider
|
|
2024
2093
|
pushLabel(`❯ Model`, ansi.bold + C.tool)
|
|
2025
|
-
pushLine(
|
|
2026
|
-
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)
|
|
2027
2096
|
}
|
|
2028
2097
|
|
|
2029
|
-
/** /distill
|
|
2098
|
+
/** /distill:从current会话提取候选,逐条 y/n 确认后入库 */
|
|
2030
2099
|
async function runDistill() {
|
|
2031
2100
|
if (agent.history.length === 0) {
|
|
2032
|
-
pushLine("[distill]
|
|
2101
|
+
pushLine("[distill] current会话为空,没有可提取的内容", C.dim)
|
|
2033
2102
|
return
|
|
2034
2103
|
}
|
|
2035
2104
|
state.processing = true
|
|
@@ -2037,17 +2106,17 @@ export async function startTUI(agent, opts = {}) {
|
|
|
2037
2106
|
render()
|
|
2038
2107
|
try {
|
|
2039
2108
|
const { extractCandidates, historyToTranscript, saveCandidate } = await import("./distill.mjs")
|
|
2040
|
-
pushLine("[distill]
|
|
2109
|
+
pushLine("[distill] Analyzing session...", C.tool)
|
|
2041
2110
|
const candidates = await extractCandidates(agent.provider, historyToTranscript(agent.history))
|
|
2042
2111
|
if (candidates.length === 0) {
|
|
2043
|
-
pushLine("[distill]
|
|
2112
|
+
pushLine("[distill] No knowledge worth saving from this session", C.dim)
|
|
2044
2113
|
return
|
|
2045
2114
|
}
|
|
2046
2115
|
let saved = 0
|
|
2047
2116
|
for (const c of candidates) {
|
|
2048
|
-
pushLine(`──
|
|
2117
|
+
pushLine(`── Candidate [${c.type}] ${c.title} (scope: ${c.scope ?? "personal"})`, C.warn)
|
|
2049
2118
|
for (const line of c.content.split("\n").slice(0, 6)) pushLine(` ${line}`, C.dim)
|
|
2050
|
-
if (c.type === "rule") pushLine(" (rule
|
|
2119
|
+
if (c.type === "rule") pushLine(" (rule type — consider writing manually; press y to extract)", C.warn)
|
|
2051
2120
|
const accept = await askPermission("distill-save", { title: c.title })
|
|
2052
2121
|
if (!accept) {
|
|
2053
2122
|
pushLine(" skipped", C.dim)
|
|
@@ -2057,7 +2126,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
2057
2126
|
pushLine(` saved -> ${where}`, C.tool)
|
|
2058
2127
|
saved++
|
|
2059
2128
|
}
|
|
2060
|
-
pushLine(`[distill]
|
|
2129
|
+
pushLine(`[distill] Done: saved ${saved}/${candidates.length} 条`, C.tool)
|
|
2061
2130
|
} catch (error) {
|
|
2062
2131
|
pushLine(`[distill] error: ${error.message}`, C.error)
|
|
2063
2132
|
} finally {
|
|
@@ -2071,7 +2140,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
2071
2140
|
|
|
2072
2141
|
// keypress 挂在过滤后的 keyStream 上:鼠标序列已在上游滤网中处理并剥除
|
|
2073
2142
|
keyStream.on("keypress", (str, key = {}) => {
|
|
2074
|
-
// 权限确认态:y 批准 / n 拒绝 / a
|
|
2143
|
+
// 权限确认态:y 批准 / n 拒绝 / a 批准并On AUTO (后续不再询问)
|
|
2075
2144
|
if (state.permission) {
|
|
2076
2145
|
const answer = (str || "").toLowerCase()
|
|
2077
2146
|
const isContinue = state.permission.name === "continue"
|
|
@@ -2085,10 +2154,10 @@ export async function startTUI(agent, opts = {}) {
|
|
|
2085
2154
|
agent.autoApprove = true
|
|
2086
2155
|
agent._pendingReminders = agent._pendingReminders ?? []
|
|
2087
2156
|
agent._pendingReminders.push("[System reminder: AUTO mode is now ON. All tool calls are automatically approved. Use /auto to disable.]")
|
|
2088
|
-
pushLine(` [auto] AUTO
|
|
2157
|
+
pushLine(` [auto] AUTO 已On:后续工具调用不再询问 (/auto Off)`, C.warn)
|
|
2089
2158
|
}
|
|
2090
2159
|
const approved = answer === "y" || (answer === "a" && !isContinue)
|
|
2091
|
-
//
|
|
2160
|
+
// 决定落痕:对话区留下批准/拒绝记录 (continue 询问有自己的输出,不重复记)
|
|
2092
2161
|
if (!isContinue) {
|
|
2093
2162
|
pushLine(` [${approved ? "approved" : "denied"}] ${name}`, approved ? C.dim : C.error)
|
|
2094
2163
|
}
|
|
@@ -2150,7 +2219,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
2150
2219
|
if (key.ctrl && key.name === "c") {
|
|
2151
2220
|
if (state.processing && state.controller) {
|
|
2152
2221
|
state.controller.abort()
|
|
2153
|
-
pushLine("[
|
|
2222
|
+
pushLine("[Aborting…]", C.warn)
|
|
2154
2223
|
render()
|
|
2155
2224
|
return
|
|
2156
2225
|
}
|
|
@@ -2177,7 +2246,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
2177
2246
|
return
|
|
2178
2247
|
}
|
|
2179
2248
|
|
|
2180
|
-
//
|
|
2249
|
+
// 初始Config向导:菜单步 ↑↓/Enter/Esc;文本步 Enter 提交、Esc 取消,编辑键落到正常输入
|
|
2181
2250
|
if (state.wizard) {
|
|
2182
2251
|
const w = state.wizard
|
|
2183
2252
|
if (key.name === "escape") {
|
|
@@ -2219,7 +2288,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
2219
2288
|
|
|
2220
2289
|
if (state.processing) return // 处理中锁定输入
|
|
2221
2290
|
|
|
2222
|
-
// Tab
|
|
2291
|
+
// Tab:斜杠Commands补全 (循环候选);其余输入忽略 (\t 会顶破输入框,永不直接插入)
|
|
2223
2292
|
if (key.name === "tab") {
|
|
2224
2293
|
handleTab()
|
|
2225
2294
|
return
|
|
@@ -2293,7 +2362,14 @@ export async function startTUI(agent, opts = {}) {
|
|
|
2293
2362
|
return
|
|
2294
2363
|
}
|
|
2295
2364
|
|
|
2296
|
-
//
|
|
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))
|
|
2369
|
+
return
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
// 可打印字符 / 粘贴 (str 可能一次多个字符);Tab 一律转成两个空格 (\t 显示宽度不定,会顶破输入框)
|
|
2297
2373
|
// \r\n 在 Windows raw mode 下可能漏进来冲乱页面
|
|
2298
2374
|
if (str && !key.ctrl && !key.meta) {
|
|
2299
2375
|
const chars = [...str.replace(/[\r\n]+/g, "").replace(/\t/g, " ")]
|
|
@@ -2305,18 +2381,18 @@ export async function startTUI(agent, opts = {}) {
|
|
|
2305
2381
|
|
|
2306
2382
|
// 启动画面
|
|
2307
2383
|
if (!agent.provider.apiKey) {
|
|
2308
|
-
pushLabel(
|
|
2309
|
-
pushLine("
|
|
2384
|
+
pushLabel(`Welcome to ThinCoder!`, ansi.bold + C.tool)
|
|
2385
|
+
pushLine("检测到还没Config API key,进入初始Config (Esc 可随时跳过)", C.text)
|
|
2310
2386
|
startWizard()
|
|
2311
2387
|
} else {
|
|
2312
2388
|
pushLine(`Welcome to ThinCoder. Provider: ${agent.activeProvider} / ${agent.provider.model}`, C.dim)
|
|
2313
2389
|
}
|
|
2314
2390
|
pushLine(`Tools: ${agent.tools.map((t) => t.name).join(", ")}`, C.dim)
|
|
2315
|
-
//
|
|
2391
|
+
// 恢复上次会话:重建对话区显示 (tool 结果行省略,保持清爽)
|
|
2316
2392
|
if (opts.restored?.display?.length) {
|
|
2317
2393
|
// 用户视角的恢复:display 是退出前对话区的原样快照,所见即所得
|
|
2318
2394
|
state.lines = [...opts.restored.display.map((l) => ({ text: l.text, color: l.color })), ...state.lines]
|
|
2319
|
-
pushLabel(`──
|
|
2395
|
+
pushLabel(`── Restored previous session; /new for a fresh session ──`, C.warn)
|
|
2320
2396
|
} else if (opts.restored?.history?.length) {
|
|
2321
2397
|
// 重建对话区:user/assistant 消息逐条展示,tool 结果行只保留首行摘要
|
|
2322
2398
|
for (let i = 0; i < opts.restored.history.length; i++) {
|
|
@@ -2338,15 +2414,15 @@ export async function startTUI(agent, opts = {}) {
|
|
|
2338
2414
|
}
|
|
2339
2415
|
// tool 消息本身不单独渲染——已在 assistant 的 tool_calls 后以摘要形式展示
|
|
2340
2416
|
}
|
|
2341
|
-
pushLabel(`──
|
|
2417
|
+
pushLabel(`── Restored previous session (${opts.restored.history.length} messages); /new for a fresh session ──`, C.warn)
|
|
2342
2418
|
}
|
|
2343
2419
|
// 有归档槽位时给个提示
|
|
2344
2420
|
if (listSlots(agent.cwd).length > 0) {
|
|
2345
|
-
pushLine("
|
|
2421
|
+
pushLine("Tip: archived sessions available — /session to view/switch", C.dim)
|
|
2346
2422
|
}
|
|
2347
2423
|
render()
|
|
2348
2424
|
|
|
2349
|
-
//
|
|
2425
|
+
// 后台索引 (进界面后再跑,不阻塞启动);进度走底部状态栏,不往对话区塞行
|
|
2350
2426
|
;(async () => {
|
|
2351
2427
|
const { codeSync, docSync } = await import("./memory.mjs")
|
|
2352
2428
|
const cwd = agent.cwd
|