thincoder 0.7.7 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +36 -13
  2. package/bin/thincoder.mjs +27 -346
  3. package/package.json +1 -1
  4. package/src/agent/dispatch.mjs +98 -0
  5. package/src/agent/helpers.mjs +185 -0
  6. package/src/agent/setup.mjs +117 -0
  7. package/src/agent-tools/goal.mjs +71 -0
  8. package/src/agent-tools/plan.mjs +31 -0
  9. package/src/agent-tools/recent-changes.mjs +23 -0
  10. package/src/agent-tools/skill.mjs +46 -0
  11. package/src/agent-tools/subagent.mjs +113 -0
  12. package/src/agent-tools/task.mjs +67 -0
  13. package/src/agent-tools/verify.mjs +198 -0
  14. package/src/agent-tools.mjs +12 -0
  15. package/src/agent.mjs +90 -1040
  16. package/src/cli/distill-command.mjs +85 -0
  17. package/src/cli/make-agent.mjs +85 -0
  18. package/src/cli/memory-command.mjs +63 -0
  19. package/src/cli/permission.mjs +41 -0
  20. package/src/cli/setup-wizard.mjs +70 -0
  21. package/src/config.mjs +5 -8
  22. package/src/context.mjs +10 -13
  23. package/src/distill.mjs +4 -3
  24. package/src/embedding.mjs +4 -2
  25. package/src/{checkpoint.mjs → git/checkpoint.mjs} +1 -1
  26. package/src/mcp/helpers.mjs +37 -0
  27. package/src/mcp/transport-http.mjs +176 -0
  28. package/src/mcp/transport-stdio.mjs +84 -0
  29. package/src/mcp/transport-ws.mjs +87 -0
  30. package/src/mcp.mjs +4 -428
  31. package/src/memory/code-index.mjs +211 -0
  32. package/src/memory/code-sync.mjs +306 -0
  33. package/src/memory/core.mjs +277 -0
  34. package/src/memory/docs.mjs +262 -0
  35. package/src/memory/schema.mjs +426 -0
  36. package/src/memory.mjs +12 -1403
  37. package/src/provider/core.mjs +239 -0
  38. package/src/provider/index.mjs +6 -0
  39. package/src/provider/rate.mjs +104 -0
  40. package/src/session.mjs +18 -5
  41. package/src/tools/bash.md +1 -0
  42. package/src/tools/bash.mjs +144 -0
  43. package/src/tools/file.mjs +205 -0
  44. package/src/tools/git.mjs +166 -0
  45. package/src/tools/glob.mjs +51 -0
  46. package/src/tools/grep.mjs +100 -0
  47. package/src/tools/index.mjs +22 -0
  48. package/src/tools/ls.mjs +36 -0
  49. package/src/tools/patch.mjs +226 -0
  50. package/src/tools/repomap-parse.mjs +168 -0
  51. package/src/tools/shared.mjs +257 -0
  52. package/src/tools/system.mjs +336 -0
  53. package/src/tools/web.mjs +121 -0
  54. package/src/tools.mjs +2 -1188
  55. package/src/tui/agent-turn.mjs +254 -0
  56. package/src/tui/ansi.mjs +32 -0
  57. package/src/tui/clipboard.mjs +48 -0
  58. package/src/tui/cmd-auto.mjs +21 -0
  59. package/src/tui/cmd-clear.mjs +26 -0
  60. package/src/tui/cmd-config.mjs +72 -0
  61. package/src/tui/cmd-exit.mjs +5 -0
  62. package/src/tui/cmd-extract.mjs +5 -0
  63. package/src/tui/cmd-goal.mjs +47 -0
  64. package/src/tui/cmd-help.mjs +25 -0
  65. package/src/tui/cmd-init.mjs +91 -0
  66. package/src/tui/cmd-mcp.mjs +146 -0
  67. package/src/tui/cmd-model.mjs +7 -0
  68. package/src/tui/cmd-new.mjs +18 -0
  69. package/src/tui/cmd-plan.mjs +21 -0
  70. package/src/tui/cmd-reindex.mjs +44 -0
  71. package/src/tui/cmd-restore.mjs +39 -0
  72. package/src/tui/cmd-session.mjs +42 -0
  73. package/src/tui/cmd-skills.mjs +17 -0
  74. package/src/tui/cmd-think.mjs +56 -0
  75. package/src/tui/config-helpers.mjs +34 -0
  76. package/src/tui/distill-cmd.mjs +45 -0
  77. package/src/tui/index.mjs +330 -0
  78. package/src/tui/interaction.mjs +79 -0
  79. package/src/tui/key-handler.mjs +267 -0
  80. package/src/tui/layout.mjs +115 -0
  81. package/src/tui/pickers.mjs +279 -0
  82. package/src/tui/render-frame.mjs +304 -0
  83. package/src/tui/render.mjs +205 -0
  84. package/src/tui/slash-commands.mjs +138 -0
  85. package/src/tui/startup.mjs +113 -0
  86. package/src/tui/wizard.mjs +168 -0
  87. package/src/tui-render.mjs +4 -0
  88. package/src/tui.mjs +3 -2546
  89. package/src/provider.mjs +0 -383
  90. /package/src/{gitmem.mjs → git/gitmem.mjs} +0 -0
  91. /package/src/{coder-overlay.md → prompts/coder.md} +0 -0
  92. /package/src/{discipline-rules.md → prompts/discipline.md} +0 -0
  93. /package/src/{explore-overlay.md → prompts/explore.md} +0 -0
  94. /package/src/{main-overlay.md → prompts/main.md} +0 -0
  95. /package/src/{plan-overlay.md → prompts/plan.md} +0 -0
  96. /package/src/{SYSTEM_PROMPT.md → prompts/system.md} +0 -0
  97. /package/src/{repomap.mjs → tools/repomap.mjs} +0 -0
package/src/tui.mjs CHANGED
@@ -1,2548 +1,5 @@
1
1
  /**
2
- * tui.mjs — 裸 ANSI 终端 UI
3
- * 零依赖:raw mode 键盘输入、ANSI 转义渲染、自研宽字符换行。
4
- * 布局:header / 对话区 (可滚动)/ todo 面板 (有任务时)/ 输入框 / 状态栏。
2
+ * tui.mjs — 终端 UI(重新导出中心)
3
+ * 子模块在 src/tui/ 目录下。
5
4
  */
6
-
7
- import { emitKeypressEvents } from "node:readline"
8
- import { PassThrough } from "node:stream"
9
- import { basename } from "node:path"
10
- import { existsSync, readFileSync } from "node:fs"
11
- import { runAgent, ContinueError } from "./agent.mjs"
12
- import { estimateTokens } from "./context.mjs"
13
- import { saveSession, clearSession, archiveCurrent, listSlots, switchToSlot, sessionPath } from "./session.mjs"
14
- import { PROVIDER_PRESETS as PRESETS, specForModel } from "./config.mjs"
15
- import { closeAllMcp } from "./mcp.mjs"
16
-
17
- // ---------------------------------------------------------------- ANSI 工具
18
-
19
- const ESC = "\x1b"
20
- const ansi = {
21
- hideCursor: `${ESC}[?25l`,
22
- showCursor: `${ESC}[?25h`,
23
- altBuffer: `${ESC}[?1049h`,
24
- mainBuffer: `${ESC}[?1049l`,
25
- mouseOn: `${ESC}[?1000h${ESC}[?1006h`, // 基本鼠标 + SGR 扩展坐标 (滚轮上报)
26
- mouseOff: `${ESC}[?1000l${ESC}[?1006l`,
27
- home: `${ESC}[H`,
28
- clearLine: `${ESC}[K`,
29
- reset: `${ESC}[0m`,
30
- dim: `${ESC}[2m`,
31
- bold: `${ESC}[1m`,
32
- fg: (n) => `${ESC}[${30 + n}m`,
33
- gray: `${ESC}[90m`,
34
- }
35
-
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 (思考流)
41
- tool: ansi.fg(6), // cyan
42
- error: ansi.fg(1), // red
43
- dim: ansi.gray,
44
- warn: ansi.fg(3), // yellow
45
- }
46
-
47
- /** 字符显示宽度:CJK/emoji 计 2,组合字符计 0,其余计 1 */
48
- export function charWidth(cp) {
49
- if (
50
- (cp >= 0x300 && cp <= 0x36f) || // 组合变音符
51
- (cp >= 0x200b && cp <= 0x200f) || // 零宽
52
- cp === 0xfe0f // emoji 变体选择符
53
- ) {
54
- return 0
55
- }
56
- if (
57
- (cp >= 0x1100 && cp <= 0x115f) ||
58
- (cp >= 0x2e80 && cp <= 0xa4cf) ||
59
- (cp >= 0xac00 && cp <= 0xd7a3) ||
60
- (cp >= 0xf900 && cp <= 0xfaff) ||
61
- (cp >= 0xfe30 && cp <= 0xfe4f) ||
62
- (cp >= 0xff00 && cp <= 0xff60) ||
63
- (cp >= 0xffe0 && cp <= 0xffe6) ||
64
- (cp >= 0x1f000 && cp <= 0x1faff) ||
65
- (cp >= 0x20000 && cp <= 0x3fffd) ||
66
- (cp >= 0x2600 && cp <= 0x27bf)
67
- ) {
68
- return 2
69
- }
70
- return 1
71
- }
72
-
73
- export function stringWidth(text) {
74
- let w = 0
75
- for (const ch of text) w += charWidth(ch.codePointAt(0))
76
- return w
77
- }
78
-
79
- /** 按显示宽度裁剪 */
80
- function sliceByWidth(text, maxWidth) {
81
- let w = 0
82
- let out = ""
83
- for (const ch of text) {
84
- const cw = charWidth(ch.codePointAt(0))
85
- if (w + cw > maxWidth) break
86
- w += cw
87
- out += ch
88
- }
89
- return out
90
- }
91
-
92
- /** 按显示宽度右补空格 */
93
- function padByWidth(text, width) {
94
- return text + " ".repeat(Math.max(0, width - stringWidth(text)))
95
- }
96
-
97
- // ---------------------------------------------------------------- markdown 表格重排
98
-
99
- const isTableRow = (line) => (line.match(/\|/g) ?? []).length >= 2
100
- const isTableSeparator = (line) => /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line) && line.includes("-")
101
-
102
- /**
103
- * 识别文本中的 markdown 表格块,按显示宽度重排 (修 CJK 错位)。
104
- * width 为可用显示宽度;过宽的表格按列收缩。非表格行原样保留。
105
- */
106
- export function formatTables(text, width) {
107
- const lines = text.split("\n")
108
- const out = []
109
- let i = 0
110
- while (i < lines.length) {
111
- if (isTableRow(lines[i]) && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
112
- const block = [lines[i], lines[i + 1]]
113
- i += 2
114
- while (i < lines.length && isTableRow(lines[i])) {
115
- block.push(lines[i])
116
- i++
117
- }
118
- out.push(...renderTable(block, width))
119
- } else {
120
- out.push(lines[i])
121
- i++
122
- }
123
- }
124
- return out
125
- }
126
-
127
- function renderTable(block, width) {
128
- const rows = block.map((line) =>
129
- line
130
- .replace(/^\s*\|/, "")
131
- .replace(/\|\s*$/, "")
132
- .split("|")
133
- .map((c) => c.trim()),
134
- )
135
- const colCount = Math.max(...rows.map((r) => r.length))
136
- for (const r of rows) while (r.length < colCount) r.push("")
137
-
138
- // 列宽:先按内容,超宽则从最宽列开始收缩 (收缩到至少 3)
139
- const widths = Array.from({ length: colCount }, (_, c) =>
140
- Math.max(3, ...rows.map((r) => stringWidth(r[c] ?? ""))),
141
- )
142
- const borders = colCount * 3 + 1 // " │ " 分隔 + 首尾 |
143
- while (widths.reduce((a, b) => a + b, 0) + borders > width && Math.max(...widths) > 3) {
144
- const widest = widths.indexOf(Math.max(...widths))
145
- widths[widest]--
146
- }
147
-
148
- // 单元格渲染:sliceByWidth 截断 (表头单行),padByWidth 补齐
149
- const fmtCell = (text, ci) => padByWidth(sliceByWidth(text, widths[ci]), widths[ci])
150
- const fmtRow = (cells) => "│ " + cells.map((c, i) => fmtCell(c, i)).join(" │ ") + " │"
151
-
152
- // 分隔线
153
- const separator = "├" + widths.map((w) => "─".repeat(w + 2)).join("┼") + "┤"
154
-
155
- const out = []
156
- // 表头:单行截断 (表头通常是短标签,折行不如截断直观)
157
- out.push(fmtRow(rows[0]))
158
- out.push(separator)
159
-
160
- // 数据行:过长单元格按列宽折行,一个逻辑行可能对应多条显示行
161
- for (let r = 2; r < rows.length; r++) {
162
- // wrapText 返回按 width 折行后的行数组,保留内部 \n
163
- const wrapped = rows[r].map((cell, ci) => wrapText(cell, widths[ci]))
164
- const height = Math.max(...wrapped.map((lines) => lines.length))
165
- for (let lineIdx = 0; lineIdx < height; lineIdx++) {
166
- out.push(fmtRow(wrapped.map((lines) => lines[lineIdx] ?? "")))
167
- }
168
- }
169
-
170
- return out
171
- }
172
-
173
- /** 输入区布局:把输入缓冲折行,同时算出光标的 (行, 列) 位置 (显示宽度) */
174
- export function layoutInput(chars, cursor, width) {
175
- const PROMPT = "▸ "
176
- const lines = []
177
- let cursorLine = 0
178
- let cursorCol = 0
179
- let cur = ""
180
- let col = 0
181
- let firstLine = true
182
- const avail = () => (firstLine ? width - 2 : width)
183
- const flush = () => {
184
- lines.push((firstLine ? PROMPT : "") + cur)
185
- firstLine = false
186
- cur = ""
187
- col = 0
188
- }
189
- for (let i = 0; i <= chars.length; i++) {
190
- if (i === cursor) {
191
- cursorLine = lines.length
192
- cursorCol = (firstLine ? 2 : 0) + col
193
- }
194
- const ch = chars[i]
195
- if (ch === undefined) break
196
- if (ch === "\n") {
197
- flush()
198
- continue
199
- }
200
- const w = charWidth(ch.codePointAt(0))
201
- if (col + w > avail()) flush()
202
- cur += ch
203
- col += w
204
- }
205
- if (cur || lines.length === 0) flush()
206
- return { lines, cursorLine, cursorCol }
207
- }
208
-
209
- /**
210
- * 显示净化:控制字符会破坏终端网格数学 (\r 回车覆盖、\t 宽度误判致整帧错位、ANSI/响铃冲屏)。
211
- * 只动显示层——模型看到的工具结果原文不变;session 里已存的脏 display 回放时也经此净化。
212
- */
213
- const ANSI_SEQUENCE_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/g
214
- export function sanitizeDisplay(s) {
215
- return s
216
- .replace(ANSI_SEQUENCE_RE, "")
217
- .replace(/\r\n/g, "\n")
218
- .replace(/\r/g, "\n")
219
- .replace(/\t/g, " ")
220
- .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
221
- .replace(/\n+$/, "")
222
- }
223
-
224
- /** 文本按宽度折行 (保留 \n),返回行数组 */
225
- export function wrapText(text, width) {
226
- const lines = []
227
- for (const rawLine of text.split("\n")) {
228
- if (rawLine === "") {
229
- lines.push("")
230
- continue
231
- }
232
- let line = rawLine
233
- while (stringWidth(line) > width) {
234
- const head = sliceByWidth(line, width)
235
- lines.push(head)
236
- line = line.slice([...head].length)
237
- }
238
- lines.push(line)
239
- }
240
- return lines
241
- }
242
-
243
- // ---------------------------------------------------------------- TUI 主入口
244
-
245
- /**
246
- * 启动 TUI,接管终端直到退出。
247
- * agent: createAgent 的返回值
248
- * opts: { projectDir?, team?, author? } —— /distill 写入 project/team 层时用
249
- */
250
- export async function startTUI(agent, opts = {}) {
251
- if (!process.stdin.isTTY) {
252
- throw new Error("TUI requires a TTY; use 'thincoder chat' for non-interactive use")
253
- }
254
-
255
- const distillOpts = opts
256
-
257
- const state = {
258
- lines: [], // 对话区行:{ text, color }
259
- streaming: "", // current流式缓冲
260
- input: [], // 输入缓冲区 (码点数组)
261
- cursor: 0,
262
- history: [],
263
- historyIndex: -1,
264
- scroll: 0, // 从底部向上的滚动行数
265
- processing: false,
266
- controller: null, // AbortController for current agent run
267
- permission: null, // { name, args, resolve }
268
- permissionPreview: [], // 权限审批的内容预览行 (渲染在输入框上方,不分隔)
269
- question: null, // { text, options, resolve } — agent 的 question 工具回调
270
- picker: null, // 模型选择器 { entries, lines, index, scroll, selectedLine }
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
- completion: null, // Tab 补全状态 { candidates, index }
277
- toolStreams: {}, // 各工具的实时输出 (按工具名隔离,并行工具互不串扰)
278
- subTasks: {}, // 子 agent 面板:{ roleName: { role, text, done } },每 role 一行,完成后标记 done 停留片刻
279
- currentTool: null, // 正在执行的工具名 (状态栏显示)
280
- processingStarted: 0, // 本轮处理开始时间 (状态栏计时)
281
- status: "Ready",
282
- queue: [], // 处理中排队的待执行消息:[{ text }],处理完自动取下一条
283
- }
284
-
285
- // 恢复的会话如果所有任务completed,自动收起 todo 面板 (对齐运行时行为)
286
- if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
287
- state.tasks = []
288
- }
289
-
290
- // 输入流先过一道滤网:鼠标序列 (滚轮)在这里拦截处理,剥净后才交给 keypress 解析,
291
- // 防止序列残片 (如 "64;72;42M")漏进输入框
292
- const keyStream = new PassThrough()
293
- let mousePending = "" // 跨 chunk 的不完整鼠标序列尾部
294
- let lastRenderedScroll = 0
295
- emitKeypressEvents(keyStream)
296
- process.stdin.setRawMode(true)
297
- process.stdout.write(ansi.altBuffer + ansi.hideCursor + ansi.mouseOn)
298
-
299
- process.stdin.on("data", (chunk) => {
300
- let text = mousePending + chunk.toString("utf8")
301
- mousePending = ""
302
-
303
- // 滚轮:\x1b[<64;…M 上滚,\x1b[<65;…M 下滚 (每次 3 行)
304
- for (const m of text.matchAll(/\x1b\[<(\d+);\d+;\d+([Mm])/g)) {
305
- if (Number(m[1]) === 64) {
306
- state.scroll += 3
307
- } else if (Number(m[1]) === 65) {
308
- state.scroll = Math.max(0, state.scroll - 3)
309
- }
310
- }
311
-
312
- // 剥掉完整鼠标序列;不完整的尾部留到下一块数据再拼
313
- text = text.replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, "")
314
- const tail = text.match(/\x1b\[<[\d;]*$/)
315
- if (tail) {
316
- mousePending = tail[0]
317
- text = text.slice(0, -tail[0].length)
318
- }
319
-
320
- if (state.scroll !== lastRenderedScroll) {
321
- lastRenderedScroll = state.scroll
322
- render()
323
- }
324
- if (text) keyStream.write(text)
325
- })
326
-
327
- let cleanedUp = false
328
- const cleanup = () => {
329
- if (cleanedUp) return
330
- cleanedUp = true
331
- // 退出前保存会话 (同步写);先归档current到槽位,再落新——不丢
332
- try {
333
- archiveCurrent(agent.cwd)
334
- saveSession(agent, state.lines)
335
- } catch {
336
- // 存失败不耽误退出
337
- }
338
- // Off MCP stdio 子进程,不留孤儿
339
- try {
340
- closeAllMcp(agent)
341
- } catch {
342
- // 关不掉就算了,进程马上退出
343
- }
344
- process.stdin.setRawMode(false)
345
- process.stdout.write(ansi.mouseOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
346
- }
347
- process.on("exit", cleanup)
348
-
349
- const pushLine = (text, color) => {
350
- state.lines.push({ text, color })
351
- if (state.lines.length > 5000) state.lines.splice(0, 1000) // 防none限增长
352
- render()
353
- }
354
-
355
- /** 消息块标签:空行 + 标签行。用户/助手消息之间留出呼吸空间 */
356
- const pushLabel = (text, color) => {
357
- if (state.lines.length > 0) state.lines.push({ text: "", color: C.dim })
358
- state.lines.push({ text, color })
359
- render()
360
- }
361
-
362
- // 每轮对话只打一次助手标签 (首个 token 或首个工具调用时)
363
- let assistantLabeled = false
364
- const ensureAssistantLabel = () => {
365
- if (!assistantLabeled) {
366
- assistantLabeled = true
367
- pushLabel(`❯ ThinCoder:`, ansi.bold + C.assistant)
368
- }
369
- }
370
-
371
- // ---------------------------------------------------------- 渲染
372
-
373
- // 帧去重 + 流式限流:内容没变的帧不重写 (防闪屏);token 洪流合并到 ~25fps
374
- let lastFrame = ""
375
- let renderTimer = null
376
-
377
- /** 流式期间的限流渲染 (trailing edge:最后一次变化一定渲染到) */
378
- function scheduleRender() {
379
- if (renderTimer) return
380
- renderTimer = setTimeout(() => {
381
- renderTimer = null
382
- render()
383
- }, 40)
384
- }
385
-
386
- function render() {
387
- const cols = process.stdout.columns || 80
388
- const rows = process.stdout.rows || 24
389
- const model = agent.provider.model
390
- const thinking = agent.provider.thinking
391
- const effort = agent.provider.reasoningEffort
392
- const isMultimodal = specForModel(model).multimodal
393
- const thinkBadge = thinking?.type === "disabled" ? "│ think: off"
394
- : effort ? `│ think: ${effort}` : thinking?.type === "enabled" ? "│ think: on" : ""
395
-
396
- // 输入区:全边框盒,宽度 W (所有输出行严格 ≤ cols-1,防自动折行错位)
397
- const W = Math.max(20, cols - 1)
398
- const layout = layoutInput(state.input, state.cursor, W - 4)
399
- // 最多显示 5 行;超出时以光标所在行为中心滚动
400
- const MAX_INPUT_LINES = 5
401
- let inputOffset = 0
402
- if (layout.lines.length > MAX_INPUT_LINES) {
403
- inputOffset = Math.min(layout.cursorLine, layout.lines.length - MAX_INPUT_LINES)
404
- }
405
- const inputLines = layout.lines.slice(inputOffset, inputOffset + MAX_INPUT_LINES)
406
- // question 模式下输入框显示选项/答案草稿,而不是普通输入 (高度也要跟着走)
407
- let boxLines = inputLines
408
- if (state.question) {
409
- const q = state.question
410
- if (q.options.length > 0) {
411
- // 选项窗口:只显示选中项 ±2,选项过多时防输入框none限增高撑破锚定布局
412
- const sel = q.selected ?? 0
413
- const QWIN = 5
414
- const start = Math.max(0, Math.min(sel - 2, q.options.length - QWIN))
415
- boxLines = q.options
416
- .slice(start, start + QWIN)
417
- .map((opt, i) => (start + i === sel ? "▸ " : " ") + opt)
418
- } else {
419
- boxLines = ["▸ " + (q.answer ?? "")]
420
- }
421
- }
422
- const inputBoxH = boxLines.length + 2
423
-
424
- const headerH = 1
425
- const statusH = 1
426
- // 浮层 (模型选择器 / 初始Config向导)打开时,在对话区下方预留一块 (标题 + 列表窗口)
427
- const overlay = state.picker ?? state.wizard
428
- const pickerH = overlay
429
- ? Math.min(overlay.lines.length + 1, Math.max(6, rows - 12))
430
- : 0
431
- // todo 面板:有任务列表时占对话区与输入框之间最多 5 行
432
- // 折叠时优先 in_progress,兼顾最早的 pending 和最近的 done
433
- const MAX_TASK_LINES = 5
434
- let visibleTasks = []
435
- if (state.tasks.length <= MAX_TASK_LINES) {
436
- visibleTasks = state.tasks
437
- } else {
438
- const inProgress = state.tasks.filter((t) => t.status === "in_progress")
439
- const pending = state.tasks.filter((t) => t.status === "pending")
440
- const done = state.tasks.filter((t) => t.status === "done")
441
- visibleTasks = [...inProgress, ...pending, ...done].slice(0, MAX_TASK_LINES)
442
- }
443
- const taskPanelH = visibleTasks.length
444
- // 子 agent 面板 (subTasks):每活跃子 agent 一行,上方对话区下方,最多 4 行折叠
445
- const activeSubs = Object.values(state.subTasks).filter((s) => !s.done)
446
- const subPanelH = Math.min(activeSubs.length, 4)
447
- const subOutLen = subPanelH
448
- // 权限预览占位:字符数之外再封顶显示行数 (rows-8),多行短行也能把帧撑过终端高度,破坏锚定布局
449
- let permPreviewLines = []
450
- if (state.permission) {
451
- const maxLines = Math.max(1, rows - 8)
452
- outer: for (const l of state.permissionPreview) {
453
- for (const wrapped of wrapText(` ${l}`, W - 1)) {
454
- if (permPreviewLines.length >= maxLines) break outer
455
- permPreviewLines.push(wrapped)
456
- }
457
- }
458
- }
459
- const permPreviewLen = state.permission ? 1 + permPreviewLines.length : 0
460
- const convH = Math.max(1, rows - headerH - inputBoxH - statusH - pickerH - taskPanelH - subOutLen - permPreviewLen)
461
-
462
- // 对话区内容行 (含流式缓冲);markdown 表格先按显示宽度重排
463
- const convLines = []
464
- for (const l of state.lines) {
465
- for (const line of formatTables(sanitizeDisplay(l.text), cols - 1)) {
466
- for (const wrapped of wrapText(line, cols - 1)) {
467
- convLines.push({ text: wrapped, color: l.color })
468
- }
469
- }
470
- }
471
- // 思考流 (暗色)在正文流之前
472
- if (state.reasoning) {
473
- for (const wrapped of wrapText(sanitizeDisplay(state.reasoning), cols - 1)) {
474
- convLines.push({ text: wrapped, color: C.reason })
475
- }
476
- }
477
- if (state.streaming) {
478
- for (const line of formatTables(sanitizeDisplay(state.streaming), cols - 1)) {
479
- for (const wrapped of wrapText(line, cols - 1)) {
480
- convLines.push({ text: wrapped, color: C.text })
481
- }
482
- }
483
- }
484
- // 工具实时输出 (暗色,只保留末尾防刷屏;按工具名隔离防止并行工具串扰)
485
- const allStreams = Object.values(state.toolStreams).join("")
486
- if (allStreams) {
487
- const tail = sanitizeDisplay(allStreams.slice(-4000))
488
- for (const wrapped of wrapText(tail, cols - 1)) {
489
- convLines.push({ text: wrapped, color: C.dim })
490
- }
491
- }
492
-
493
- const maxScroll = Math.max(0, convLines.length - convH)
494
- state.scroll = Math.min(state.scroll, maxScroll)
495
- const end = convLines.length - state.scroll
496
- const visible = convLines.slice(Math.max(0, end - convH), end)
497
-
498
- const out = [ansi.home]
499
-
500
- // header (超宽截断,防终端折行)
501
- out.push(
502
- `${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}`,
503
- )
504
-
505
- // 对话区 (不足部分补空行,把输入框钉在底部)
506
- const pad = convH - visible.length
507
- for (let i = 0; i < pad; i++) out.push(ansi.clearLine)
508
- for (const l of visible) {
509
- out.push(`${l.color}${l.text}${ansi.reset}${ansi.clearLine}`)
510
- }
511
-
512
- // 浮层 (模型选择器 / 初始Config向导):列表滚动跟随选中行
513
- if (overlay) {
514
- const winH = pickerH - 1
515
- if (overlay.selectedLine < overlay.scroll) overlay.scroll = overlay.selectedLine
516
- if (overlay.selectedLine >= overlay.scroll + winH) overlay.scroll = overlay.selectedLine - winH + 1
517
- const start = Math.max(0, Math.min(overlay.scroll, Math.max(0, overlay.lines.length - winH)))
518
- const shown = overlay.lines.slice(start, start + winH)
519
- const overlayTitle = state.picker ? ` ❯ ${state.picker.title} ` : " ❯ 初始Config "
520
- out.push(`${ansi.bold}${C.tool}${overlayTitle}${ansi.reset}${ansi.dim}${state.picker ? "(↑↓ 移动, Enter 确认, Esc 取消)" : ""}${ansi.reset}${ansi.clearLine}`)
521
- for (const l of shown) {
522
- out.push(`${l.color}${sliceByWidth(l.text, cols - 1)}${ansi.reset}${ansi.clearLine}`)
523
- }
524
- for (let i = shown.length; i < winH; i++) out.push(ansi.clearLine)
525
- }
526
-
527
- // todo 面板 (对话区与输入框之间):▶ in_progress / ✓ done(删除线) / ○ pending
528
- for (const t of visibleTasks) {
529
- const mark = t.status === "done" ? "✓" : t.status === "in_progress" ? "▶" : "○"
530
- const color = t.status === "done" ? `${C.dim}${ESC}[9m` : t.status === "in_progress" ? C.tool : C.text
531
- out.push(`${color} ${mark} ${sliceByWidth(t.title, cols - 4)}${ansi.reset}${ansi.clearLine}`)
532
- }
533
-
534
- // 子 agent 面板:每活跃子 agent 一行,done 的灰色显示后 3 秒自动清除
535
- const subs = Object.values(state.subTasks)
536
- if (subs.length > 0 && state.processing) {
537
- for (const s of subs.slice(0, 4)) {
538
- const icon = s.done ? "✓" : "…"
539
- const color = s.done ? C.dim : C.tool
540
- const label = `[${s.role}]`.padEnd(10)
541
- const text = s.text ? sliceByWidth(s.text, W - 14) : (s.done ? "done" : "running...")
542
- out.push(`${color} ${icon} ${label} ${text}${ansi.reset}${ansi.clearLine}`)
543
- }
544
- if (subs.length > 4) {
545
- out.push(`${C.dim} ... +${subs.length - 4} more subagents${ansi.reset}${ansi.clearLine}`)
546
- }
547
- }
548
-
549
- // 权限审批内容预览 (黄色,紧挨输入框上方);用上方已封顶的 permPreviewLines,渲染行数与占位一致
550
- if (state.permission) {
551
- out.push(`${ansi.bold}${C.warn}❯ 权限请求${ansi.reset}${ansi.clearLine}`)
552
- for (const wrapped of permPreviewLines) {
553
- out.push(`${C.warn}${wrapped}${ansi.reset}${ansi.clearLine}`)
554
- }
555
- }
556
-
557
- // 队列预览 (暗色,紧挨输入框上方):与子 agent 面板/权限预览共享输入框上方空间
558
- // 只在 processing 时显示(非 processing 时队列应为空),且最多 1 行预览避免挤压对话区
559
- if (state.queue.length > 0 && state.processing) {
560
- const preview = sliceByWidth(state.queue[0].text, W - 20)
561
- out.push(`${C.dim}❯ Queue: ${state.queue.length} pending${state.queue.length > 1 ? ` (next: ${preview}…)` : ` (next: ${preview})`} — Ctrl+D del${ansi.reset}${ansi.clearLine}`)
562
- }
563
-
564
- // 输入框 (全边框,宽 W)
565
- let borderColor = C.tool
566
- let title
567
- if (state.question) {
568
- borderColor = C.tool
569
- title = " Question "
570
- } else if (state.permission) {
571
- borderColor = C.warn
572
- if (state.permission.name === "continue") {
573
- title = " Continue? (y/n) "
574
- } else {
575
- title = ` Allow ${state.permission.name}? (y/n/a) `
576
- }
577
- } else if (state.picker) {
578
- title = " Select "
579
- } else if (state.wizard) {
580
- title = " Setup "
581
- } else if (state.processing) {
582
- title = " Processing... "
583
- } else {
584
- title = " Input "
585
- }
586
- let topBorder
587
- if (title === " Input " && isMultimodal) {
588
- const hint = process.platform === "win32" ? " Alt+V paste " : " Ctrl+V paste "
589
- topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 4 - stringWidth(title) - stringWidth(hint)))}${hint}─╮`
590
- } else {
591
- topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 3 - stringWidth(title)))}╮`
592
- }
593
- out.push(`${borderColor}${topBorder}${ansi.reset}${ansi.clearLine}`)
594
- for (const l of boxLines) {
595
- const content = sliceByWidth(l, W - 4)
596
- const fill = " ".repeat(Math.max(0, W - 4 - stringWidth(content)))
597
- out.push(`${borderColor}│${ansi.reset} ${content}${fill} ${borderColor}│${ansi.reset}${ansi.clearLine}`)
598
- }
599
- out.push(`${borderColor}╰${"─".repeat(Math.max(0, W - 2))}╯${ansi.reset}${ansi.clearLine}`)
600
-
601
- // 状态栏 (输入 / 开头时变为Commands提示)
602
- const scrollHint = state.scroll > 0 ? ` │ scrolled ${state.scroll}` : ""
603
- const rawInput = state.input.join("")
604
- let statusLine
605
- if (state.question) {
606
- const q = state.question
607
- statusLine = q.options.length > 0
608
- ? " ↑↓: select │ Enter: confirm │ Esc: cancel"
609
- : " Type answer then Enter │ Esc: cancel"
610
- } else if (state.permission) {
611
- statusLine = state.permission.name === "continue"
612
- ? " y: continue │ n: stop"
613
- : " y: approve │ n: deny │ a: approve all (AUTO)"
614
- } else if (state.picker) {
615
- statusLine = " ↑↓: select │ Enter: confirm │ Esc: cancel"
616
- } else if (state.wizard) {
617
- statusLine = state.wizard.step === "provider"
618
- ? " ↑↓: select │ Enter: confirm │ Esc: skip"
619
- : " Type then Enter │ Esc: cancel"
620
- } else if (rawInput.startsWith("/") && !state.processing && !state.permission) {
621
- const [cmd, sub] = rawInput.split(/\s+/)
622
- const cmds = SLASH_COMMANDS.filter((c) => c.name.startsWith(cmd))
623
- const match = cmds.length === 1 ? cmds[0] : null
624
- if (match?.name === "/config" && cmd === "/config") {
625
- statusLine = " /config open config menu"
626
- } else if (match?.name === "/provider" && cmd === "/provider") {
627
- statusLine = " /provider open provider management menu"
628
- } else if (match?.name === "/model" && cmd === "/model" && !sub) {
629
- statusLine = " /model open model picker"
630
- } else if (match?.name === "/think" && cmd === "/think") {
631
- statusLine = " /think open thinking mode menu"
632
- } else if (match?.name === "/mcp" && cmd === "/mcp") {
633
- statusLine = " /mcp open MCP management menu"
634
- } else if (match?.name === "/goal" && cmd === "/goal") {
635
- statusLine = " /goal open goal management menu"
636
- } else if (match?.name === "/session" && cmd === "/session") {
637
- statusLine = " /session select archived session"
638
- } else if (match?.name === "/rewind" && cmd === "/rewind") {
639
- statusLine = " /rewind select checkpoint to restore"
640
- } else if (cmds.length > 0) {
641
- if (cmds.length <= 4) {
642
- statusLine = ` ${cmds.map((c) => `${c.name} ${c.desc}`).join(" │ ")}`
643
- } else {
644
- statusLine = ` ${cmds.map((c) => c.name).join(" ")} │ Tab complete`
645
- }
646
- } else {
647
- statusLine = ` unknown command (/help for available commands)`
648
- }
649
- } else {
650
- const taskHint = state.tasks.length > 0
651
- ? ` │ ✓${state.tasks.filter((t) => t.status === "done").length}/${state.tasks.length}`
652
- : ""
653
- // token 用量:↑输入 ↓输出 + 缓存命中率 (DeepSeek usage 带 prompt_cache_hit/miss_tokens)
654
- const tk = state.tokens
655
- const fmtK = (n) => (n >= 10000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`)
656
- const cacheTotal = tk.cacheHit + tk.cacheMiss
657
- const tokenHint = tk.prompt > 0
658
- ? ` │ ↑${fmtK(tk.prompt)} ↓${fmtK(tk.completion)}${cacheTotal > 0 ? ` hit${Math.round((tk.cacheHit / cacheTotal) * 100)}%` : ""}`
659
- : ""
660
- const elapsed = state.processing ? ` ${Math.floor((Date.now() - state.processingStarted) / 1000)}s` : ""
661
- const toolHint = state.currentTool ? ` ${state.currentTool}…` : ""
662
- const statusText = state.processing ? `${state.status}${toolHint}${elapsed}` : state.status
663
- // 上下文利用率:占压缩阈值百分比 (到 100% 触发压缩;≥80% 变黄提醒该收尾或 /new)
664
- if (state.ctxCache.len !== agent.history.length) {
665
- state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
666
- }
667
- const ctxThreshold = agent.config?.agent?.compactThreshold ?? 100_000
668
- const ctxPct = Math.round((state.ctxCache.tokens / ctxThreshold) * 100)
669
- const ctxHint = ctxPct > 0
670
- ? ctxPct >= 80
671
- ? ` │ ${ansi.reset}${C.warn}ctx ${ctxPct}%${ansi.reset}${ansi.dim}`
672
- : ` │ ctx ${ctxPct}%`
673
- : ""
674
- const queueHint = state.queue.length > 0 ? ` │ queue: ${state.queue.length}` : ""
675
- statusLine = ` ${statusText}${taskHint}${tokenHint}${ctxHint}${queueHint}${scrollHint} │ Enter: send${state.processing ? " (queue)" : ""} │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+C: exit`
676
- }
677
- const autoBanner = agent.autoApprove ? `${C.warn} AUTO${ansi.reset}${ansi.dim}│` : ""
678
- const planBanner = agent.planMode ? `${C.tool} PLAN${ansi.reset}${ansi.dim}│` : ""
679
- // 状态栏最多一行:终端宽度扣掉 banner 前缀的可视列数,防折行
680
- const bannerPrefix = (agent.planMode ? " PLAN│ " : "") + (agent.autoApprove ? " AUTO│ " : "")
681
- const statusMax = cols - 1 - (bannerPrefix ? stringWidth(bannerPrefix) : 0)
682
- statusLine = sliceByWidth(statusLine, Math.max(10, statusMax))
683
- out.push(`${ansi.dim}${planBanner}${autoBanner}${statusLine}${ansi.reset}${ansi.clearLine}`)
684
-
685
- const frame = out.join("\r\n")
686
- if (frame !== lastFrame) {
687
- lastFrame = frame
688
- process.stdout.write(frame)
689
- }
690
-
691
- // 光标:输入态定位到输入框内 (IME 候选框跟随真实光标);权限确认/菜单态时隐藏
692
- if (state.permission || state.question || state.picker || state.wizard?.step === "provider") {
693
- process.stdout.write(ansi.hideCursor)
694
- } else {
695
- const cursorRow = 1 + convH + pickerH + taskPanelH + 2 + (layout.cursorLine - inputOffset) // header + 对话区 + todo 面板 + 上边框 + 行偏移
696
- const cursorCol = 3 + layout.cursorCol // 左边框 + 空格 + 文本偏移 (1 基)
697
- process.stdout.write(`${ESC}[${cursorRow};${cursorCol}H${ansi.showCursor}`)
698
- }
699
- }
700
-
701
- process.stdout.on("resize", render)
702
-
703
- // ---------------------------------------------------------- 提交
704
-
705
- async function submit() {
706
- const text = state.input.join("").trim()
707
- if (!text) return
708
- state.input = []
709
- state.cursor = 0
710
- state.history.push(text)
711
- state.historyIndex = -1
712
- state.scroll = 0
713
-
714
- // 斜杠Commands:本地处理,不进入 agent(处理中也允许执行部分命令如 /cancel)
715
- if (text.startsWith("/")) {
716
- if (state.processing) {
717
- // 处理中只允许取消当前任务,其他命令排队
718
- if (text === "/cancel" || text === "/exit") {
719
- await handleSlash(text)
720
- } else {
721
- state.queue.push({ text })
722
- render()
723
- }
724
- return
725
- }
726
- await handleSlash(text)
727
- return
728
- }
729
-
730
- // 处理中:入队等待,不立即执行
731
- if (state.processing) {
732
- state.queue.push({ text })
733
- pushLabel(`❯ You: (queued #${state.queue.length})`, ansi.bold + C.user)
734
- pushLine(text, C.dim)
735
- render()
736
- return
737
- }
738
-
739
- await runAgentTurn(text)
740
- }
741
-
742
- /** 执行一轮 agent 对话(从 submit 或队列取出调用) */
743
- async function runAgentTurn(text) {
744
- pushLine(text, C.text)
745
-
746
- // 任务开始前自动打存档点 (git 仓库内;失败静默,不挡任务)
747
- try {
748
- const { createCheckpoint } = await import("./checkpoint.mjs")
749
- await createCheckpoint(agent.cwd)
750
- } catch {
751
- // 存档失败不影响任务
752
- }
753
-
754
- assistantLabeled = false
755
- state.processing = true
756
- state.status = "Processing..."
757
- state.streaming = ""
758
- state.reasoning = ""
759
- state.subTasks = {}
760
- state.currentTool = null
761
- state.processingStarted = Date.now()
762
- state.controller = new AbortController()
763
- // 处理中每秒刷新一次状态栏 (运行计时)
764
- const ticker = setInterval(() => {
765
- if (state.processing) render()
766
- }, 1000)
767
- render()
768
-
769
- const callbacks = {
770
- onToken: (t) => {
771
- // 子 agent 流式输出:前缀匹配 explore/coder/plan/sub 的 token 进 subTasks 面板
772
- const subMatch = t.match(/^(explore|coder|plan|sub)\//)
773
- if (subMatch) {
774
- const role = subMatch[1]
775
- if (!state.subTasks[role]) state.subTasks[role] = { role, text: "", done: false }
776
- state.subTasks[role].text = (state.subTasks[role].text + t.slice(subMatch[0].length)).slice(-200)
777
- scheduleRender()
778
- return
779
- }
780
- ensureAssistantLabel()
781
- state.streaming += t
782
- scheduleRender()
783
- },
784
- onReasoning: (t) => {
785
- // 子 agent 的思考 token 同样带 role/ 前缀,进 subTasks 面板,不污染主思考流
786
- const subMatch = t.match(/^(explore|coder|plan|sub)\//)
787
- if (subMatch) {
788
- const role = subMatch[1]
789
- if (!state.subTasks[role]) state.subTasks[role] = { role, text: "", done: false }
790
- scheduleRender()
791
- return
792
- }
793
- ensureAssistantLabel()
794
- state.reasoning += t
795
- scheduleRender()
796
- },
797
- onToolCall: (name, args) => {
798
- flushStream()
799
- ensureAssistantLabel()
800
- state.currentTool = name
801
- pushLine(` [tool] ${name} ${summarize(args)}`, C.tool)
802
- },
803
- onToolResult: (name, result) => {
804
- state.currentTool = null
805
- // 子 agent 结束:标记 done,面板保留片刻后清除
806
- const isSubagent = name === "subagent"
807
- if (isSubagent) {
808
- // 所有活跃子 agent 标记 done
809
- for (const key of Object.keys(state.subTasks)) {
810
- state.subTasks[key].done = true
811
- }
812
- // 子 agent 报告摘要 (最多 8 行)直接展示在对话区
813
- const lines = result.split("\n")
814
- const preview = lines.slice(0, 8).map((l) => l.slice(0, 120)).join("\n")
815
- if (preview) pushLine(preview, C.dim)
816
- if (lines.length > 8) pushLine(` ... (${lines.length - 8} more lines)`, C.dim)
817
- // 3 秒后清除面板中 done 的条目
818
- setTimeout(() => {
819
- for (const key of Object.keys(state.subTasks)) {
820
- if (state.subTasks[key].done) delete state.subTasks[key]
821
- }
822
- if (state.processing) render()
823
- }, 3000)
824
- }
825
- const stream = state.toolStreams[name]
826
- if (stream) {
827
- const tail = stream.trimEnd().slice(-4000)
828
- if (tail) pushLine(tail, C.dim)
829
- delete state.toolStreams[name]
830
- }
831
- if (!isSubagent) {
832
- const first = result.split("\n")[0]
833
- pushLine(` [done] ${name} → ${sliceByWidth(first, 100)}`, C.dim)
834
- }
835
- },
836
- onToolOutput: (name, chunk) => {
837
- state.toolStreams[name] = (state.toolStreams[name] ?? "") + chunk
838
- scheduleRender()
839
- },
840
- onPermissionRequest: (name, args) => askPermission(name, args),
841
- onQuestion: (text, options) => askQuestion(text, options),
842
- onCompress: () => {
843
- pushLine(" [context] Context too long, auto-compacted (early conversation summarized by LLM, task state preserved)", C.warn)
844
- },
845
- onUsage: (usage) => {
846
- state.tokens.prompt += usage.prompt_tokens ?? 0
847
- state.tokens.completion += usage.completion_tokens ?? 0
848
- state.tokens.cacheHit += usage.prompt_cache_hit_tokens ?? 0
849
- state.tokens.cacheMiss += usage.prompt_cache_miss_tokens ?? 0
850
- },
851
- // 节流等待 (主动闸门 / 429 退避):状态栏明示,防用户以为卡死
852
- onWait: ({ phase, seconds }) => {
853
- state.status = phase === "gate" ? `TPM 节流等待 ~${seconds}s` : `限流 429,${seconds}s 后重试`
854
- render()
855
- },
856
- onTaskUpdate: (items) => {
857
- state.tasks = items
858
- const done = items.filter((i) => i.status === "done").length
859
- // 留痕带上current任务标题:回看历史时知道进行到哪一项
860
- const current = items.find((i) => i.status === "in_progress")
861
- pushLine(` [task] ${done}/${items.length}${current ? ` ▶ ${current.title}` : ""}`, C.dim)
862
- render()
863
- },
864
- // 增量保存:每 5 个工具 turn 落一次盘,中途崩溃丢失窗口从一整轮缩到几轮
865
- onTurnEnd: (() => {
866
- let n = 0
867
- return () => {
868
- if (++n % 5 !== 0) return
869
- try { saveSession(agent, state.lines) } catch {}
870
- }
871
- })(),
872
- }
873
-
874
- for (let resume = false; ; resume = true) {
875
- try {
876
- await runAgent(agent, text, callbacks, { signal: state.controller.signal, resume })
877
- flushStream()
878
- break // 正常完成,退出循环
879
- } catch (error) {
880
- flushStream()
881
- if (error.name === "AbortError" || state.controller?.signal.aborted) {
882
- pushLine("[stopped]", C.warn)
883
- break
884
- }
885
- if (error instanceof ContinueError) {
886
- pushLabel(`❯ Continue`, ansi.bold + C.warn)
887
- pushLine(`Ran ${error.turn} turns (limit ${error.turn}). Continue?`, C.warn)
888
- // 暂停询问:复用 permission 机制
889
- const willContinue = await new Promise((resolve) => {
890
- state.permission = {
891
- name: "continue",
892
- args: { turns: error.turn },
893
- resolve,
894
- }
895
- state.status = `Continue after ${error.turn} turns?`
896
- render()
897
- })
898
- state.permission = null
899
- if (!willContinue) {
900
- pushLine("[continue cancelled]", C.warn)
901
- break
902
- }
903
- pushLine("[continuing…]", C.tool)
904
- // 重创新 AbortController:旧 signal 一旦 abort 过,resume 会立即失败 (防御性,current路径不可达但耦合紧)
905
- state.controller = new AbortController()
906
- continue
907
- }
908
- pushLine(`[error] ${error.message}`, C.error)
909
- break
910
- }
911
- }
912
-
913
- clearInterval(ticker)
914
- state.processing = false
915
- state.subTasks = {}
916
- state.controller = null
917
- state.status = "Ready"
918
- // 全部完成时自动收起 todo 面板 (对齐 kimi-code TUI;agent.tasks 本身保留)
919
- if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
920
- state.tasks = []
921
- }
922
- // 每轮结束后保存会话 (崩溃也不丢)
923
- try {
924
- saveSession(agent, state.lines)
925
- } catch {
926
- // 存失败不打断使用
927
- }
928
- render()
929
-
930
- // 队列里有待执行消息:自动取下一条执行
931
- if (state.queue.length > 0) {
932
- const next = state.queue.shift()
933
- // 队列里的斜杠命令直接执行
934
- if (next.text.startsWith("/")) {
935
- await handleSlash(next.text)
936
- render()
937
- // 斜杠命令执行完也继续检查队列
938
- if (state.queue.length > 0 && !state.processing) {
939
- const next2 = state.queue.shift()
940
- await runAgentTurn(next2.text)
941
- }
942
- } else {
943
- pushLabel(`❯ You: (from queue)`, ansi.bold + C.user)
944
- await runAgentTurn(next.text)
945
- }
946
- }
947
- }
948
-
949
- function flushStream() {
950
- if (state.reasoning) {
951
- pushLine(state.reasoning, C.reason)
952
- state.reasoning = ""
953
- }
954
- if (state.streaming) {
955
- pushLine(state.streaming, C.text)
956
- state.streaming = ""
957
- }
958
- }
959
-
960
- function askPermission(name, args) {
961
- // auto 模式:完全授权,不再询问
962
- if (agent.autoApprove) {
963
- pushLine(` [auto] ${name} ${summarize(args)}`, C.warn)
964
- return Promise.resolve(true)
965
- }
966
- // 预览内容存到 permissionPreview,渲染在输入框上方紧挨"Allow?"提示
967
- state.permissionPreview = formatPermission(name, args)
968
- return new Promise((resolve) => {
969
- state.permission = { name, args, resolve }
970
- state.status = `Waiting: ${name}`
971
- render()
972
- })
973
- }
974
-
975
- /** 权限请求的关键信息 (按工具定制),返回行数组。name 可能带子 agent 前缀 ("coder/bash"),取基名匹配 */
976
- function formatPermission(name, args) {
977
- const cap = (s, n = 1000) => (s.length > n ? `${s.slice(0, n)}…(${s.length} chars total)` : s)
978
- const base = name.includes("/") ? name.split("/").pop() : name
979
- if (base === "bash") return cap(args.command ?? "").split("\n")
980
- if (base === "write") {
981
- // 批准写文件必须看得到要写什么:路径 + 内容预览
982
- return [`${args.path} (write ${(args.content ?? "").length} chars)`, ...cap(args.content ?? "", 1000).split("\n")]
983
- }
984
- if (base === "edit") {
985
- // 简易 diff:- 旧内容 / + 新内容
986
- return [
987
- `${args.path}`,
988
- ...cap(args.old_string ?? "", 500).split("\n").map((l) => `- ${l}`),
989
- " ↓",
990
- ...cap(args.new_string ?? "", 500).split("\n").map((l) => `+ ${l}`),
991
- ]
992
- }
993
- if (base === "apply_patch") {
994
- // 补丁本身就是可读的 diff,直接预览
995
- return cap(args.patch ?? "", 1500).split("\n")
996
- }
997
- if (base === "delete") return [`${args.path}${args.force ? " (force: also delete tracked files)" : ""}`]
998
- if (base === "subagent") return cap(args.task ?? "", 500).split("\n")
999
- if (base === "memory_put") return [`[${args.type ?? ""}] ${args.title ?? ""}`, ...cap(args.content ?? "", 500).split("\n")]
1000
- return [cap(summarize(args), 300)]
1001
- }
1002
-
1003
- function askQuestion(text, options = []) {
1004
- // 一次只能问一个:question 是只读工具走并行通道,同批第二个直接驳回,
1005
- // 否则后到的会覆盖 state.question,先到的 Promise 永远悬挂 (agent 死等)
1006
- if (state.question) {
1007
- return Promise.resolve("(error: another question is pending; ask one at a time and wait for the answer)")
1008
- }
1009
- if (!options.length) {
1010
- // 自由文本:打开输入态让用户打字,Enter 提交
1011
- pushLabel(`❯ Question`, ansi.bold + C.tool)
1012
- for (const line of text.split("\n")) pushLine(` ${line}`, C.text)
1013
- return new Promise((resolve) => {
1014
- state.question = { text, options: [], resolve }
1015
- state.status = "Waiting for answer..."
1016
- render()
1017
- })
1018
- }
1019
- // 选项模式:输入框内显示列表,方向键选,Enter 确认
1020
- pushLabel(`❯ Question`, ansi.bold + C.tool)
1021
- for (const line of text.split("\n")) pushLine(` ${line}`, C.text)
1022
- return new Promise((resolve) => {
1023
- state.question = { text, options, selected: 0, resolve }
1024
- state.status = "Waiting for choice..."
1025
- render()
1026
- })
1027
- }
1028
-
1029
- /** Ctrl+V / Alt+V:读取剪贴板图片 → 写入工作目录临时文件 → 输入框插入 read_image 命令 */
1030
- async function pasteClipboardImage(agent) {
1031
- const { execFile } = await import("node:child_process")
1032
- const { mkdir, stat, unlink } = await import("node:fs/promises")
1033
- const { join } = await import("node:path")
1034
-
1035
- const run = (cmd, args) => new Promise((resolve, reject) => {
1036
- execFile(cmd, args, { timeout: 10000 }, (err, stdout) => { if (err) reject(err); else resolve(stdout) })
1037
- })
1038
-
1039
- const dest = join(agent.cwd, `.thincoder-paste-${Date.now()}.png`)
1040
- const isWin = process.platform === "win32"
1041
- const isMac = process.platform === "darwin"
1042
-
1043
- try {
1044
- if (isWin) {
1045
- 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 }`
1046
- await run("powershell", ["-NoProfile", "-Command", psScript])
1047
- } else if (isMac) {
1048
- 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`
1049
- await run("osascript", ["-e", script])
1050
- } else {
1051
- 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`])
1052
- }
1053
- } catch {
1054
- pushLine("Clipboard does not contain an image, or clipboard access failed", C.dim)
1055
- try { await unlink(dest) } catch {}
1056
- return
1057
- }
1058
-
1059
- const st = await stat(dest).catch(() => null)
1060
- if (!st || st.size === 0) {
1061
- pushLine("Clipboard does not contain an image, or clipboard access failed", C.dim)
1062
- try { await unlink(dest) } catch {}
1063
- return
1064
- }
1065
-
1066
- const cmd = `read_image ${dest}`
1067
- state.input.splice(state.cursor, 0, ...[...cmd])
1068
- state.cursor += cmd.length
1069
- pushLine(`[image pasted → ${dest}]`, C.tool)
1070
- render()
1071
- }
1072
-
1073
- // ---------------------------------------------------------- 斜杠Commands
1074
-
1075
- const SLASH_COMMANDS = [
1076
- { name: "/plan", group: "Agent", desc: "toggle plan mode (design first, then implement)" },
1077
- { name: "/auto", group: "Agent", desc: "toggle auto-approve" },
1078
- { name: "/model", group: "Agent", desc: "select model" },
1079
- { name: "/goal", group: "Agent", desc: "set/view/cancel long-term goal" },
1080
- { name: "/think", group: "Agent", desc: "thinking mode & reasoning effort" },
1081
- { name: "/init", group: "Tools", desc: "generate project AGENTS.md skeleton" },
1082
- { name: "/skills", group: "Tools", desc: "list project skills" },
1083
- { name: "/mcp", group: "Tools", desc: "manage MCP servers" },
1084
- { name: "/provider", group: "Config", desc: "manage providers (add/remove/set key)" },
1085
- { name: "/config", group: "Config", desc: "config management (embedding / agent)" },
1086
- { name: "/reindex", group: "Config", desc: "rebuild memory index" },
1087
- { name: "/new", group: "Session", desc: "new session (old one archived to slot)" },
1088
- { name: "/session", group: "Session", desc: "list/switch archived sessions" },
1089
- { name: "/clear", group: "Session", desc: "clear screen" },
1090
- { name: "/distill", group: "Session", desc: "extract knowledge from session" },
1091
- { name: "/rewind", group: "Session", desc: "restore checkpoint" },
1092
- { name: "/exit", group: "Session", desc: "exit" },
1093
- { name: "/help", group: "", desc: "this list" },
1094
- ]
1095
-
1096
- async function handleSlash(text) {
1097
- const [cmd, ...rest] = text.split(/\s+/)
1098
- switch (cmd) {
1099
- case "/clear":
1100
- state.lines = []
1101
- state.streaming = ""
1102
- render()
1103
- return
1104
- case "/new":
1105
- agent.history = []
1106
- agent.tasks = []
1107
- agent.planMode = false
1108
- agent.goal = null
1109
- agent._pendingReminders = []
1110
- state.tasks = []
1111
- state.lines = []
1112
- state.streaming = ""
1113
- clearSession(agent.cwd)
1114
- pushLine("New session started (old session archived to slot; /session to view)", C.dim)
1115
- return
1116
- case "/exit":
1117
- cleanup()
1118
- setTimeout(() => process.exit(0), 100) // 延迟一拍:fetch 后立刻 exit 在 Windows/Node 24 会触发 libuv 断言
1119
- return
1120
- case "/session": {
1121
- const slots = listSlots(agent.cwd)
1122
- if (slots.length === 0) {
1123
- pushLine("No archived sessions (use /new and old sessions auto-archive to slots)", C.dim)
1124
- } else {
1125
- const entries = [
1126
- { type: "header", text: "Archived sessions (↑↓ select, Enter switch, Esc cancel)" },
1127
- ...slots.map((s) => ({ type: "item", text: `Slot ${s.slot} — ${s.date}`, slot: s.slot })),
1128
- ]
1129
- openPicker({
1130
- title: "Switch Session",
1131
- entries,
1132
- onSelect: (e) => {
1133
- const data = switchToSlot(agent.cwd, e.slot)
1134
- if (!data) {
1135
- pushLine(`Slot ${e.slot} not found`, C.dim)
1136
- return
1137
- }
1138
- applySession(agent, data)
1139
- state.lines = data.display.length
1140
- ? data.display.map((l) => ({ text: l.text, color: l.color }))
1141
- : []
1142
- state.tasks = agent.tasks ?? []
1143
- if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
1144
- state.tasks = []
1145
- }
1146
- pushLabel(`── Switched to slot ${e.slot} (${data.history.length} messages) ──`, C.warn)
1147
- render()
1148
- },
1149
- })
1150
- }
1151
- return
1152
- }
1153
- case "/reindex": {
1154
- const { syncDir, codeSync, docSync } = await import("./memory.mjs")
1155
- pushLine("[reindex] Rebuilding index...", C.tool)
1156
- agent.memory.db.prepare("DELETE FROM files").run()
1157
- agent.memory.db.prepare("DELETE FROM code_chunks").run()
1158
- agent.memory.db.prepare("DELETE FROM doc_chunks").run()
1159
- let total = 0
1160
- if (distillOpts.projectDir) {
1161
- const s = await syncDir(agent.memory, { layer: "project", dir: distillOpts.projectDir })
1162
- total += s.added
1163
- pushLine(` project: +${s.added} ~${s.updated} -${s.removed}`, C.dim)
1164
- }
1165
- if (distillOpts.team?.dir) {
1166
- const s = await syncDir(agent.memory, { layer: "team", dir: distillOpts.team.dir })
1167
- total += s.added
1168
- pushLine(` team: +${s.added} ~${s.updated} -${s.removed}`, C.dim)
1169
- }
1170
- // 重建代码索引和文档索引并行(读写不同表,WAL 支持)
1171
- pushLine(` [code+doc] Rebuilding indexes...`, C.tool)
1172
- const [cr, dr] = await Promise.all([
1173
- codeSync(agent.memory, agent.cwd, {
1174
- onProgress: (p) => {
1175
- if (p.phase === "index" && p.current % 20 === 0) {
1176
- pushLine(` code: ${p.current}/${p.total}`, C.dim)
1177
- }
1178
- }
1179
- }),
1180
- docSync(agent.memory, agent.cwd, {
1181
- onProgress: (p) => {
1182
- if (p.phase === "index" && p.current % 5 === 0) {
1183
- pushLine(` doc: ${p.current}/${p.total}`, C.dim)
1184
- }
1185
- }
1186
- }),
1187
- ])
1188
- pushLine(` code: ${cr.total} files, +${cr.updated} ~${cr.skipped} -${cr.removed}`, C.dim)
1189
- pushLine(` doc: ${dr.total} files, +${dr.updated} ~${dr.skipped} -${dr.removed}`, C.dim)
1190
- pushLine(`[reindex] Done, ${total} entries total. Vectors will be lazily generated on next search.`, C.tool)
1191
- return
1192
- }
1193
- case "/distill":
1194
- await runDistill()
1195
- return
1196
- case "/init": {
1197
- const { existsSync } = await import("node:fs")
1198
- const { writeFile, readFile } = await import("node:fs/promises")
1199
- const { join, basename } = await import("node:path")
1200
- const agPath = join(agent.cwd, "AGENTS.md")
1201
- if (existsSync(agPath)) {
1202
- pushLine(`AGENTS.md already exists: ${agPath}`, C.warn)
1203
- return
1204
- }
1205
-
1206
- // 探测项目类型与关键信息
1207
- let name = basename(agent.cwd)
1208
- let lang = "", cmds = ""
1209
-
1210
- // Node.js
1211
- try {
1212
- const pkg = JSON.parse(await readFile(join(agent.cwd, "package.json"), "utf8"))
1213
- if (pkg.name) name = pkg.name
1214
- lang = "Node.js"
1215
- const ks = Object.keys(pkg.scripts ?? {})
1216
- if (ks.length) cmds = ks.slice(0, 5).map(k => `- \`npm run ${k}\``).join("\n")
1217
- } catch {}
1218
-
1219
- // Python
1220
- if (!lang) {
1221
- for (const f of ["requirements.txt", "pyproject.toml", "setup.py", "setup.cfg"]) {
1222
- if (existsSync(join(agent.cwd, f))) { lang = "Python"; break }
1223
- }
1224
- if (lang) cmds = "- `pip install -r requirements.txt`\n- `python -m pytest`"
1225
- }
1226
-
1227
- // Go
1228
- if (!lang) {
1229
- if (existsSync(join(agent.cwd, "go.mod"))) {
1230
- lang = "Go"
1231
- cmds = "- `go build ./...`\n- `go test ./...`"
1232
- }
1233
- }
1234
-
1235
- // Rust
1236
- if (!lang) {
1237
- if (existsSync(join(agent.cwd, "Cargo.toml"))) {
1238
- lang = "Rust"
1239
- cmds = "- `cargo build`\n- `cargo test`"
1240
- }
1241
- }
1242
-
1243
- // Java / Kotlin
1244
- if (!lang) {
1245
- if (existsSync(join(agent.cwd, "pom.xml"))) { lang = "Java (Maven)"; cmds = "- `mvn test`" }
1246
- else if (existsSync(join(agent.cwd, "build.gradle")) || existsSync(join(agent.cwd, "build.gradle.kts"))) {
1247
- lang = "Java/Kotlin (Gradle)"; cmds = "- `gradle test`"
1248
- }
1249
- }
1250
-
1251
- const lines = [`# ${name}`, ""]
1252
- if (lang) {
1253
- lines.push(`## Tech Stack`, "", lang, "")
1254
- if (cmds) lines.push(`## Commands`, "", cmds, "")
1255
- }
1256
-
1257
- const template = lines.join("\n")
1258
- await writeFile(agPath, template, "utf8")
1259
- pushLabel(`❯ Init`, ansi.bold + C.tool)
1260
- pushLine(`Generated AGENTS.md → ${agPath}${lang ? ` (${lang})` : ""}`, C.tool)
1261
- if (lang) pushLine("Tell me more about the project and I will fill in conventions and structure", C.dim)
1262
- return
1263
- }
1264
- case "/rewind": {
1265
- const { listCheckpoints, rewind, isGitRepo } = await import("./checkpoint.mjs")
1266
- if (!isGitRepo(agent.cwd)) {
1267
- pushLine("[rewind] not a git repository, checkpoints unavailable", C.error)
1268
- return
1269
- }
1270
- const cps = await listCheckpoints(agent.cwd)
1271
- if (cps.length === 0) {
1272
- pushLine("(no checkpoints — created automatically before each task)", C.dim)
1273
- return
1274
- }
1275
- const entries = [
1276
- { type: "header", text: "Checkpoints (↑↓ select, Enter restore, Esc cancel)" },
1277
- ...cps.slice(0, 12).map((cp) => ({
1278
- type: "item",
1279
- text: `${cp.id} ${new Date(cp.time).toLocaleString()} (+${cp.untracked} untracked files)`,
1280
- id: cp.id,
1281
- })),
1282
- ]
1283
- openPicker({
1284
- title: "Restore Checkpoint",
1285
- entries,
1286
- onSelect: async (e) => {
1287
- try {
1288
- const summary = await rewind(agent.cwd, e.id)
1289
- pushLabel(`❯ Rewind`, ansi.bold + C.warn)
1290
- pushLine(`Restored to ${e.id}: patch ${summary.patchApplied ? "applied" : "none"},deleted ${summary.deleted} new files, restored ${summary.restored} 个`, C.tool)
1291
- pushLine("(current state saved as new checkpoint; /rewind again to go back)", C.dim)
1292
- } catch (error) {
1293
- pushLine(`[rewind] ${error.message}`, C.error)
1294
- }
1295
- },
1296
- })
1297
- return
1298
- }
1299
- case "/plan": {
1300
- agent.planMode = !agent.planMode
1301
- agent._pendingReminders = agent._pendingReminders ?? []
1302
- if (agent.planMode) {
1303
- agent._pendingReminders.push("[System reminder: plan mode is now ON. You are restricted to READ-ONLY tools — explore, search, read, analyze. DO NOT write, edit, or run mutation commands. Present your design to the user first.]")
1304
- } else {
1305
- agent._pendingReminders.push("[System reminder: plan mode is now OFF. You may edit files, run commands, and implement changes.]")
1306
- }
1307
- pushLabel(`❯ Plan`, ansi.bold + (agent.planMode ? C.tool : C.dim))
1308
- pushLine(
1309
- agent.planMode
1310
- ? `Plan mode ON: read-only tools only. Design first, then implement. /plan again to exit.`
1311
- : `Plan mode OFF: you may now edit files and run commands.`,
1312
- agent.planMode ? C.tool : C.dim,
1313
- )
1314
- return
1315
- }
1316
- case "/goal": {
1317
- const entries = [
1318
- { type: "header", text: agent.goal ? `Current goal: ${agent.goal.objective.slice(0, 60)}` : "Actions" },
1319
- { type: "item", text: "Set new goal", action: "set" },
1320
- ]
1321
- if (agent.goal) {
1322
- entries.push({ type: "item", text: "Cancel goal", action: "cancel" })
1323
- entries.push({ type: "item", text: "View details", action: "view" })
1324
- }
1325
- openPicker({
1326
- title: "Goal",
1327
- entries,
1328
- onSelect: (e) => {
1329
- if (e.action === "view") {
1330
- const statusText = { active: "active", complete: "completed", blocked: "blocked" }[agent.goal.status] ?? agent.goal.status
1331
- pushLabel(`❯ Goal`, ansi.bold + C.warn)
1332
- pushLine(`Goal: ${agent.goal.objective}`, C.tool)
1333
- if (agent.goal.criteria) pushLine(` Criteria: ${agent.goal.criteria}`, C.dim)
1334
- pushLine(` Status: ${statusText} │ Turns used: ${agent.goal.turnsUsed ?? 0} │ Set at: ${new Date(agent.goal.setAt).toLocaleString()}`, C.dim)
1335
- return
1336
- }
1337
- if (e.action === "cancel") {
1338
- agent.goal = null
1339
- pushLabel(`❯ Goal`, ansi.bold + C.dim)
1340
- pushLine(`Goal cancelled.`, C.dim)
1341
- return
1342
- }
1343
- // set — 需要输入目标文本
1344
- askQuestion("Enter goal description (; separates criteria)").then((text) => {
1345
- if (!text) return
1346
- const semi = text.indexOf(";") >= 0 ? ";" : text.indexOf(";") >= 0 ? ";" : null
1347
- const objective = semi ? text.slice(0, semi).trim() : text.trim()
1348
- const criteria = semi ? text.slice(semi + 1).trim() : ""
1349
- agent.goal = { objective, criteria, setAt: Date.now(), status: "active", turnsUsed: 0, _blockTally: null }
1350
- pushLabel(`❯ Goal`, ansi.bold + C.warn)
1351
- pushLine(`Goal set: ${objective}`, C.tool)
1352
- if (criteria) pushLine(` Criteria: ${criteria}`, C.dim)
1353
- else pushLine(` ⚠ No criteria — agent will be asked to provide verifiable criteria when using goal set`, C.warn)
1354
- })
1355
- },
1356
- })
1357
- return
1358
- }
1359
- case "/skills": {
1360
- const { loadSkills } = await import("./skills.mjs")
1361
- const skills = await loadSkills(agent.cwd)
1362
- pushLabel(`❯ Skills`, ansi.bold + C.tool)
1363
- if (skills.length === 0) {
1364
- pushLine(" (none项目技能——在 .thincoder/skills/ 下创建 .md 文件即可添加)", C.dim)
1365
- }
1366
- for (const s of skills) {
1367
- pushLine(` ${s.name}: ${s.description.slice(0, 100)}`, C.dim)
1368
- }
1369
- pushLine("激活: 告诉 agent \"load the <name> skill\"", C.dim)
1370
- return
1371
- }
1372
- case "/mcp": {
1373
- const servers = agent.config?.mcp?.servers ?? []
1374
- const entries = [
1375
- { type: "header", text: `${servers.length} MCP servers configured` },
1376
- { type: "item", text: "View list", action: "list" },
1377
- { type: "item", text: "Add server", action: "add" },
1378
- ]
1379
- if (servers.length > 0) {
1380
- entries.push(
1381
- { type: "item", text: "Remove server", action: "remove" },
1382
- { type: "item", text: "Reconnect server", action: "connect" },
1383
- )
1384
- }
1385
- openPicker({
1386
- title: "MCP",
1387
- entries,
1388
- onSelect: async (e) => {
1389
- if (e.action === "list") {
1390
- pushLabel(`❯ MCP Servers`, ansi.bold + C.tool)
1391
- if (servers.length === 0) {
1392
- pushLine(" (none MCP server)", C.dim)
1393
- }
1394
- for (const srv of servers) {
1395
- const connected = agent.tools.some((t) => t._mcpName === srv.name)
1396
- const mark = connected ? "●" : "○"
1397
- const color = connected ? C.tool : C.dim
1398
- const toolCount = agent.tools.filter((t) => t._mcpName === srv.name).length
1399
- const desc = srv.wsUrl ? srv.wsUrl : srv.url ? srv.url : `${srv.command} ${(srv.args ?? []).join(" ")}`
1400
- pushLine(` ${mark} ${srv.name}: ${desc} (${toolCount} tools)`, color)
1401
- }
1402
- return
1403
- }
1404
- if (e.action === "remove") {
1405
- const removeEntries = [
1406
- { type: "header", text: "Select server to remove" },
1407
- ...servers.map((s) => ({ type: "item", text: s.name, name: s.name })),
1408
- ]
1409
- openPicker({
1410
- title: "Remove MCP",
1411
- entries: removeEntries,
1412
- onSelect: async (se) => {
1413
- const { removeMcpTools } = await import("./mcp.mjs")
1414
- removeMcpTools(agent, se.name)
1415
- await persistRaw((raw) => { raw.mcp ??= { servers: [] }; raw.mcp.servers = raw.mcp.servers.filter((s) => s.name !== se.name) })
1416
- if (agent.config?.mcp?.servers) agent.config.mcp.servers = agent.config.mcp.servers.filter((s) => s.name !== se.name)
1417
- pushLabel(`❯ MCP`, ansi.bold + C.tool)
1418
- pushLine(`${se.name} disconnected and removed from config.`, C.tool)
1419
- },
1420
- })
1421
- return
1422
- }
1423
- if (e.action === "connect") {
1424
- const connEntries = [
1425
- { type: "header", text: "Select server to reconnect" },
1426
- ...servers.map((s) => ({ type: "item", text: s.name, name: s.name })),
1427
- ]
1428
- openPicker({
1429
- title: "Reconnect MCP",
1430
- entries: connEntries,
1431
- onSelect: async (se) => {
1432
- const srv = servers.find((s) => s.name === se.name)
1433
- if (!srv) return
1434
- const { removeMcpTools, connectMcpServer } = await import("./mcp.mjs")
1435
- removeMcpTools(agent, se.name)
1436
- try {
1437
- pushLine(`[mcp] Reconnecting ${se.name}...`, C.dim)
1438
- const tools = await connectMcpServer(srv)
1439
- agent.tools.push(...tools)
1440
- pushLabel(`❯ MCP`, ansi.bold + C.tool)
1441
- pushLine(`${se.name} reconnected, ${tools.length} tools available.`, C.tool)
1442
- } catch (error) {
1443
- pushLine(`[mcp] ${se.name}: ${error.message}`, C.error)
1444
- }
1445
- },
1446
- })
1447
- return
1448
- }
1449
- if (e.action === "add") {
1450
- askQuestion("输入: <名称> <URL|Commands> [参数...]\nURL 自动识别: https://… → HTTP, ws://… → WebSocket, 其他 → stdio Commands").then(async (text) => {
1451
- if (!text) return
1452
- const parts = text.split(/\s+/)
1453
- if (parts.length < 2) { pushLine("用法: <名称> <URL|Commands> [参数...]", C.error); return }
1454
- const [name, second, ...extras] = parts
1455
- const existing = (agent.config?.mcp?.servers ?? []).find((s) => s.name === name)
1456
- if (existing) { pushLine(`[mcp] "${name}" already exists`, C.error); return }
1457
- const isWS = /^wss?:\/\//.test(second)
1458
- const isHTTP = /^https?:\/\//.test(second)
1459
- let srv
1460
- if (isWS) {
1461
- const headers = parseHeaders(extras)
1462
- srv = { name, wsUrl: second, headers: Object.keys(headers).length > 0 ? headers : undefined }
1463
- } else if (isHTTP) {
1464
- const headers = parseHeaders(extras)
1465
- srv = { name, url: second, headers: Object.keys(headers).length > 0 ? headers : undefined }
1466
- } else {
1467
- srv = { name, command: second, args: extras.length > 0 ? extras : undefined }
1468
- }
1469
- await addAndConnect(srv)
1470
- })
1471
- }
1472
- },
1473
- })
1474
- return
1475
- }
1476
-
1477
- // ---- header 解析 (/mcp add 共享)----
1478
- function parseHeaders(pairs) {
1479
- const headers = {}
1480
- for (const pair of pairs) {
1481
- const eq = pair.indexOf("=")
1482
- if (eq > 0) headers[pair.slice(0, eq)] = pair.slice(eq + 1).replace(/^["']|["']$/g, "")
1483
- }
1484
- return headers
1485
- }
1486
-
1487
- // ---- /mcp 共享 helper: 保存Config + Connecting ----
1488
- async function addAndConnect(srv) {
1489
- await persistRaw((raw) => {
1490
- raw.mcp ??= { servers: [] }
1491
- const entry = { name: srv.name }
1492
- if (srv.url) { entry.url = srv.url; if (srv.headers) entry.headers = srv.headers }
1493
- else if (srv.wsUrl) { entry.wsUrl = srv.wsUrl; if (srv.headers) entry.headers = srv.headers }
1494
- else { entry.command = srv.command; if (srv.args) entry.args = srv.args }
1495
- raw.mcp.servers.push(entry)
1496
- })
1497
- agent.config ??= {}
1498
- agent.config.mcp ??= { servers: [] }
1499
- agent.config.mcp.servers.push(srv)
1500
- try {
1501
- pushLine(`[mcp] Connecting ${srv.name}...`, C.dim)
1502
- const { connectMcpServer } = await import("./mcp.mjs")
1503
- const tools = await connectMcpServer(srv)
1504
- agent.tools.push(...tools)
1505
- pushLabel(`❯ MCP`, ansi.bold + C.tool)
1506
- const desc = srv.wsUrl ? srv.wsUrl : srv.url ? srv.url : `${srv.command} ${(srv.args ?? []).join(" ")}`
1507
- pushLine(`${srv.name} (${desc}) connected, ${tools.length} tools:`, C.tool)
1508
- for (const t of tools) pushLine(` ${t.name}: ${t.description.slice(0, 100)}`, C.dim)
1509
- } catch (error) {
1510
- pushLine(`[mcp] ${srv.name}: ${error.message} (config saved, retry after restart)`, C.error)
1511
- }
1512
- }
1513
- case "/auto":
1514
- agent.autoApprove = !agent.autoApprove
1515
- agent._pendingReminders = agent._pendingReminders ?? []
1516
- if (agent.autoApprove) {
1517
- agent._pendingReminders.push("[System reminder: AUTO mode is now ON. All tool calls are automatically approved — you may write, edit, and run commands without asking. Use this for long autonomous tasks. The user can still interrupt.]")
1518
- } else {
1519
- agent._pendingReminders.push("[System reminder: AUTO mode is now OFF. Destructive tool calls now require user approval again. Confirm before writing files, running commands, or spawning subagents.]")
1520
- }
1521
- pushLabel(`❯ Auto`, ansi.bold + (agent.autoApprove ? C.warn : C.tool))
1522
- pushLine(
1523
- agent.autoApprove
1524
- ? `AUTO ON: all tool calls (write/bash/subagent) auto-approved. For long tasks. /auto to disable.`
1525
- : `AUTO OFF: destructive tool calls require per-use approval again.`,
1526
- agent.autoApprove ? C.warn : C.dim,
1527
- )
1528
- return
1529
- case "/think": {
1530
- const cur = agent.provider
1531
- const thinkingEnabled = cur.thinking?.type === "enabled" || cur.thinking?.type === undefined
1532
- const { specForModel } = await import("./config.mjs")
1533
- const spec = specForModel(cur.model)
1534
- const isEffortOnly = spec.thinkApi === "effort"
1535
-
1536
- const entries = [
1537
- { type: "header", text: "Thinking mode" },
1538
- { type: "item", text: `On${thinkingEnabled ? " ← current" : ""}`, action: "on" },
1539
- { type: "item", text: `Off${!thinkingEnabled ? " ← current" : ""}`, action: "off" },
1540
- { type: "header", text: "Reasoning effort" },
1541
- ...["low", "high", "max"].map((l) => ({
1542
- type: "item",
1543
- text: `${l}${cur.reasoningEffort === l ? " ← current" : ""}`,
1544
- action: "effort",
1545
- level: l,
1546
- })),
1547
- ]
1548
- openPicker({
1549
- title: "Thinking mode",
1550
- entries,
1551
- defaultIndex: thinkingEnabled ? 0 : 1,
1552
- onSelect: async (e) => {
1553
- if (e.action === "effort") {
1554
- cur.reasoningEffort = e.level
1555
- await syncProviderField("reasoningEffort", e.level)
1556
- pushLabel(`❯ Think`, ansi.bold + C.tool)
1557
- pushLine(`Reasoning effort set to ${e.level}`, C.tool)
1558
- } else {
1559
- const enable = e.action === "on"
1560
- if (isEffortOnly) {
1561
- if (!enable) delete cur.reasoningEffort
1562
- else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
1563
- if (!enable) await syncProviderField("reasoningEffort", undefined)
1564
- else await syncProviderField("reasoningEffort", cur.reasoningEffort)
1565
- } else {
1566
- cur.thinking = enable ? { type: "enabled" } : { type: "disabled" }
1567
- if (!enable) delete cur.reasoningEffort
1568
- else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
1569
- await syncProviderField("thinking", cur.thinking)
1570
- if (!enable) await syncProviderField("reasoningEffort", undefined)
1571
- else await syncProviderField("reasoningEffort", cur.reasoningEffort)
1572
- }
1573
- pushLabel(`❯ Think`, ansi.bold + C.tool)
1574
- pushLine(`Thinking mode已${enable ? "On" : "Off"}`, C.tool)
1575
- if (enable) pushLine(`Reasoning effort: ${cur.reasoningEffort}`, C.dim)
1576
- }
1577
- },
1578
- })
1579
- return
1580
- }
1581
- case "/model": {
1582
- openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
1583
- return
1584
- }
1585
- case "/provider": {
1586
- const entries = [
1587
- { type: "header", text: `${agent.providers.length} providers` },
1588
- { type: "item", text: "View list", action: "list" },
1589
- { type: "item", text: "Add provider", action: "add" },
1590
- ]
1591
- if (agent.providers.length > 0) {
1592
- entries.push(
1593
- { type: "item", text: "Remove provider", action: "remove" },
1594
- )
1595
- }
1596
- if (!agent.provider.apiKey) {
1597
- entries.push({ type: "item", text: "Set API Key", action: "key" })
1598
- } else {
1599
- entries.push({ type: "item", text: "Change API Key", action: "key" })
1600
- }
1601
- openPicker({
1602
- title: "Providers",
1603
- entries,
1604
- onSelect: async (e) => {
1605
- if (e.action === "list") {
1606
- pushLabel(`❯ Providers (${agent.providers.length})`, ansi.bold + C.tool)
1607
- for (const p of agent.providers) {
1608
- const active = p.name === agent.activeProvider
1609
- pushLine(
1610
- `${active ? " ▸" : " "} ${p.name.padEnd(12)} ${p.model.padEnd(20)} ${p.baseURL}${p.apiKey ? " ●key" : " ○nonekey"}${active ? " ← current" : ""}`,
1611
- active ? C.tool : C.dim,
1612
- )
1613
- }
1614
- return
1615
- }
1616
- if (e.action === "remove") {
1617
- const candidates = agent.providers.filter((p) => p.name !== agent.activeProvider)
1618
- if (candidates.length === 0) {
1619
- pushLine("Cannot remove current provider (switch to another with /model first)", C.warn)
1620
- return
1621
- }
1622
- const removeEntries = [
1623
- { type: "header", text: "选择要移除的 provider (current使用的不可移除)" },
1624
- ...candidates.map((p) => ({ type: "item", text: `${p.name} (${p.model})`, name: p.name })),
1625
- ]
1626
- openPicker({
1627
- title: "Remove Provider",
1628
- entries: removeEntries,
1629
- onSelect: async (se) => {
1630
- const at = agent.providers.findIndex((p) => p.name === se.name)
1631
- agent.providers.splice(at, 1)
1632
- await persistRaw((raw) => { raw.providers = agent.providers })
1633
- pushLabel(`❯ Provider`, ansi.bold + C.tool)
1634
- pushLine(`Removed ${se.name}`, C.tool)
1635
- },
1636
- })
1637
- return
1638
- }
1639
- if (e.action === "add") {
1640
- // Add needs text input: name baseURL model
1641
- askQuestion(
1642
- `输入: <名称> <baseURL> <model>\n预设可用: ${Object.keys(PRESETS).join(", ")}\nor just a preset name (e.g. deepseek) for auto-fill`,
1643
- ).then(async (text) => {
1644
- if (!text) return
1645
- const parts = text.split(/\s+/)
1646
- const name = parts[0]
1647
- if (!name) return
1648
- if (agent.providers.some((p) => p.name === name)) {
1649
- pushLine(`"${name}" already exists;先 /provider → 移除`, C.warn)
1650
- return
1651
- }
1652
- const preset = PRESETS[name]
1653
- const baseURL = (parts[1] ?? preset?.baseURL)?.replace(/\/+$/, "")
1654
- const model = parts[2] ?? preset?.model
1655
- if (!baseURL || !model) {
1656
- pushLine(`Missing args: ${name} <baseURL> <model>`, C.error)
1657
- return
1658
- }
1659
- if (!/^https?:\/\//.test(baseURL)) { pushLine(`baseURL must start with http(s)://`, C.error); return }
1660
- agent.providers.push({ name, baseURL, model, ...(preset?.desc ? { desc: preset.desc } : {}) })
1661
- await persistRaw((raw) => { raw.providers = agent.providers })
1662
- pushLabel(`❯ Provider`, ansi.bold + C.tool)
1663
- pushLine(`Added ${name} (${baseURL} / ${model})`, C.tool)
1664
- pushLine(`Next: /provider → Set Key`, C.dim)
1665
- })
1666
- return
1667
- }
1668
- if (e.action === "key") {
1669
- // Key: pick which provider, then prompt for key
1670
- const keyEntries = [
1671
- { type: "header", text: "Select provider to configure key" },
1672
- ...agent.providers.map((p) => ({
1673
- type: "item",
1674
- text: `${p.name}${p.name === agent.activeProvider ? " ← current" : ""}${p.apiKey ? " ●has key" : " ○nonekey"}`,
1675
- name: p.name,
1676
- })),
1677
- ]
1678
- openPicker({
1679
- title: "Configure API Key",
1680
- entries: keyEntries,
1681
- onSelect: (se) => {
1682
- askQuestion(`Enter API key for ${se.name}:`).then(async (key) => {
1683
- if (!key) return
1684
- await setProviderKey(se.name, key)
1685
- })
1686
- },
1687
- })
1688
- }
1689
- },
1690
- })
1691
- return
1692
- }
1693
- case "/config": {
1694
- const entries = [
1695
- { type: "header", text: "Config" },
1696
- { type: "item", text: "View current config", action: "view" },
1697
- { type: "item", text: "Set embedding key (vector search)", action: "embedkey" },
1698
- { type: "item", text: "Advanced (set path value)", action: "set" },
1699
- ]
1700
- openPicker({
1701
- title: "Config",
1702
- entries,
1703
- onSelect: async (e) => {
1704
- if (e.action === "view") {
1705
- const { configPath: cp } = await import("./config.mjs")
1706
- pushLabel(`❯ Config`, ansi.bold + C.tool)
1707
- pushLine(`Active: ${agent.activeProvider} / ${agent.provider.model}`, C.dim)
1708
- pushLine(`Key: ${maskKey(agent.provider.apiKey)}`, C.dim)
1709
- const ac = agent.config?.agent ?? {}
1710
- const tn = `${ac.compactThreshold ?? 100000}${ac.compactThresholdAuto ? " (auto)" : ""}`
1711
- pushLine(`agent: maxTurns=${ac.maxTurns ?? 100} | compactThreshold=${tn}`, C.dim)
1712
- pushLine(`embedding: ${agent.memory?.embedder ? `enabled (${agent.config?.embedding?.model ?? ""})` : "disabled (FTS only)"}`, C.dim)
1713
- pushLine(`Config文件: ${cp}`, C.dim)
1714
- return
1715
- }
1716
- if (e.action === "embedkey") {
1717
- askQuestion("Enter embedding API key (default: SiliconFlow bge-m3):").then(async (key) => {
1718
- if (!key) return
1719
- agent.config.embedding ??= {}
1720
- agent.config.embedding.apiKey = key
1721
- await persistRaw((raw) => { raw.embedding = { ...(raw.embedding ?? {}), apiKey: key } })
1722
- if (agent.memory) {
1723
- const { createEmbedder } = await import("./embedding.mjs")
1724
- agent.memory.embedder = createEmbedder(agent.config.embedding)
1725
- }
1726
- pushLabel(`❯ Config`, ansi.bold + C.tool)
1727
- pushLine(`Embedding key saved, vector search enabled`, C.tool)
1728
- })
1729
- return
1730
- }
1731
- if (e.action === "set") {
1732
- askQuestion("Enter: <path> <value> (e.g. agent.maxTurns 80, supports a.b nesting):").then(async (text) => {
1733
- if (!text) return
1734
- const parts = text.split(/\s+/)
1735
- const [path, value] = [parts[0], parts.slice(1).join(" ")]
1736
- if (!path || !value) { pushLine("Usage: <path> <value> e.g. agent.maxTurns 80", C.error); return }
1737
- try {
1738
- const { configPath, loadConfig, saveConfig } = await import("./config.mjs")
1739
- const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
1740
- const keys = path.split(".")
1741
- let obj = raw
1742
- for (let i = 0; i < keys.length - 1; i++) { obj[keys[i]] ??= {}; obj = obj[keys[i]] }
1743
- obj[keys[keys.length - 1]] = isNaN(value) ? value : Number(value)
1744
- saveConfig(raw)
1745
- const cfg = loadConfig()
1746
- agent.provider = cfg.provider
1747
- agent.providers = cfg.providersList
1748
- agent.activeProvider = cfg.activeProvider
1749
- agent.config = cfg
1750
- pushLabel(`❯ Config`, ansi.bold + C.tool)
1751
- pushLine(`Saved: ${path} = ${value}`, C.tool)
1752
- } catch (error) {
1753
- pushLine(`Save failed: ${error.message}`, C.error)
1754
- }
1755
- })
1756
- }
1757
- },
1758
- })
1759
- return
1760
- }
1761
- case "/help": {
1762
- const order = ["Agent", "Session", "Tools", "Config"]
1763
- const byGroup = new Map()
1764
- for (const c of SLASH_COMMANDS) {
1765
- if (!c.group) continue
1766
- if (!byGroup.has(c.group)) byGroup.set(c.group, [])
1767
- byGroup.get(c.group).push(c)
1768
- }
1769
- const maxW = Math.max(...SLASH_COMMANDS.map((c) => c.name.length))
1770
- for (const g of order) {
1771
- const cmds = byGroup.get(g)
1772
- if (!cmds?.length) continue
1773
- byGroup.delete(g)
1774
- pushLabel(`❯ ${g}`, ansi.bold + C.tool)
1775
- for (const c of cmds) pushLine(` ${c.name.padEnd(maxW + 1)} ${c.desc}`, C.dim)
1776
- }
1777
- for (const [g, cmds] of byGroup) {
1778
- pushLabel(`❯ ${g}`, ansi.bold + C.tool)
1779
- for (const c of cmds) pushLine(` ${c.name.padEnd(maxW + 1)} ${c.desc}`, C.dim)
1780
- }
1781
- return
1782
- }
1783
- default:
1784
- pushLine(`Unknown command: ${cmd} (/help 查看可用Commands)`, C.error)
1785
- return
1786
- }
1787
- }
1788
-
1789
- function maskKey(key) {
1790
- if (!key) return "(none)"
1791
- if (key.length <= 8) return "***"
1792
- return `${key.slice(0, 5)}…${key.slice(-4)}`
1793
- }
1794
-
1795
- /** Tab 补全候选:Commands名 / 子Commands / provider 名 / 预设名 / think 参数 */
1796
- function completions(input) {
1797
- if (!input.startsWith("/")) return []
1798
- const parts = input.split(/\s+/)
1799
- // 还在敲第一个 token:补Commands名
1800
- if (parts.length === 1) {
1801
- return SLASH_COMMANDS.filter((c) => c.name.startsWith(parts[0])).map((c) => c.name)
1802
- }
1803
- const cmd = parts[0]
1804
- const last = parts.at(-1) // 结尾是空格时Enter API key for "",即列出全部候选
1805
- const head = parts.slice(0, -1).join(" ")
1806
- const argIndex = parts.length - 2 // 正在敲第几个参数 (0 基)
1807
- const match = (cands) => cands.filter((c) => c.startsWith(last)).map((c) => `${head} ${c}`)
1808
- if (cmd === "/model" && argIndex === 0) return match(agent.providers.map((p) => p.name))
1809
- if (cmd === "/provider") {
1810
- if (argIndex === 0) return match(["add", "remove", "key"])
1811
- if (argIndex === 1 && parts[1] === "add") return match(Object.keys(PRESETS))
1812
- if (argIndex === 1 && (parts[1] === "remove" || parts[1] === "key")) return match(agent.providers.map((p) => p.name))
1813
- }
1814
- if (cmd === "/think") {
1815
- if (argIndex === 0) return match(["on", "off", "effort"])
1816
- if (argIndex === 1 && parts[1] === "effort") return match(["low", "high", "max"])
1817
- }
1818
- if (cmd === "/config" && argIndex === 0) return match(["embedkey", "set"])
1819
- if (cmd === "/goal" && argIndex === 0) return match(["set", "cancel"])
1820
- if (cmd === "/mcp") {
1821
- if (argIndex === 0) return match(["add", "url", "ws", "remove", "connect", "list"])
1822
- if (argIndex === 1 && (parts[1] === "remove" || parts[1] === "connect")) return match((agent.config?.mcp?.servers ?? []).map((s) => s.name))
1823
- }
1824
- return []
1825
- }
1826
-
1827
- /** Tab:计算候选并循环替换输入 */
1828
- function handleTab() {
1829
- const input = state.input.join("")
1830
- if (state.completion && input === state.completion.candidates[state.completion.index]) {
1831
- // 上一次的候选还在输入框:循环到下一个
1832
- state.completion.index = (state.completion.index + 1) % state.completion.candidates.length
1833
- } else {
1834
- const candidates = completions(input)
1835
- if (candidates.length === 0) return
1836
- state.completion = { candidates, index: 0 }
1837
- }
1838
- const text = state.completion.candidates[state.completion.index]
1839
- state.input = [...text]
1840
- state.cursor = state.input.length
1841
- render()
1842
- }
1843
-
1844
- /** 读Config文件 → 修改 → 写回;文件not found时从空对象开始 */
1845
- async function persistRaw(mutate) {
1846
- const { saveConfig, configPath } = await import("./config.mjs")
1847
- const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
1848
- mutate(raw)
1849
- saveConfig(raw)
1850
- }
1851
-
1852
- /** 把current激活 provider 的某个字段同步到 providers 列表并持久化 */
1853
- async function syncProviderField(field, value) {
1854
- const target = agent.providers.find((p) => p.name === agent.activeProvider)
1855
- if (!target) return
1856
- if (value === undefined) delete target[field]
1857
- else target[field] = value
1858
- // 全量写回:raw 里的 providers 顺序/内容可能与运行时列表不一致,逐字段改容易写错位
1859
- await persistRaw((raw) => {
1860
- raw.providers = agent.providers
1861
- })
1862
- }
1863
-
1864
- // ---------------------------------------------------------- 模型选择器 (/model)
1865
-
1866
- const pickerItems = () => state.picker?.entries.filter((e) => e.type === "item") ?? []
1867
-
1868
- /** 打开通用列表选择器。entries 含 { type: "header"|"item", text, note?, ...extra },
1869
- * onSelect 拿到选中条目 (含 extra 字段透传),onCancel 在 Esc 时调。 */
1870
- function openPicker({ title, entries, onSelect, onCancel, defaultIndex = 0 }) {
1871
- state.picker = { title, entries, lines: [], index: defaultIndex, scroll: 0, selectedLine: 0, onSelect, onCancel }
1872
- renderPickerLines()
1873
- }
1874
-
1875
- function closePicker() {
1876
- state.picker?.onCancel?.()
1877
- state.picker = null
1878
- render()
1879
- }
1880
-
1881
- /** 按 entries 重建显示行并刷新 */
1882
- function renderPickerLines() {
1883
- const p = state.picker
1884
- if (!p) return
1885
- const lines = []
1886
- let row = 0
1887
- let selectedLine = 0
1888
- for (const e of p.entries) {
1889
- if (e.type === "header") {
1890
- lines.push({ text: ` ${e.text}${e.note ? ` ${e.note}` : ""}`, color: ansi.bold + C.tool })
1891
- } else {
1892
- const selected = row === p.index
1893
- if (selected) selectedLine = lines.length
1894
- const marker = e.marker ? ` ${e.marker}` : ""
1895
- lines.push({
1896
- text: `${selected ? " ▸ " : " "}${e.text}${marker}`,
1897
- color: selected ? ansi.bold + C.text : C.dim,
1898
- })
1899
- row++
1900
- }
1901
- }
1902
- p.lines = lines
1903
- p.selectedLine = selectedLine
1904
- render()
1905
- }
1906
-
1907
- // ========== 模型选择器 (基于通用 picker,异步拉取远端模型列表) ==========
1908
-
1909
- async function openModelPicker() {
1910
- const entries = []
1911
- for (const p of agent.providers) {
1912
- entries.push({ type: "header", text: p.name, note: `${p.baseURL}${p.apiKey ? "" : " (no key)"} loading...` })
1913
- entries.push({ type: "item", text: p.model, provider: p.name, model: p.model })
1914
- }
1915
- const onSelect = (e) => selectModel(e).catch((err) => pushLine(`[error] ${err.message}`, C.error))
1916
- openPicker({ title: "Select Model", entries, onSelect })
1917
- // 默认选中current在用的模型
1918
- const current = pickerItems().findIndex(
1919
- (e) => e.provider === agent.activeProvider && e.model === agent.provider.model,
1920
- )
1921
- if (current >= 0) state.picker.index = current
1922
- renderPickerLines()
1923
-
1924
- const { listModels } = await import("./provider.mjs")
1925
- await Promise.all(
1926
- agent.providers.map(async (p) => {
1927
- const header = entries.find((e) => e.type === "header" && e.provider === undefined && e.text === p.name)
1928
- const noteBase = `${p.baseURL}${p.apiKey ? "" : " (no key)"}`
1929
- try {
1930
- const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[p.name]
1931
- let apiKey = p.apiKey
1932
- if (!apiKey && envKey && process.env[envKey]) apiKey = process.env[envKey]
1933
- if (!apiKey) apiKey = process.env.THINCODER_API_KEY
1934
- const models = await listModels(
1935
- { baseURL: p.baseURL, apiKey: apiKey ?? "" },
1936
- { signal: AbortSignal.timeout(10000) },
1937
- )
1938
- const at = entries.findIndex((e) => e.type === "item" && e.provider === p.name && e.model === p.model)
1939
- entries.splice(
1940
- at + 1,
1941
- 0,
1942
- ...models.filter((m) => m !== p.model).map((m) => ({ type: "item", text: m, provider: p.name, model: m })),
1943
- )
1944
- if (header) header.note = noteBase
1945
- } catch (error) {
1946
- if (header) header.note = `${noteBase} (fetch failed: ${sliceByWidth(error.message, 60)})`
1947
- }
1948
- if (state.picker?.entries === entries) renderPickerLines()
1949
- }),
1950
- )
1951
- }
1952
-
1953
- /** 给指定 provider 写 key (内存 + Config文件);若它是current激活的,同步运行时 */
1954
- async function setProviderKey(name, key) {
1955
- const target = agent.providers.find((p) => p.name === name)
1956
- if (!target) {
1957
- pushLine(`Provider "${name}"`, C.error)
1958
- return
1959
- }
1960
- target.apiKey = key
1961
- if (name === agent.activeProvider) agent.provider.apiKey = key
1962
- await persistRaw((raw) => { raw.providers = agent.providers })
1963
- pushLabel(`❯ Provider`, ansi.bold + C.tool)
1964
- pushLine(`API key saved to ${name}`, C.tool)
1965
- }
1966
-
1967
- // ---------------------------------------------------------- 初始Config向导 (首次启动)
1968
-
1969
- /** 菜单步的候选项:已有 provider (no key 的标注)+ 未添加的预设 + 自定义 */
1970
- function wizardProviderItems() {
1971
- const items = []
1972
- for (const p of agent.providers) {
1973
- items.push({ kind: "existing", name: p.name, baseURL: p.baseURL, model: p.model, label: `${p.name} (added${p.apiKey ? "" : ",no key"})` })
1974
- }
1975
- for (const [name, p] of Object.entries(PRESETS)) {
1976
- if (!agent.providers.some((x) => x.name === name)) {
1977
- items.push({ kind: "preset", name, baseURL: p.baseURL, model: p.model, label: `${name} (${p.desc})` })
1978
- }
1979
- }
1980
- items.push({ kind: "custom", name: null, label: "Custom endpoint…" })
1981
- return items
1982
- }
1983
-
1984
- /** 文本步骤定义:提示语 + 校验 (通过返回 true,否则返回错误文案) */
1985
- const WIZARD_STEPS = {
1986
- name: {
1987
- prompt: "给这个 provider 起个名字 (字母/数字/-/_,如 my-openai)",
1988
- validate: (v) =>
1989
- (/^[\w-]+$/.test(v) && !agent.providers.some((p) => p.name === v)) || "Name must be alphanumeric/-/_ and unique",
1990
- },
1991
- baseURL: {
1992
- prompt: "输入 baseURL (如 https://api.openai.com/v1)",
1993
- validate: (v) => /^https?:\/\/.+/.test(v) || "baseURL must start with http(s)://",
1994
- },
1995
- model: {
1996
- prompt: "输入模型名 (如 gpt-4o)",
1997
- validate: (v) => v.length > 0 || "Model name required",
1998
- },
1999
- key: {
2000
- prompt: "输入 API key",
2001
- validate: (v) => v.length > 0 || "key 不能为空",
2002
- },
2003
- embedkey: {
2004
- prompt: "可选:embedding API key (SiliconFlow,记忆向量检索用;直接回车跳过)",
2005
- validate: () => true, // 可跳过
2006
- },
2007
- }
2008
- const WIZARD_NEXT = { name: "baseURL", baseURL: "model", model: "key", key: "embedkey", embedkey: null }
2009
-
2010
- function startWizard() {
2011
- state.wizard = { step: "provider", index: 0, scroll: 0, selectedLine: 0, fields: {}, error: null, lines: [] }
2012
- renderWizard()
2013
- }
2014
-
2015
- function renderWizard() {
2016
- const w = state.wizard
2017
- if (!w) return
2018
- const lines = []
2019
- if (w.step === "provider") {
2020
- lines.push({ text: " Choose a model provider:", color: C.text })
2021
- wizardProviderItems().forEach((it, i) => {
2022
- if (i === w.index) w.selectedLine = lines.length
2023
- lines.push({
2024
- text: `${i === w.index ? " ▸ " : " "}${it.label}`,
2025
- color: i === w.index ? ansi.bold + C.text : C.dim,
2026
- })
2027
- })
2028
- } else {
2029
- const f = w.fields
2030
- if (f.name) lines.push({ text: ` Provider: ${f.name}`, color: C.dim })
2031
- if (f.baseURL) lines.push({ text: ` baseURL: ${f.baseURL}`, color: C.dim })
2032
- if (f.model) lines.push({ text: ` 模型: ${f.model}`, color: C.dim })
2033
- lines.push({ text: ` ❯ ${WIZARD_STEPS[w.step].prompt}`, color: ansi.bold + C.text })
2034
- lines.push({ text: " (type in input box below)", color: C.dim })
2035
- w.selectedLine = 0
2036
- }
2037
- if (w.error) lines.push({ text: ` ${w.error}`, color: C.error })
2038
- w.lines = lines
2039
- render()
2040
- }
2041
-
2042
- function wizardChooseProvider(item) {
2043
- const w = state.wizard
2044
- if (item.kind === "custom") {
2045
- w.step = "name"
2046
- } else {
2047
- w.fields = { name: item.name, baseURL: item.baseURL, model: item.model }
2048
- w.step = "key"
2049
- }
2050
- renderWizard()
2051
- }
2052
-
2053
- function wizardSubmitText() {
2054
- const w = state.wizard
2055
- const value = state.input.join("").trim()
2056
- const ok = WIZARD_STEPS[w.step].validate(value)
2057
- if (ok !== true) {
2058
- w.error = ok
2059
- renderWizard()
2060
- return
2061
- }
2062
- w.error = null
2063
- state.input = []
2064
- state.cursor = 0
2065
- w.fields[w.step === "key" ? "key" : w.step] = w.step === "baseURL" ? value.replace(/\/+$/, "") : value
2066
- const next = WIZARD_NEXT[w.step]
2067
- if (next) {
2068
- w.step = next
2069
- renderWizard()
2070
- } else {
2071
- finishWizard().catch((e) => pushLine(`[error] ${e.message}`, C.error))
2072
- }
2073
- }
2074
-
2075
- function cancelWizard() {
2076
- state.wizard = null
2077
- pushLine("已跳过初始Config。之后随时可用 /provider add 添加Provider、/provider key 配 key。", C.dim)
2078
- render()
2079
- }
2080
-
2081
- /** 向导完成:写入 provider (有则更新)、设为激活、持久化,然后接模型选择器 */
2082
- async function finishWizard() {
2083
- const f = state.wizard.fields
2084
- state.wizard = null
2085
- const existing = agent.providers.find((p) => p.name === f.name)
2086
- if (existing) Object.assign(existing, { baseURL: f.baseURL, model: f.model, apiKey: f.key })
2087
- else agent.providers.push({ name: f.name, baseURL: f.baseURL, model: f.model, apiKey: f.key })
2088
- agent.activeProvider = f.name
2089
- agent.provider = { ...agent.providers.find((p) => p.name === f.name) }
2090
- if (agent.config?.agent?.compactThresholdAuto) {
2091
- const { resolveCompactThreshold } = await import("./config.mjs")
2092
- agent.config.agent.compactThreshold = resolveCompactThreshold(null, f.model).value
2093
- }
2094
- await persistRaw((raw) => {
2095
- raw.providers = agent.providers
2096
- raw.activeProvider = f.name
2097
- })
2098
- agent.config.activeProvider = f.name
2099
- pushLabel(`❯ Setup`, ansi.bold + C.tool)
2100
- pushLine(`Setup complete: ${f.name} / ${f.model} (saved to config)`, C.tool)
2101
- // embedding key:配了就启用向量检索,没配提示事后通道
2102
- if (f.embedkey) {
2103
- agent.config.embedding ??= {}
2104
- agent.config.embedding.apiKey = f.embedkey
2105
- await persistRaw((raw) => { raw.embedding = { ...(raw.embedding ?? {}), apiKey: f.embedkey } })
2106
- if (agent.memory && !agent.memory.embedder) {
2107
- const { createEmbedder } = await import("./embedding.mjs")
2108
- agent.memory.embedder = createEmbedder(agent.config.embedding)
2109
- }
2110
- pushLine(`Vector search enabled (${agent.config.embedding.model ?? "BAAI/bge-m3"})`, C.tool)
2111
- } else {
2112
- pushLine(`向量检索未启用 (记忆退化为纯文本检索);之后可 /config embedkey <key> On`, C.dim)
2113
- }
2114
- pushLine(`Select model (Esc to keep ${f.model})`, C.dim)
2115
- openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
2116
- }
2117
-
2118
- /** 选中:切换 provider + 模型,持久化,阈值随模型走 */
2119
- async function selectModel(item) {
2120
- closePicker()
2121
- const target = agent.providers.find((pp) => pp.name === item.provider)
2122
- if (!target) return
2123
- target.model = item.model
2124
- agent.activeProvider = item.provider
2125
- agent.provider = { ...target }
2126
- if (!agent.provider.apiKey) {
2127
- const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[item.provider]
2128
- if (envKey && process.env[envKey]) agent.provider.apiKey = process.env[envKey]
2129
- }
2130
- if (!agent.provider.apiKey) agent.provider.apiKey = process.env.THINCODER_API_KEY
2131
- let thresholdNote = ""
2132
- if (agent.config?.agent?.compactThresholdAuto) {
2133
- const { resolveCompactThreshold } = await import("./config.mjs")
2134
- const { value } = resolveCompactThreshold(null, item.model)
2135
- agent.config.agent.compactThreshold = value
2136
- thresholdNote = `, compact threshold adjusted to ${value}`
2137
- }
2138
- await persistRaw((raw) => {
2139
- raw.providers = agent.providers
2140
- raw.activeProvider = item.provider
2141
- })
2142
- agent.config.activeProvider = item.provider
2143
- pushLabel(`❯ Model`, ansi.bold + C.tool)
2144
- pushLine(`Switched to ${item.provider} / ${item.model}${thresholdNote} (persisted)`, C.tool)
2145
- if (!agent.provider.apiKey) pushLine(`Provider has no key: /provider → Set Key`, C.warn)
2146
- }
2147
-
2148
- /** /distill:从current会话提取候选,逐条 y/n 确认后入库 */
2149
- async function runDistill() {
2150
- if (agent.history.length === 0) {
2151
- pushLine("[distill] current会话为空,没有可提取的内容", C.dim)
2152
- return
2153
- }
2154
- state.processing = true
2155
- state.status = "Distilling..."
2156
- render()
2157
- try {
2158
- const { extractCandidates, historyToTranscript, saveCandidate } = await import("./distill.mjs")
2159
- pushLine("[distill] Analyzing session...", C.tool)
2160
- const candidates = await extractCandidates(agent.provider, historyToTranscript(agent.history))
2161
- if (candidates.length === 0) {
2162
- pushLine("[distill] No knowledge worth saving from this session", C.dim)
2163
- return
2164
- }
2165
- let saved = 0
2166
- for (const c of candidates) {
2167
- pushLine(`── Candidate [${c.type}] ${c.title} (scope: ${c.scope ?? "personal"})`, C.warn)
2168
- for (const line of c.content.split("\n").slice(0, 6)) pushLine(` ${line}`, C.dim)
2169
- if (c.type === "rule") pushLine(" (rule type — consider writing manually; press y to extract)", C.warn)
2170
- const accept = await askPermission("distill-save", { title: c.title })
2171
- if (!accept) {
2172
- pushLine(" skipped", C.dim)
2173
- continue
2174
- }
2175
- const where = await saveCandidate(agent.memory, c, distillOpts)
2176
- pushLine(` saved -> ${where}`, C.tool)
2177
- saved++
2178
- }
2179
- pushLine(`[distill] Done: saved ${saved}/${candidates.length} 条`, C.tool)
2180
- } catch (error) {
2181
- pushLine(`[distill] error: ${error.message}`, C.error)
2182
- } finally {
2183
- state.processing = false
2184
- state.status = "Ready"
2185
- render()
2186
- }
2187
- }
2188
-
2189
- // ---------------------------------------------------------- 键盘 / 鼠标
2190
-
2191
- // keypress 挂在过滤后的 keyStream 上:鼠标序列已在上游滤网中处理并剥除
2192
- keyStream.on("keypress", (str, key = {}) => {
2193
- // 权限确认态:y 批准 / n 拒绝 / a 批准并On AUTO (后续不再询问)
2194
- if (state.permission) {
2195
- const answer = (str || "").toLowerCase()
2196
- const isContinue = state.permission.name === "continue"
2197
- const validKeys = isContinue ? ["y", "n"] : ["y", "n", "a"]
2198
- if (validKeys.includes(answer) || key.name === "escape") {
2199
- const { resolve, name } = state.permission
2200
- state.permission = null
2201
- state.permissionPreview = []
2202
- state.status = "Processing..."
2203
- if (answer === "a" && !isContinue) {
2204
- agent.autoApprove = true
2205
- agent._pendingReminders = agent._pendingReminders ?? []
2206
- agent._pendingReminders.push("[System reminder: AUTO mode is now ON. All tool calls are automatically approved. Use /auto to disable.]")
2207
- pushLine(` [auto] AUTO 已On:后续工具调用不再询问 (/auto Off)`, C.warn)
2208
- }
2209
- const approved = answer === "y" || (answer === "a" && !isContinue)
2210
- // 决定落痕:对话区留下批准/拒绝记录 (continue 询问有自己的输出,不重复记)
2211
- if (!isContinue) {
2212
- pushLine(` [${approved ? "approved" : "denied"}] ${name}`, approved ? C.dim : C.error)
2213
- }
2214
- resolve(approved)
2215
- render()
2216
- }
2217
- return
2218
- }
2219
-
2220
- // question 工具回调:自由文本 / 选项选择
2221
- if (state.question) {
2222
- const q = state.question
2223
- if (q.options.length > 0) {
2224
- // 选项模式:↑↓ 选择,Enter 确认,Esc 取消
2225
- if (key.name === "escape") {
2226
- q.resolve("(cancelled)")
2227
- state.question = null
2228
- state.status = "Processing..."
2229
- render()
2230
- } else if (key.name === "up") {
2231
- q.selected = Math.max(0, (q.selected ?? 0) - 1)
2232
- render()
2233
- } else if (key.name === "down") {
2234
- q.selected = Math.min(q.options.length - 1, (q.selected ?? 0) + 1)
2235
- render()
2236
- } else if (key.name === "return") {
2237
- const answer = q.options[q.selected ?? 0]
2238
- q.resolve(answer)
2239
- state.question = null
2240
- state.status = "Processing..."
2241
- pushLine(` → ${answer}`, C.tool)
2242
- render()
2243
- }
2244
- } else {
2245
- // 自由文本:键入答案,Enter 提交,Esc 取消
2246
- if (key.name === "escape") {
2247
- q.resolve("(cancelled)")
2248
- state.question = null
2249
- state.status = "Processing..."
2250
- render()
2251
- } else if (key.name === "return") {
2252
- const answer = (q.answer ?? "").trim()
2253
- q.resolve(answer || "(empty answer)")
2254
- state.question = null
2255
- state.status = "Processing..."
2256
- pushLine(` → ${answer || "(empty)"}`, C.tool)
2257
- render()
2258
- } else if (key.name === "backspace") {
2259
- q.answer = (q.answer ?? "").slice(0, -1)
2260
- render()
2261
- } else if (str && !key.ctrl && !key.meta) {
2262
- q.answer = (q.answer ?? "") + str
2263
- render()
2264
- }
2265
- }
2266
- return
2267
- }
2268
-
2269
- if (key.ctrl && key.name === "c") {
2270
- if (state.processing && state.controller) {
2271
- state.controller.abort()
2272
- pushLine("[Aborting…]", C.warn)
2273
- render()
2274
- return
2275
- }
2276
- cleanup()
2277
- setTimeout(() => process.exit(0), 100)
2278
- }
2279
-
2280
- // 通用列表选择器:↑↓ 移动,Enter 确认,Esc 取消
2281
- if (state.picker) {
2282
- const items = pickerItems()
2283
- if (key.name === "escape") {
2284
- closePicker()
2285
- } else if (key.name === "up" && items.length) {
2286
- state.picker.index = (state.picker.index - 1 + items.length) % items.length
2287
- renderPickerLines()
2288
- } else if (key.name === "down" && items.length) {
2289
- state.picker.index = (state.picker.index + 1) % items.length
2290
- renderPickerLines()
2291
- } else if (key.name === "return" && items.length) {
2292
- const selected = items[state.picker.index]
2293
- state.picker.onSelect?.(selected)
2294
- closePicker()
2295
- }
2296
- return
2297
- }
2298
-
2299
- // 初始Config向导:菜单步 ↑↓/Enter/Esc;文本步 Enter 提交、Esc 取消,编辑键落到正常输入
2300
- if (state.wizard) {
2301
- const w = state.wizard
2302
- if (key.name === "escape") {
2303
- cancelWizard()
2304
- return
2305
- }
2306
- if (w.step === "provider") {
2307
- const items = wizardProviderItems()
2308
- if (key.name === "up" && items.length) {
2309
- w.index = (w.index - 1 + items.length) % items.length
2310
- renderWizard()
2311
- } else if (key.name === "down" && items.length) {
2312
- w.index = (w.index + 1) % items.length
2313
- renderWizard()
2314
- } else if (key.name === "return" && items.length) {
2315
- wizardChooseProvider(items[w.index])
2316
- }
2317
- return
2318
- }
2319
- if (key.name === "return") {
2320
- wizardSubmitText()
2321
- return
2322
- }
2323
- // 文本步骤屏蔽翻页/历史,其余编辑键放行到下面的普通输入逻辑
2324
- if (key.name === "up" || key.name === "down" || key.name === "pageup" || key.name === "pagedown") return
2325
- }
2326
-
2327
- // 翻页
2328
- if (key.name === "pageup") {
2329
- state.scroll += Math.max(1, (process.stdout.rows || 24) - 8)
2330
- render()
2331
- return
2332
- }
2333
- if (key.name === "pagedown") {
2334
- state.scroll = Math.max(0, state.scroll - Math.max(1, (process.stdout.rows || 24) - 8))
2335
- render()
2336
- return
2337
- }
2338
-
2339
- if (state.processing) {
2340
- // 处理中允许输入(排队),但屏蔽方向键历史和 Tab 补全
2341
- if (key.name === "tab" || key.name === "up" || key.name === "down") return
2342
- // Ctrl+D:删除队列中最后一条
2343
- if (key.ctrl && key.name === "d") {
2344
- if (state.queue.length > 0) {
2345
- state.queue.pop()
2346
- render()
2347
- }
2348
- return
2349
- }
2350
- // 其余可打印字符正常进入输入框
2351
- }
2352
-
2353
- // Tab:斜杠Commands补全 (循环候选);其余输入忽略 (\t 会顶破输入框,永不直接插入)
2354
- if (key.name === "tab") {
2355
- handleTab()
2356
- return
2357
- }
2358
-
2359
- // 输入历史
2360
- if (key.name === "up") {
2361
- if (state.history.length) {
2362
- state.historyIndex = state.historyIndex === -1 ? state.history.length - 1 : Math.max(0, state.historyIndex - 1)
2363
- state.input = [...state.history[state.historyIndex]]
2364
- state.cursor = state.input.length
2365
- render()
2366
- }
2367
- return
2368
- }
2369
- if (key.name === "down") {
2370
- if (state.historyIndex !== -1) {
2371
- state.historyIndex++
2372
- if (state.historyIndex >= state.history.length) {
2373
- state.historyIndex = -1
2374
- state.input = []
2375
- } else {
2376
- state.input = [...state.history[state.historyIndex]]
2377
- }
2378
- state.cursor = state.input.length
2379
- render()
2380
- }
2381
- return
2382
- }
2383
-
2384
- // 光标移动
2385
- if (key.name === "left") {
2386
- state.cursor = Math.max(0, state.cursor - 1)
2387
- render()
2388
- return
2389
- }
2390
- if (key.name === "right") {
2391
- state.cursor = Math.min(state.input.length, state.cursor + 1)
2392
- render()
2393
- return
2394
- }
2395
- if (key.name === "home") {
2396
- state.cursor = 0
2397
- render()
2398
- return
2399
- }
2400
- if (key.name === "end") {
2401
- state.cursor = state.input.length
2402
- render()
2403
- return
2404
- }
2405
-
2406
- // 编辑
2407
- if (key.name === "backspace") {
2408
- if (state.cursor > 0) {
2409
- state.input.splice(state.cursor - 1, 1)
2410
- state.cursor--
2411
- render()
2412
- }
2413
- return
2414
- }
2415
- if (key.name === "delete") {
2416
- if (state.cursor < state.input.length) {
2417
- state.input.splice(state.cursor, 1)
2418
- render()
2419
- }
2420
- return
2421
- }
2422
- if (key.name === "return") {
2423
- submit().catch((e) => pushLine(`[error] ${e.message}`, C.error))
2424
- return
2425
- }
2426
-
2427
- // Ctrl+V (Unix) / Alt+V (Windows):粘贴剪贴板图片 → 存临时文件 → 输入框插入 read_image
2428
- const isPasteImage = (key.name === "v" && (key.ctrl || key.meta)) || (key.name === "v" && key.alt)
2429
- if (isPasteImage) {
2430
- pasteClipboardImage(agent).catch((e) => pushLine(`[error] ${e.message}`, C.error))
2431
- return
2432
- }
2433
-
2434
- // 可打印字符 / 粘贴 (str 可能一次多个字符);Tab 一律转成两个空格 (\t 显示宽度不定,会顶破输入框)
2435
- // \r\n 在 Windows raw mode 下可能漏进来冲乱页面
2436
- if (str && !key.ctrl && !key.meta) {
2437
- const chars = [...str.replace(/[\r\n]+/g, "").replace(/\t/g, " ")]
2438
- state.input.splice(state.cursor, 0, ...chars)
2439
- state.cursor += chars.length
2440
- render()
2441
- }
2442
- })
2443
-
2444
- // 启动画面
2445
- if (!agent.provider.apiKey) {
2446
- pushLabel(`Welcome to ThinCoder!`, ansi.bold + C.tool)
2447
- pushLine("检测到还没Config API key,进入初始Config (Esc 可随时跳过)", C.text)
2448
- startWizard()
2449
- } else {
2450
- pushLine(`Welcome to ThinCoder. Provider: ${agent.activeProvider} / ${agent.provider.model}`, C.dim)
2451
- }
2452
- pushLine(`Tools: ${agent.tools.map((t) => t.name).join(", ")}`, C.dim)
2453
- // 恢复上次会话:重建对话区显示 (tool 结果行省略,保持清爽)
2454
- if (opts.restored?.display?.length) {
2455
- // 用户视角的恢复:display 是退出前对话区的原样快照,所见即所得
2456
- state.lines = [...opts.restored.display.map((l) => ({ text: l.text, color: l.color })), ...state.lines]
2457
- pushLabel(`── Restored previous session; /new for a fresh session ──`, C.warn)
2458
- } else if (opts.restored?.history?.length) {
2459
- // 重建对话区:user/assistant 消息逐条展示,tool 结果行只保留首行摘要
2460
- for (let i = 0; i < opts.restored.history.length; i++) {
2461
- const m = opts.restored.history[i]
2462
- if (m.role === "user") {
2463
- if (typeof m.content === "string" && m.content.startsWith("[System reminder:")) continue
2464
- pushLabel(`❯ You:`, ansi.bold + C.user)
2465
- if (typeof m.content === "string" && m.content) pushLine(m.content, C.text)
2466
- } else if (m.role === "assistant") {
2467
- pushLabel(`❯ ThinCoder:`, ansi.bold + C.assistant)
2468
- if (typeof m.content === "string" && m.content) pushLine(m.content, C.text)
2469
- for (const tc of m.tool_calls ?? []) {
2470
- // 找到下一条对应的 tool 结果,显示首行摘要
2471
- const toolResult = opts.restored.history[i + 1]
2472
- const hasResult = toolResult?.role === "tool" && toolResult?.tool_call_id === tc.id
2473
- const summary = hasResult ? " → " + sliceByWidth(String(toolResult.content).split("\n")[0], 80) : ""
2474
- pushLine(` [tool] ${tc.function?.name ?? "?"}${summary}`, C.tool)
2475
- }
2476
- }
2477
- // tool 消息本身不单独渲染——已在 assistant 的 tool_calls 后以摘要形式展示
2478
- }
2479
- pushLabel(`── Restored previous session (${opts.restored.history.length} messages); /new for a fresh session ──`, C.warn)
2480
- }
2481
- // 有归档槽位时给个提示
2482
- if (listSlots(agent.cwd).length > 0) {
2483
- pushLine("Tip: archived sessions available — /session to view/switch", C.dim)
2484
- }
2485
- render()
2486
-
2487
- // 后台索引 (进界面后再跑,不阻塞启动);进度走底部状态栏,不往对话区塞行
2488
- // 优先用 git diff 增量(快),git 不可用或首次运行时退到全量扫描
2489
- ;(async () => {
2490
- const { codeSync, docSync, gitSync } = await import("./memory.mjs")
2491
- const cwd = agent.cwd
2492
- let codeFiles = 0, docFiles = 0
2493
-
2494
- state.status = "Indexing..."
2495
- render()
2496
-
2497
- const gitRes = await gitSync(agent.memory, cwd, {
2498
- onProgress: (p) => {
2499
- if (p.phase === "index" && p.current % 5 === 0) {
2500
- state.status = `Indexing... ${p.current}/${p.total}`
2501
- render()
2502
- }
2503
- }
2504
- })
2505
-
2506
- if (gitRes !== null) {
2507
- // git 增量成功,直接统计
2508
- codeFiles = agent.memory.db.prepare(`SELECT COUNT(DISTINCT path) AS n FROM code_chunks`).get()?.n ?? 0
2509
- docFiles = agent.memory.db.prepare(`SELECT COUNT(DISTINCT path) AS n FROM doc_chunks`).get()?.n ?? 0
2510
- } else {
2511
- // 退到全量扫描(codeSync 和 docSync 并行——读写不同表,SQLite WAL 天然支持)
2512
- const [codeRes, docRes] = await Promise.allSettled([
2513
- codeSync(agent.memory, cwd, {
2514
- onProgress: (p) => {
2515
- if (p.phase === "index" && p.current % 30 === 0) {
2516
- state.status = `Indexing code... ${p.current}/${p.total}`
2517
- render()
2518
- }
2519
- }
2520
- }),
2521
- docSync(agent.memory, cwd, {
2522
- onProgress: (p) => {
2523
- if (p.phase === "index" && p.current % 10 === 0) {
2524
- state.status = `Indexing docs... ${p.current}/${p.total}`
2525
- render()
2526
- }
2527
- }
2528
- }),
2529
- ])
2530
- if (codeRes.status === "fulfilled") {
2531
- codeFiles = agent.memory.db.prepare(`SELECT COUNT(DISTINCT path) AS n FROM code_chunks`).get()?.n ?? 0
2532
- }
2533
- if (docRes.status === "fulfilled") {
2534
- docFiles = agent.memory.db.prepare(`SELECT COUNT(DISTINCT path) AS n FROM doc_chunks`).get()?.n ?? 0
2535
- }
2536
- }
2537
-
2538
- state.status = codeFiles || docFiles
2539
- ? `Ready — idx code ${codeFiles} doc ${docFiles}`
2540
- : "Ready"
2541
- render()
2542
- })()
2543
- }
2544
-
2545
- function summarize(obj) {
2546
- const s = JSON.stringify(obj)
2547
- return s.length > 80 ? s.slice(0, 80) + "…" : s
2548
- }
5
+ export { startTUI } from "./tui/index.mjs"