thincoder 0.7.8 → 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.
- package/README.md +32 -13
- package/bin/thincoder.mjs +27 -346
- package/package.json +1 -1
- package/src/agent/dispatch.mjs +98 -0
- package/src/agent/helpers.mjs +185 -0
- package/src/agent/setup.mjs +117 -0
- package/src/agent-tools/goal.mjs +71 -0
- package/src/agent-tools/plan.mjs +31 -0
- package/src/agent-tools/recent-changes.mjs +23 -0
- package/src/agent-tools/skill.mjs +46 -0
- package/src/agent-tools/subagent.mjs +113 -0
- package/src/agent-tools/task.mjs +67 -0
- package/src/agent-tools/verify.mjs +198 -0
- package/src/agent-tools.mjs +12 -0
- package/src/agent.mjs +90 -1040
- package/src/cli/distill-command.mjs +85 -0
- package/src/cli/make-agent.mjs +85 -0
- package/src/cli/memory-command.mjs +63 -0
- package/src/cli/permission.mjs +41 -0
- package/src/cli/setup-wizard.mjs +70 -0
- package/src/config.mjs +5 -8
- package/src/context.mjs +10 -13
- package/src/distill.mjs +4 -3
- package/src/embedding.mjs +4 -2
- package/src/{checkpoint.mjs → git/checkpoint.mjs} +1 -1
- package/src/mcp/helpers.mjs +37 -0
- package/src/mcp/transport-http.mjs +176 -0
- package/src/mcp/transport-stdio.mjs +84 -0
- package/src/mcp/transport-ws.mjs +87 -0
- package/src/mcp.mjs +4 -428
- package/src/memory/code-index.mjs +211 -0
- package/src/memory/code-sync.mjs +306 -0
- package/src/memory/core.mjs +277 -0
- package/src/memory/docs.mjs +262 -0
- package/src/memory/schema.mjs +426 -0
- package/src/memory.mjs +12 -1403
- package/src/provider/core.mjs +239 -0
- package/src/provider/index.mjs +6 -0
- package/src/provider/rate.mjs +104 -0
- package/src/session.mjs +18 -5
- package/src/tools/bash.mjs +144 -0
- package/src/tools/file.mjs +205 -0
- package/src/tools/git.mjs +166 -0
- package/src/tools/glob.mjs +51 -0
- package/src/tools/grep.mjs +100 -0
- package/src/tools/index.mjs +22 -0
- package/src/tools/ls.mjs +36 -0
- package/src/tools/patch.mjs +226 -0
- package/src/tools/repomap-parse.mjs +168 -0
- package/src/tools/shared.mjs +257 -0
- package/src/tools/system.mjs +336 -0
- package/src/tools/web.mjs +121 -0
- package/src/tools.mjs +2 -1194
- package/src/tui/agent-turn.mjs +254 -0
- package/src/tui/ansi.mjs +32 -0
- package/src/tui/clipboard.mjs +48 -0
- package/src/tui/cmd-auto.mjs +21 -0
- package/src/tui/cmd-clear.mjs +26 -0
- package/src/tui/cmd-config.mjs +72 -0
- package/src/tui/cmd-exit.mjs +5 -0
- package/src/tui/cmd-extract.mjs +5 -0
- package/src/tui/cmd-goal.mjs +47 -0
- package/src/tui/cmd-help.mjs +25 -0
- package/src/tui/cmd-init.mjs +91 -0
- package/src/tui/cmd-mcp.mjs +146 -0
- package/src/tui/cmd-model.mjs +7 -0
- package/src/tui/cmd-new.mjs +18 -0
- package/src/tui/cmd-plan.mjs +21 -0
- package/src/tui/cmd-reindex.mjs +44 -0
- package/src/tui/cmd-restore.mjs +39 -0
- package/src/tui/cmd-session.mjs +42 -0
- package/src/tui/cmd-skills.mjs +17 -0
- package/src/tui/cmd-think.mjs +56 -0
- package/src/tui/config-helpers.mjs +34 -0
- package/src/tui/distill-cmd.mjs +45 -0
- package/src/tui/index.mjs +330 -0
- package/src/tui/interaction.mjs +79 -0
- package/src/tui/key-handler.mjs +267 -0
- package/src/tui/layout.mjs +115 -0
- package/src/tui/pickers.mjs +279 -0
- package/src/tui/render-frame.mjs +304 -0
- package/src/tui/render.mjs +205 -0
- package/src/tui/slash-commands.mjs +138 -0
- package/src/tui/startup.mjs +113 -0
- package/src/tui/wizard.mjs +168 -0
- package/src/tui-render.mjs +4 -0
- package/src/tui.mjs +3 -2566
- package/src/provider.mjs +0 -383
- /package/src/{gitmem.mjs → git/gitmem.mjs} +0 -0
- /package/src/{coder-overlay.md → prompts/coder.md} +0 -0
- /package/src/{discipline-rules.md → prompts/discipline.md} +0 -0
- /package/src/{explore-overlay.md → prompts/explore.md} +0 -0
- /package/src/{main-overlay.md → prompts/main.md} +0 -0
- /package/src/{plan-overlay.md → prompts/plan.md} +0 -0
- /package/src/{SYSTEM_PROMPT.md → prompts/system.md} +0 -0
- /package/src/{repomap.mjs → tools/repomap.mjs} +0 -0
package/src/tui.mjs
CHANGED
|
@@ -1,2568 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* tui.mjs —
|
|
3
|
-
*
|
|
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
|
-
// 直接接 key 输入,不让用户再绕一圈
|
|
1665
|
-
askQuestion(`Enter API key for ${name} (留空跳过,之后 /provider → Set Key):`).then(async (key) => {
|
|
1666
|
-
if (key) {
|
|
1667
|
-
await setProviderKey(name, key)
|
|
1668
|
-
pushLine(`Key saved for ${name}`, C.tool)
|
|
1669
|
-
} else {
|
|
1670
|
-
pushLine(`跳过 key。之后 /provider → Set API Key 配置`, C.dim)
|
|
1671
|
-
}
|
|
1672
|
-
})
|
|
1673
|
-
})
|
|
1674
|
-
return
|
|
1675
|
-
}
|
|
1676
|
-
if (e.action === "key") {
|
|
1677
|
-
// Key: pick which provider, then prompt for key
|
|
1678
|
-
const keyEntries = [
|
|
1679
|
-
{ type: "header", text: "Select provider to configure key" },
|
|
1680
|
-
...agent.providers.map((p) => ({
|
|
1681
|
-
type: "item",
|
|
1682
|
-
text: `${p.name}${p.name === agent.activeProvider ? " ← current" : ""}${p.apiKey ? " ●has key" : " ○nonekey"}`,
|
|
1683
|
-
name: p.name,
|
|
1684
|
-
})),
|
|
1685
|
-
]
|
|
1686
|
-
openPicker({
|
|
1687
|
-
title: "Configure API Key",
|
|
1688
|
-
entries: keyEntries,
|
|
1689
|
-
onSelect: (se) => {
|
|
1690
|
-
askQuestion(`Enter API key for ${se.name}:`).then(async (key) => {
|
|
1691
|
-
if (!key) {
|
|
1692
|
-
pushLine(`跳过 key 输入`, C.dim)
|
|
1693
|
-
return
|
|
1694
|
-
}
|
|
1695
|
-
await setProviderKey(se.name, key)
|
|
1696
|
-
})
|
|
1697
|
-
},
|
|
1698
|
-
})
|
|
1699
|
-
}
|
|
1700
|
-
},
|
|
1701
|
-
})
|
|
1702
|
-
return
|
|
1703
|
-
}
|
|
1704
|
-
case "/config": {
|
|
1705
|
-
const entries = [
|
|
1706
|
-
{ type: "header", text: "Config" },
|
|
1707
|
-
{ type: "item", text: "View current config", action: "view" },
|
|
1708
|
-
{ type: "item", text: "Set embedding key (vector search)", action: "embedkey" },
|
|
1709
|
-
{ type: "item", text: "Advanced (set path value)", action: "set" },
|
|
1710
|
-
]
|
|
1711
|
-
openPicker({
|
|
1712
|
-
title: "Config",
|
|
1713
|
-
entries,
|
|
1714
|
-
onSelect: async (e) => {
|
|
1715
|
-
if (e.action === "view") {
|
|
1716
|
-
const { configPath: cp } = await import("./config.mjs")
|
|
1717
|
-
pushLabel(`❯ Config`, ansi.bold + C.tool)
|
|
1718
|
-
pushLine(`Active: ${agent.activeProvider} / ${agent.provider.model}`, C.dim)
|
|
1719
|
-
pushLine(`Key: ${maskKey(agent.provider.apiKey)}`, C.dim)
|
|
1720
|
-
const ac = agent.config?.agent ?? {}
|
|
1721
|
-
const tn = `${ac.compactThreshold ?? 100000}${ac.compactThresholdAuto ? " (auto)" : ""}`
|
|
1722
|
-
pushLine(`agent: maxTurns=${ac.maxTurns ?? 100} | compactThreshold=${tn}`, C.dim)
|
|
1723
|
-
pushLine(`embedding: ${agent.memory?.embedder ? `enabled (${agent.config?.embedding?.model ?? ""})` : "disabled (FTS only)"}`, C.dim)
|
|
1724
|
-
pushLine(`Config文件: ${cp}`, C.dim)
|
|
1725
|
-
return
|
|
1726
|
-
}
|
|
1727
|
-
if (e.action === "embedkey") {
|
|
1728
|
-
askQuestion("Enter embedding API key (default: SiliconFlow bge-m3):").then(async (key) => {
|
|
1729
|
-
if (!key) return
|
|
1730
|
-
agent.config.embedding ??= {}
|
|
1731
|
-
agent.config.embedding.apiKey = key
|
|
1732
|
-
await persistRaw((raw) => { raw.embedding = { ...(raw.embedding ?? {}), apiKey: key } })
|
|
1733
|
-
if (agent.memory) {
|
|
1734
|
-
const { createEmbedder } = await import("./embedding.mjs")
|
|
1735
|
-
agent.memory.embedder = createEmbedder(agent.config.embedding)
|
|
1736
|
-
}
|
|
1737
|
-
pushLabel(`❯ Config`, ansi.bold + C.tool)
|
|
1738
|
-
pushLine(`Embedding key saved, vector search enabled`, C.tool)
|
|
1739
|
-
})
|
|
1740
|
-
return
|
|
1741
|
-
}
|
|
1742
|
-
if (e.action === "set") {
|
|
1743
|
-
askQuestion("Enter: <path> <value> (e.g. agent.maxTurns 80, supports a.b nesting):").then(async (text) => {
|
|
1744
|
-
if (!text) return
|
|
1745
|
-
const parts = text.split(/\s+/)
|
|
1746
|
-
const [path, value] = [parts[0], parts.slice(1).join(" ")]
|
|
1747
|
-
if (!path || !value) { pushLine("Usage: <path> <value> e.g. agent.maxTurns 80", C.error); return }
|
|
1748
|
-
try {
|
|
1749
|
-
const { configPath, loadConfig, saveConfig } = await import("./config.mjs")
|
|
1750
|
-
const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
|
|
1751
|
-
const keys = path.split(".")
|
|
1752
|
-
let obj = raw
|
|
1753
|
-
for (let i = 0; i < keys.length - 1; i++) { obj[keys[i]] ??= {}; obj = obj[keys[i]] }
|
|
1754
|
-
obj[keys[keys.length - 1]] = isNaN(value) ? value : Number(value)
|
|
1755
|
-
saveConfig(raw)
|
|
1756
|
-
const cfg = loadConfig()
|
|
1757
|
-
agent.provider = cfg.provider
|
|
1758
|
-
agent.providers = cfg.providersList
|
|
1759
|
-
agent.activeProvider = cfg.activeProvider
|
|
1760
|
-
agent.config = cfg
|
|
1761
|
-
pushLabel(`❯ Config`, ansi.bold + C.tool)
|
|
1762
|
-
pushLine(`Saved: ${path} = ${value}`, C.tool)
|
|
1763
|
-
} catch (error) {
|
|
1764
|
-
pushLine(`Save failed: ${error.message}`, C.error)
|
|
1765
|
-
}
|
|
1766
|
-
})
|
|
1767
|
-
}
|
|
1768
|
-
},
|
|
1769
|
-
})
|
|
1770
|
-
return
|
|
1771
|
-
}
|
|
1772
|
-
case "/help": {
|
|
1773
|
-
const order = ["Agent", "Session", "Tools", "Config"]
|
|
1774
|
-
const byGroup = new Map()
|
|
1775
|
-
for (const c of SLASH_COMMANDS) {
|
|
1776
|
-
if (!c.group) continue
|
|
1777
|
-
if (!byGroup.has(c.group)) byGroup.set(c.group, [])
|
|
1778
|
-
byGroup.get(c.group).push(c)
|
|
1779
|
-
}
|
|
1780
|
-
const maxW = Math.max(...SLASH_COMMANDS.map((c) => c.name.length))
|
|
1781
|
-
for (const g of order) {
|
|
1782
|
-
const cmds = byGroup.get(g)
|
|
1783
|
-
if (!cmds?.length) continue
|
|
1784
|
-
byGroup.delete(g)
|
|
1785
|
-
pushLabel(`❯ ${g}`, ansi.bold + C.tool)
|
|
1786
|
-
for (const c of cmds) pushLine(` ${c.name.padEnd(maxW + 1)} ${c.desc}`, C.dim)
|
|
1787
|
-
}
|
|
1788
|
-
for (const [g, cmds] of byGroup) {
|
|
1789
|
-
pushLabel(`❯ ${g}`, ansi.bold + C.tool)
|
|
1790
|
-
for (const c of cmds) pushLine(` ${c.name.padEnd(maxW + 1)} ${c.desc}`, C.dim)
|
|
1791
|
-
}
|
|
1792
|
-
return
|
|
1793
|
-
}
|
|
1794
|
-
default:
|
|
1795
|
-
pushLine(`Unknown command: ${cmd} (/help 查看可用Commands)`, C.error)
|
|
1796
|
-
return
|
|
1797
|
-
}
|
|
1798
|
-
}
|
|
1799
|
-
|
|
1800
|
-
function maskKey(key) {
|
|
1801
|
-
if (!key) return "(none)"
|
|
1802
|
-
if (key.length <= 8) return "***"
|
|
1803
|
-
return `${key.slice(0, 5)}…${key.slice(-4)}`
|
|
1804
|
-
}
|
|
1805
|
-
|
|
1806
|
-
/** Tab 补全候选:Commands名 / 子Commands / provider 名 / 预设名 / think 参数 */
|
|
1807
|
-
function completions(input) {
|
|
1808
|
-
if (!input.startsWith("/")) return []
|
|
1809
|
-
const parts = input.split(/\s+/)
|
|
1810
|
-
// 还在敲第一个 token:补Commands名
|
|
1811
|
-
if (parts.length === 1) {
|
|
1812
|
-
return SLASH_COMMANDS.filter((c) => c.name.startsWith(parts[0])).map((c) => c.name)
|
|
1813
|
-
}
|
|
1814
|
-
const cmd = parts[0]
|
|
1815
|
-
const last = parts.at(-1) // 结尾是空格时Enter API key for "",即列出全部候选
|
|
1816
|
-
const head = parts.slice(0, -1).join(" ")
|
|
1817
|
-
const argIndex = parts.length - 2 // 正在敲第几个参数 (0 基)
|
|
1818
|
-
const match = (cands) => cands.filter((c) => c.startsWith(last)).map((c) => `${head} ${c}`)
|
|
1819
|
-
if (cmd === "/model" && argIndex === 0) return match(agent.providers.map((p) => p.name))
|
|
1820
|
-
if (cmd === "/provider") {
|
|
1821
|
-
if (argIndex === 0) return match(["add", "remove", "key"])
|
|
1822
|
-
if (argIndex === 1 && parts[1] === "add") return match(Object.keys(PRESETS))
|
|
1823
|
-
if (argIndex === 1 && (parts[1] === "remove" || parts[1] === "key")) return match(agent.providers.map((p) => p.name))
|
|
1824
|
-
}
|
|
1825
|
-
if (cmd === "/think") {
|
|
1826
|
-
if (argIndex === 0) return match(["on", "off", "effort"])
|
|
1827
|
-
if (argIndex === 1 && parts[1] === "effort") return match(["low", "high", "max"])
|
|
1828
|
-
}
|
|
1829
|
-
if (cmd === "/config" && argIndex === 0) return match(["embedkey", "set"])
|
|
1830
|
-
if (cmd === "/goal" && argIndex === 0) return match(["set", "cancel"])
|
|
1831
|
-
if (cmd === "/mcp") {
|
|
1832
|
-
if (argIndex === 0) return match(["add", "url", "ws", "remove", "connect", "list"])
|
|
1833
|
-
if (argIndex === 1 && (parts[1] === "remove" || parts[1] === "connect")) return match((agent.config?.mcp?.servers ?? []).map((s) => s.name))
|
|
1834
|
-
}
|
|
1835
|
-
return []
|
|
1836
|
-
}
|
|
1837
|
-
|
|
1838
|
-
/** Tab:计算候选并循环替换输入 */
|
|
1839
|
-
function handleTab() {
|
|
1840
|
-
const input = state.input.join("")
|
|
1841
|
-
if (state.completion && input === state.completion.candidates[state.completion.index]) {
|
|
1842
|
-
// 上一次的候选还在输入框:循环到下一个
|
|
1843
|
-
state.completion.index = (state.completion.index + 1) % state.completion.candidates.length
|
|
1844
|
-
} else {
|
|
1845
|
-
const candidates = completions(input)
|
|
1846
|
-
if (candidates.length === 0) return
|
|
1847
|
-
state.completion = { candidates, index: 0 }
|
|
1848
|
-
}
|
|
1849
|
-
const text = state.completion.candidates[state.completion.index]
|
|
1850
|
-
state.input = [...text]
|
|
1851
|
-
state.cursor = state.input.length
|
|
1852
|
-
render()
|
|
1853
|
-
}
|
|
1854
|
-
|
|
1855
|
-
/** 读Config文件 → 修改 → 写回;文件not found时从空对象开始 */
|
|
1856
|
-
async function persistRaw(mutate) {
|
|
1857
|
-
const { saveConfig, configPath } = await import("./config.mjs")
|
|
1858
|
-
const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
|
|
1859
|
-
mutate(raw)
|
|
1860
|
-
saveConfig(raw)
|
|
1861
|
-
}
|
|
1862
|
-
|
|
1863
|
-
/** 把current激活 provider 的某个字段同步到 providers 列表并持久化 */
|
|
1864
|
-
async function syncProviderField(field, value) {
|
|
1865
|
-
const target = agent.providers.find((p) => p.name === agent.activeProvider)
|
|
1866
|
-
if (!target) return
|
|
1867
|
-
if (value === undefined) delete target[field]
|
|
1868
|
-
else target[field] = value
|
|
1869
|
-
// 全量写回:raw 里的 providers 顺序/内容可能与运行时列表不一致,逐字段改容易写错位
|
|
1870
|
-
await persistRaw((raw) => {
|
|
1871
|
-
raw.providers = agent.providers
|
|
1872
|
-
})
|
|
1873
|
-
}
|
|
1874
|
-
|
|
1875
|
-
// ---------------------------------------------------------- 模型选择器 (/model)
|
|
1876
|
-
|
|
1877
|
-
const pickerItems = () => state.picker?.entries.filter((e) => e.type === "item") ?? []
|
|
1878
|
-
|
|
1879
|
-
/** 打开通用列表选择器。entries 含 { type: "header"|"item", text, note?, ...extra },
|
|
1880
|
-
* onSelect 拿到选中条目 (含 extra 字段透传),onCancel 在 Esc 时调。 */
|
|
1881
|
-
function openPicker({ title, entries, onSelect, onCancel, defaultIndex = 0 }) {
|
|
1882
|
-
state.picker = { title, entries, lines: [], index: defaultIndex, scroll: 0, selectedLine: 0, onSelect, onCancel }
|
|
1883
|
-
renderPickerLines()
|
|
1884
|
-
}
|
|
1885
|
-
|
|
1886
|
-
function closePicker() {
|
|
1887
|
-
state.picker?.onCancel?.()
|
|
1888
|
-
state.picker = null
|
|
1889
|
-
render()
|
|
1890
|
-
}
|
|
1891
|
-
|
|
1892
|
-
/** 按 entries 重建显示行并刷新 */
|
|
1893
|
-
function renderPickerLines() {
|
|
1894
|
-
const p = state.picker
|
|
1895
|
-
if (!p) return
|
|
1896
|
-
const lines = []
|
|
1897
|
-
let row = 0
|
|
1898
|
-
let selectedLine = 0
|
|
1899
|
-
for (const e of p.entries) {
|
|
1900
|
-
if (e.type === "header") {
|
|
1901
|
-
lines.push({ text: ` ${e.text}${e.note ? ` ${e.note}` : ""}`, color: ansi.bold + C.tool })
|
|
1902
|
-
} else {
|
|
1903
|
-
const selected = row === p.index
|
|
1904
|
-
if (selected) selectedLine = lines.length
|
|
1905
|
-
const marker = e.marker ? ` ${e.marker}` : ""
|
|
1906
|
-
lines.push({
|
|
1907
|
-
text: `${selected ? " ▸ " : " "}${e.text}${marker}`,
|
|
1908
|
-
color: selected ? ansi.bold + C.text : C.dim,
|
|
1909
|
-
})
|
|
1910
|
-
row++
|
|
1911
|
-
}
|
|
1912
|
-
}
|
|
1913
|
-
p.lines = lines
|
|
1914
|
-
p.selectedLine = selectedLine
|
|
1915
|
-
render()
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
|
-
// ========== 模型选择器 (基于通用 picker,异步拉取远端模型列表) ==========
|
|
1919
|
-
|
|
1920
|
-
async function openModelPicker() {
|
|
1921
|
-
const entries = []
|
|
1922
|
-
for (const p of agent.providers) {
|
|
1923
|
-
entries.push({ type: "header", text: p.name, note: `${p.baseURL}${p.apiKey ? "" : " (no key)"} loading...` })
|
|
1924
|
-
entries.push({ type: "item", text: p.model, provider: p.name, model: p.model })
|
|
1925
|
-
}
|
|
1926
|
-
const onSelect = (e) => selectModel(e).catch((err) => pushLine(`[error] ${err.message}`, C.error))
|
|
1927
|
-
openPicker({ title: "Select Model", entries, onSelect })
|
|
1928
|
-
// 默认选中current在用的模型
|
|
1929
|
-
const current = pickerItems().findIndex(
|
|
1930
|
-
(e) => e.provider === agent.activeProvider && e.model === agent.provider.model,
|
|
1931
|
-
)
|
|
1932
|
-
if (current >= 0) state.picker.index = current
|
|
1933
|
-
renderPickerLines()
|
|
1934
|
-
|
|
1935
|
-
const { listModels } = await import("./provider.mjs")
|
|
1936
|
-
await Promise.all(
|
|
1937
|
-
agent.providers.map(async (p) => {
|
|
1938
|
-
const header = entries.find((e) => e.type === "header" && e.provider === undefined && e.text === p.name)
|
|
1939
|
-
const noteBase = `${p.baseURL}${p.apiKey ? "" : " (no key)"}`
|
|
1940
|
-
try {
|
|
1941
|
-
const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[p.name]
|
|
1942
|
-
let apiKey = p.apiKey
|
|
1943
|
-
if (!apiKey && envKey && process.env[envKey]) apiKey = process.env[envKey]
|
|
1944
|
-
if (!apiKey) apiKey = process.env.THINCODER_API_KEY
|
|
1945
|
-
const models = await listModels(
|
|
1946
|
-
{ baseURL: p.baseURL, apiKey: apiKey ?? "" },
|
|
1947
|
-
{ signal: AbortSignal.timeout(10000) },
|
|
1948
|
-
)
|
|
1949
|
-
const at = entries.findIndex((e) => e.type === "item" && e.provider === p.name && e.model === p.model)
|
|
1950
|
-
entries.splice(
|
|
1951
|
-
at + 1,
|
|
1952
|
-
0,
|
|
1953
|
-
...models.filter((m) => m !== p.model).map((m) => ({ type: "item", text: m, provider: p.name, model: m })),
|
|
1954
|
-
)
|
|
1955
|
-
if (header) header.note = noteBase
|
|
1956
|
-
} catch (error) {
|
|
1957
|
-
if (header) header.note = `${noteBase} (fetch failed: ${sliceByWidth(error.message, 60)})`
|
|
1958
|
-
}
|
|
1959
|
-
if (state.picker?.entries === entries) renderPickerLines()
|
|
1960
|
-
}),
|
|
1961
|
-
)
|
|
1962
|
-
}
|
|
1963
|
-
|
|
1964
|
-
/** 给指定 provider 写 key (内存 + Config文件);若它是current激活的,同步运行时 */
|
|
1965
|
-
async function setProviderKey(name, key) {
|
|
1966
|
-
const target = agent.providers.find((p) => p.name === name)
|
|
1967
|
-
if (!target) {
|
|
1968
|
-
pushLine(`Provider "${name}"`, C.error)
|
|
1969
|
-
return
|
|
1970
|
-
}
|
|
1971
|
-
target.apiKey = key
|
|
1972
|
-
if (name === agent.activeProvider) agent.provider.apiKey = key
|
|
1973
|
-
await persistRaw((raw) => { raw.providers = agent.providers })
|
|
1974
|
-
pushLabel(`❯ Provider`, ansi.bold + C.tool)
|
|
1975
|
-
pushLine(`API key saved to ${name}`, C.tool)
|
|
1976
|
-
}
|
|
1977
|
-
|
|
1978
|
-
// ---------------------------------------------------------- 初始Config向导 (首次启动)
|
|
1979
|
-
|
|
1980
|
-
/** 菜单步的候选项:已有 provider (no key 的标注)+ 未添加的预设 + 自定义 */
|
|
1981
|
-
function wizardProviderItems() {
|
|
1982
|
-
const items = []
|
|
1983
|
-
for (const p of agent.providers) {
|
|
1984
|
-
items.push({ kind: "existing", name: p.name, baseURL: p.baseURL, model: p.model, label: `${p.name} (added${p.apiKey ? "" : ",no key"})` })
|
|
1985
|
-
}
|
|
1986
|
-
for (const [name, p] of Object.entries(PRESETS)) {
|
|
1987
|
-
if (!agent.providers.some((x) => x.name === name)) {
|
|
1988
|
-
items.push({ kind: "preset", name, baseURL: p.baseURL, model: p.model, label: `${name} (${p.desc})` })
|
|
1989
|
-
}
|
|
1990
|
-
}
|
|
1991
|
-
items.push({ kind: "custom", name: null, label: "Custom endpoint…" })
|
|
1992
|
-
return items
|
|
1993
|
-
}
|
|
1994
|
-
|
|
1995
|
-
/** 文本步骤定义:提示语 + 校验 (通过返回 true,否则返回错误文案) */
|
|
1996
|
-
const WIZARD_STEPS = {
|
|
1997
|
-
name: {
|
|
1998
|
-
prompt: "给这个 provider 起个名字 (字母/数字/-/_,如 my-openai)",
|
|
1999
|
-
validate: (v) =>
|
|
2000
|
-
(/^[\w-]+$/.test(v) && !agent.providers.some((p) => p.name === v)) || "Name must be alphanumeric/-/_ and unique",
|
|
2001
|
-
},
|
|
2002
|
-
baseURL: {
|
|
2003
|
-
prompt: "输入 baseURL (如 https://api.openai.com/v1)",
|
|
2004
|
-
validate: (v) => /^https?:\/\/.+/.test(v) || "baseURL must start with http(s)://",
|
|
2005
|
-
},
|
|
2006
|
-
model: {
|
|
2007
|
-
prompt: "输入模型名 (如 gpt-4o)",
|
|
2008
|
-
validate: (v) => v.length > 0 || "Model name required",
|
|
2009
|
-
},
|
|
2010
|
-
key: {
|
|
2011
|
-
prompt: "输入 API key",
|
|
2012
|
-
validate: (v) => v.length > 0 || "key 不能为空",
|
|
2013
|
-
},
|
|
2014
|
-
embedkey: {
|
|
2015
|
-
prompt: "可选:embedding API key (SiliconFlow,记忆向量检索用;直接回车跳过)",
|
|
2016
|
-
validate: () => true, // 可跳过
|
|
2017
|
-
},
|
|
2018
|
-
}
|
|
2019
|
-
const WIZARD_NEXT = { name: "baseURL", baseURL: "model", model: "key", key: "embedkey", embedkey: null }
|
|
2020
|
-
|
|
2021
|
-
function startWizard() {
|
|
2022
|
-
state.wizard = { step: "provider", index: 0, scroll: 0, selectedLine: 0, fields: {}, error: null, lines: [] }
|
|
2023
|
-
renderWizard()
|
|
2024
|
-
}
|
|
2025
|
-
|
|
2026
|
-
function renderWizard() {
|
|
2027
|
-
const w = state.wizard
|
|
2028
|
-
if (!w) return
|
|
2029
|
-
const lines = []
|
|
2030
|
-
if (w.step === "provider") {
|
|
2031
|
-
lines.push({ text: " Choose a model provider:", color: C.text })
|
|
2032
|
-
wizardProviderItems().forEach((it, i) => {
|
|
2033
|
-
if (i === w.index) w.selectedLine = lines.length
|
|
2034
|
-
lines.push({
|
|
2035
|
-
text: `${i === w.index ? " ▸ " : " "}${it.label}`,
|
|
2036
|
-
color: i === w.index ? ansi.bold + C.text : C.dim,
|
|
2037
|
-
})
|
|
2038
|
-
})
|
|
2039
|
-
} else {
|
|
2040
|
-
const f = w.fields
|
|
2041
|
-
if (f.name) lines.push({ text: ` Provider: ${f.name}`, color: C.dim })
|
|
2042
|
-
if (f.baseURL) lines.push({ text: ` baseURL: ${f.baseURL}`, color: C.dim })
|
|
2043
|
-
if (f.model) lines.push({ text: ` 模型: ${f.model}`, color: C.dim })
|
|
2044
|
-
lines.push({ text: ` ❯ ${WIZARD_STEPS[w.step].prompt}`, color: ansi.bold + C.text })
|
|
2045
|
-
lines.push({ text: " (type in input box below)", color: C.dim })
|
|
2046
|
-
w.selectedLine = 0
|
|
2047
|
-
}
|
|
2048
|
-
if (w.error) lines.push({ text: ` ${w.error}`, color: C.error })
|
|
2049
|
-
w.lines = lines
|
|
2050
|
-
render()
|
|
2051
|
-
}
|
|
2052
|
-
|
|
2053
|
-
function wizardChooseProvider(item) {
|
|
2054
|
-
const w = state.wizard
|
|
2055
|
-
if (item.kind === "custom") {
|
|
2056
|
-
w.step = "name"
|
|
2057
|
-
} else {
|
|
2058
|
-
w.fields = { name: item.name, baseURL: item.baseURL, model: item.model }
|
|
2059
|
-
w.step = "key"
|
|
2060
|
-
}
|
|
2061
|
-
renderWizard()
|
|
2062
|
-
}
|
|
2063
|
-
|
|
2064
|
-
function wizardSubmitText() {
|
|
2065
|
-
const w = state.wizard
|
|
2066
|
-
const value = state.input.join("").trim()
|
|
2067
|
-
const ok = WIZARD_STEPS[w.step].validate(value)
|
|
2068
|
-
if (ok !== true) {
|
|
2069
|
-
w.error = ok
|
|
2070
|
-
renderWizard()
|
|
2071
|
-
return
|
|
2072
|
-
}
|
|
2073
|
-
w.error = null
|
|
2074
|
-
state.input = []
|
|
2075
|
-
state.cursor = 0
|
|
2076
|
-
w.fields[w.step === "key" ? "key" : w.step] = w.step === "baseURL" ? value.replace(/\/+$/, "") : value
|
|
2077
|
-
const next = WIZARD_NEXT[w.step]
|
|
2078
|
-
if (next) {
|
|
2079
|
-
w.step = next
|
|
2080
|
-
renderWizard()
|
|
2081
|
-
} else {
|
|
2082
|
-
finishWizard().catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
2083
|
-
}
|
|
2084
|
-
}
|
|
2085
|
-
|
|
2086
|
-
function cancelWizard() {
|
|
2087
|
-
state.wizard = null
|
|
2088
|
-
pushLine("已跳过初始Config。之后随时可用 /provider add 添加Provider、/provider key 配 key。", C.dim)
|
|
2089
|
-
render()
|
|
2090
|
-
}
|
|
2091
|
-
|
|
2092
|
-
/** 向导完成:写入 provider (有则更新)、设为激活、持久化,然后接模型选择器 */
|
|
2093
|
-
async function finishWizard() {
|
|
2094
|
-
const f = state.wizard.fields
|
|
2095
|
-
state.wizard = null
|
|
2096
|
-
const existing = agent.providers.find((p) => p.name === f.name)
|
|
2097
|
-
if (existing) Object.assign(existing, { baseURL: f.baseURL, model: f.model, apiKey: f.key })
|
|
2098
|
-
else agent.providers.push({ name: f.name, baseURL: f.baseURL, model: f.model, apiKey: f.key })
|
|
2099
|
-
agent.activeProvider = f.name
|
|
2100
|
-
agent.provider = { ...agent.providers.find((p) => p.name === f.name) }
|
|
2101
|
-
if (agent.config?.agent?.compactThresholdAuto) {
|
|
2102
|
-
const { resolveCompactThreshold } = await import("./config.mjs")
|
|
2103
|
-
agent.config.agent.compactThreshold = resolveCompactThreshold(null, f.model).value
|
|
2104
|
-
}
|
|
2105
|
-
await persistRaw((raw) => {
|
|
2106
|
-
raw.providers = agent.providers
|
|
2107
|
-
raw.activeProvider = f.name
|
|
2108
|
-
})
|
|
2109
|
-
agent.config.activeProvider = f.name
|
|
2110
|
-
pushLabel(`❯ Setup`, ansi.bold + C.tool)
|
|
2111
|
-
pushLine(`Setup complete: ${f.name} / ${f.model} (saved to config)`, C.tool)
|
|
2112
|
-
// embedding key:配了就启用向量检索,没配提示事后通道
|
|
2113
|
-
if (f.embedkey) {
|
|
2114
|
-
agent.config.embedding ??= {}
|
|
2115
|
-
agent.config.embedding.apiKey = f.embedkey
|
|
2116
|
-
await persistRaw((raw) => { raw.embedding = { ...(raw.embedding ?? {}), apiKey: f.embedkey } })
|
|
2117
|
-
if (agent.memory && !agent.memory.embedder) {
|
|
2118
|
-
const { createEmbedder } = await import("./embedding.mjs")
|
|
2119
|
-
agent.memory.embedder = createEmbedder(agent.config.embedding)
|
|
2120
|
-
}
|
|
2121
|
-
pushLine(`Vector search enabled (${agent.config.embedding.model ?? "BAAI/bge-m3"})`, C.tool)
|
|
2122
|
-
} else {
|
|
2123
|
-
pushLine(`向量检索未启用 (记忆退化为纯文本检索);之后可 /config embedkey <key> On`, C.dim)
|
|
2124
|
-
}
|
|
2125
|
-
pushLine(`Select model (Esc to keep ${f.model})`, C.dim)
|
|
2126
|
-
openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
2127
|
-
}
|
|
2128
|
-
|
|
2129
|
-
/** 选中:切换 provider + 模型,持久化,阈值随模型走 */
|
|
2130
|
-
async function selectModel(item) {
|
|
2131
|
-
closePicker()
|
|
2132
|
-
const target = agent.providers.find((pp) => pp.name === item.provider)
|
|
2133
|
-
if (!target) return
|
|
2134
|
-
target.model = item.model
|
|
2135
|
-
agent.activeProvider = item.provider
|
|
2136
|
-
agent.provider = { ...target }
|
|
2137
|
-
if (!agent.provider.apiKey) {
|
|
2138
|
-
const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[item.provider]
|
|
2139
|
-
if (envKey && process.env[envKey]) agent.provider.apiKey = process.env[envKey]
|
|
2140
|
-
}
|
|
2141
|
-
if (!agent.provider.apiKey) agent.provider.apiKey = process.env.THINCODER_API_KEY
|
|
2142
|
-
let thresholdNote = ""
|
|
2143
|
-
if (agent.config?.agent?.compactThresholdAuto) {
|
|
2144
|
-
const { resolveCompactThreshold } = await import("./config.mjs")
|
|
2145
|
-
const { value } = resolveCompactThreshold(null, item.model)
|
|
2146
|
-
agent.config.agent.compactThreshold = value
|
|
2147
|
-
thresholdNote = `, compact threshold adjusted to ${value}`
|
|
2148
|
-
}
|
|
2149
|
-
await persistRaw((raw) => {
|
|
2150
|
-
raw.providers = agent.providers
|
|
2151
|
-
raw.activeProvider = item.provider
|
|
2152
|
-
})
|
|
2153
|
-
agent.config.activeProvider = item.provider
|
|
2154
|
-
pushLabel(`❯ Model`, ansi.bold + C.tool)
|
|
2155
|
-
pushLine(`Switched to ${item.provider} / ${item.model}${thresholdNote} (persisted)`, C.tool)
|
|
2156
|
-
if (!agent.provider.apiKey) {
|
|
2157
|
-
pushLine(`Provider has no key`, C.warn)
|
|
2158
|
-
askQuestion(`Enter API key for ${item.provider} (留空跳过):`).then(async (key) => {
|
|
2159
|
-
if (key) {
|
|
2160
|
-
await setProviderKey(item.provider, key)
|
|
2161
|
-
} else {
|
|
2162
|
-
pushLine(`跳过。之后 /provider → Set API Key 配置`, C.dim)
|
|
2163
|
-
}
|
|
2164
|
-
})
|
|
2165
|
-
}
|
|
2166
|
-
}
|
|
2167
|
-
|
|
2168
|
-
/** /distill:从current会话提取候选,逐条 y/n 确认后入库 */
|
|
2169
|
-
async function runDistill() {
|
|
2170
|
-
if (agent.history.length === 0) {
|
|
2171
|
-
pushLine("[distill] current会话为空,没有可提取的内容", C.dim)
|
|
2172
|
-
return
|
|
2173
|
-
}
|
|
2174
|
-
state.processing = true
|
|
2175
|
-
state.status = "Distilling..."
|
|
2176
|
-
render()
|
|
2177
|
-
try {
|
|
2178
|
-
const { extractCandidates, historyToTranscript, saveCandidate } = await import("./distill.mjs")
|
|
2179
|
-
pushLine("[distill] Analyzing session...", C.tool)
|
|
2180
|
-
const candidates = await extractCandidates(agent.provider, historyToTranscript(agent.history))
|
|
2181
|
-
if (candidates.length === 0) {
|
|
2182
|
-
pushLine("[distill] No knowledge worth saving from this session", C.dim)
|
|
2183
|
-
return
|
|
2184
|
-
}
|
|
2185
|
-
let saved = 0
|
|
2186
|
-
for (const c of candidates) {
|
|
2187
|
-
pushLine(`── Candidate [${c.type}] ${c.title} (scope: ${c.scope ?? "personal"})`, C.warn)
|
|
2188
|
-
for (const line of c.content.split("\n").slice(0, 6)) pushLine(` ${line}`, C.dim)
|
|
2189
|
-
if (c.type === "rule") pushLine(" (rule type — consider writing manually; press y to extract)", C.warn)
|
|
2190
|
-
const accept = await askPermission("distill-save", { title: c.title })
|
|
2191
|
-
if (!accept) {
|
|
2192
|
-
pushLine(" skipped", C.dim)
|
|
2193
|
-
continue
|
|
2194
|
-
}
|
|
2195
|
-
const where = await saveCandidate(agent.memory, c, distillOpts)
|
|
2196
|
-
pushLine(` saved -> ${where}`, C.tool)
|
|
2197
|
-
saved++
|
|
2198
|
-
}
|
|
2199
|
-
pushLine(`[distill] Done: saved ${saved}/${candidates.length} 条`, C.tool)
|
|
2200
|
-
} catch (error) {
|
|
2201
|
-
pushLine(`[distill] error: ${error.message}`, C.error)
|
|
2202
|
-
} finally {
|
|
2203
|
-
state.processing = false
|
|
2204
|
-
state.status = "Ready"
|
|
2205
|
-
render()
|
|
2206
|
-
}
|
|
2207
|
-
}
|
|
2208
|
-
|
|
2209
|
-
// ---------------------------------------------------------- 键盘 / 鼠标
|
|
2210
|
-
|
|
2211
|
-
// keypress 挂在过滤后的 keyStream 上:鼠标序列已在上游滤网中处理并剥除
|
|
2212
|
-
keyStream.on("keypress", (str, key = {}) => {
|
|
2213
|
-
// 权限确认态:y 批准 / n 拒绝 / a 批准并On AUTO (后续不再询问)
|
|
2214
|
-
if (state.permission) {
|
|
2215
|
-
const answer = (str || "").toLowerCase()
|
|
2216
|
-
const isContinue = state.permission.name === "continue"
|
|
2217
|
-
const validKeys = isContinue ? ["y", "n"] : ["y", "n", "a"]
|
|
2218
|
-
if (validKeys.includes(answer) || key.name === "escape") {
|
|
2219
|
-
const { resolve, name } = state.permission
|
|
2220
|
-
state.permission = null
|
|
2221
|
-
state.permissionPreview = []
|
|
2222
|
-
state.status = "Processing..."
|
|
2223
|
-
if (answer === "a" && !isContinue) {
|
|
2224
|
-
agent.autoApprove = true
|
|
2225
|
-
agent._pendingReminders = agent._pendingReminders ?? []
|
|
2226
|
-
agent._pendingReminders.push("[System reminder: AUTO mode is now ON. All tool calls are automatically approved. Use /auto to disable.]")
|
|
2227
|
-
pushLine(` [auto] AUTO 已On:后续工具调用不再询问 (/auto Off)`, C.warn)
|
|
2228
|
-
}
|
|
2229
|
-
const approved = answer === "y" || (answer === "a" && !isContinue)
|
|
2230
|
-
// 决定落痕:对话区留下批准/拒绝记录 (continue 询问有自己的输出,不重复记)
|
|
2231
|
-
if (!isContinue) {
|
|
2232
|
-
pushLine(` [${approved ? "approved" : "denied"}] ${name}`, approved ? C.dim : C.error)
|
|
2233
|
-
}
|
|
2234
|
-
resolve(approved)
|
|
2235
|
-
render()
|
|
2236
|
-
}
|
|
2237
|
-
return
|
|
2238
|
-
}
|
|
2239
|
-
|
|
2240
|
-
// question 工具回调:自由文本 / 选项选择
|
|
2241
|
-
if (state.question) {
|
|
2242
|
-
const q = state.question
|
|
2243
|
-
if (q.options.length > 0) {
|
|
2244
|
-
// 选项模式:↑↓ 选择,Enter 确认,Esc 取消
|
|
2245
|
-
if (key.name === "escape") {
|
|
2246
|
-
q.resolve("(cancelled)")
|
|
2247
|
-
state.question = null
|
|
2248
|
-
state.status = "Processing..."
|
|
2249
|
-
render()
|
|
2250
|
-
} else if (key.name === "up") {
|
|
2251
|
-
q.selected = Math.max(0, (q.selected ?? 0) - 1)
|
|
2252
|
-
render()
|
|
2253
|
-
} else if (key.name === "down") {
|
|
2254
|
-
q.selected = Math.min(q.options.length - 1, (q.selected ?? 0) + 1)
|
|
2255
|
-
render()
|
|
2256
|
-
} else if (key.name === "return") {
|
|
2257
|
-
const answer = q.options[q.selected ?? 0]
|
|
2258
|
-
q.resolve(answer)
|
|
2259
|
-
state.question = null
|
|
2260
|
-
state.status = "Processing..."
|
|
2261
|
-
pushLine(` → ${answer}`, C.tool)
|
|
2262
|
-
render()
|
|
2263
|
-
}
|
|
2264
|
-
} else {
|
|
2265
|
-
// 自由文本:键入答案,Enter 提交,Esc 取消
|
|
2266
|
-
if (key.name === "escape") {
|
|
2267
|
-
q.resolve("(cancelled)")
|
|
2268
|
-
state.question = null
|
|
2269
|
-
state.status = "Processing..."
|
|
2270
|
-
render()
|
|
2271
|
-
} else if (key.name === "return") {
|
|
2272
|
-
const answer = (q.answer ?? "").trim()
|
|
2273
|
-
q.resolve(answer || "(empty answer)")
|
|
2274
|
-
state.question = null
|
|
2275
|
-
state.status = "Processing..."
|
|
2276
|
-
pushLine(` → ${answer || "(empty)"}`, C.tool)
|
|
2277
|
-
render()
|
|
2278
|
-
} else if (key.name === "backspace") {
|
|
2279
|
-
q.answer = (q.answer ?? "").slice(0, -1)
|
|
2280
|
-
render()
|
|
2281
|
-
} else if (str && !key.ctrl && !key.meta) {
|
|
2282
|
-
q.answer = (q.answer ?? "") + str
|
|
2283
|
-
render()
|
|
2284
|
-
}
|
|
2285
|
-
}
|
|
2286
|
-
return
|
|
2287
|
-
}
|
|
2288
|
-
|
|
2289
|
-
if (key.ctrl && key.name === "c") {
|
|
2290
|
-
if (state.processing && state.controller) {
|
|
2291
|
-
state.controller.abort()
|
|
2292
|
-
pushLine("[Aborting…]", C.warn)
|
|
2293
|
-
render()
|
|
2294
|
-
return
|
|
2295
|
-
}
|
|
2296
|
-
cleanup()
|
|
2297
|
-
setTimeout(() => process.exit(0), 100)
|
|
2298
|
-
}
|
|
2299
|
-
|
|
2300
|
-
// 通用列表选择器:↑↓ 移动,Enter 确认,Esc 取消
|
|
2301
|
-
if (state.picker) {
|
|
2302
|
-
const items = pickerItems()
|
|
2303
|
-
if (key.name === "escape") {
|
|
2304
|
-
closePicker()
|
|
2305
|
-
} else if (key.name === "up" && items.length) {
|
|
2306
|
-
state.picker.index = (state.picker.index - 1 + items.length) % items.length
|
|
2307
|
-
renderPickerLines()
|
|
2308
|
-
} else if (key.name === "down" && items.length) {
|
|
2309
|
-
state.picker.index = (state.picker.index + 1) % items.length
|
|
2310
|
-
renderPickerLines()
|
|
2311
|
-
} else if (key.name === "return" && items.length) {
|
|
2312
|
-
const selected = items[state.picker.index]
|
|
2313
|
-
state.picker.onSelect?.(selected)
|
|
2314
|
-
closePicker()
|
|
2315
|
-
}
|
|
2316
|
-
return
|
|
2317
|
-
}
|
|
2318
|
-
|
|
2319
|
-
// 初始Config向导:菜单步 ↑↓/Enter/Esc;文本步 Enter 提交、Esc 取消,编辑键落到正常输入
|
|
2320
|
-
if (state.wizard) {
|
|
2321
|
-
const w = state.wizard
|
|
2322
|
-
if (key.name === "escape") {
|
|
2323
|
-
cancelWizard()
|
|
2324
|
-
return
|
|
2325
|
-
}
|
|
2326
|
-
if (w.step === "provider") {
|
|
2327
|
-
const items = wizardProviderItems()
|
|
2328
|
-
if (key.name === "up" && items.length) {
|
|
2329
|
-
w.index = (w.index - 1 + items.length) % items.length
|
|
2330
|
-
renderWizard()
|
|
2331
|
-
} else if (key.name === "down" && items.length) {
|
|
2332
|
-
w.index = (w.index + 1) % items.length
|
|
2333
|
-
renderWizard()
|
|
2334
|
-
} else if (key.name === "return" && items.length) {
|
|
2335
|
-
wizardChooseProvider(items[w.index])
|
|
2336
|
-
}
|
|
2337
|
-
return
|
|
2338
|
-
}
|
|
2339
|
-
if (key.name === "return") {
|
|
2340
|
-
wizardSubmitText()
|
|
2341
|
-
return
|
|
2342
|
-
}
|
|
2343
|
-
// 文本步骤屏蔽翻页/历史,其余编辑键放行到下面的普通输入逻辑
|
|
2344
|
-
if (key.name === "up" || key.name === "down" || key.name === "pageup" || key.name === "pagedown") return
|
|
2345
|
-
}
|
|
2346
|
-
|
|
2347
|
-
// 翻页
|
|
2348
|
-
if (key.name === "pageup") {
|
|
2349
|
-
state.scroll += Math.max(1, (process.stdout.rows || 24) - 8)
|
|
2350
|
-
render()
|
|
2351
|
-
return
|
|
2352
|
-
}
|
|
2353
|
-
if (key.name === "pagedown") {
|
|
2354
|
-
state.scroll = Math.max(0, state.scroll - Math.max(1, (process.stdout.rows || 24) - 8))
|
|
2355
|
-
render()
|
|
2356
|
-
return
|
|
2357
|
-
}
|
|
2358
|
-
|
|
2359
|
-
if (state.processing) {
|
|
2360
|
-
// 处理中允许输入(排队),但屏蔽方向键历史和 Tab 补全
|
|
2361
|
-
if (key.name === "tab" || key.name === "up" || key.name === "down") return
|
|
2362
|
-
// Ctrl+D:删除队列中最后一条
|
|
2363
|
-
if (key.ctrl && key.name === "d") {
|
|
2364
|
-
if (state.queue.length > 0) {
|
|
2365
|
-
state.queue.pop()
|
|
2366
|
-
render()
|
|
2367
|
-
}
|
|
2368
|
-
return
|
|
2369
|
-
}
|
|
2370
|
-
// 其余可打印字符正常进入输入框
|
|
2371
|
-
}
|
|
2372
|
-
|
|
2373
|
-
// Tab:斜杠Commands补全 (循环候选);其余输入忽略 (\t 会顶破输入框,永不直接插入)
|
|
2374
|
-
if (key.name === "tab") {
|
|
2375
|
-
handleTab()
|
|
2376
|
-
return
|
|
2377
|
-
}
|
|
2378
|
-
|
|
2379
|
-
// 输入历史
|
|
2380
|
-
if (key.name === "up") {
|
|
2381
|
-
if (state.history.length) {
|
|
2382
|
-
state.historyIndex = state.historyIndex === -1 ? state.history.length - 1 : Math.max(0, state.historyIndex - 1)
|
|
2383
|
-
state.input = [...state.history[state.historyIndex]]
|
|
2384
|
-
state.cursor = state.input.length
|
|
2385
|
-
render()
|
|
2386
|
-
}
|
|
2387
|
-
return
|
|
2388
|
-
}
|
|
2389
|
-
if (key.name === "down") {
|
|
2390
|
-
if (state.historyIndex !== -1) {
|
|
2391
|
-
state.historyIndex++
|
|
2392
|
-
if (state.historyIndex >= state.history.length) {
|
|
2393
|
-
state.historyIndex = -1
|
|
2394
|
-
state.input = []
|
|
2395
|
-
} else {
|
|
2396
|
-
state.input = [...state.history[state.historyIndex]]
|
|
2397
|
-
}
|
|
2398
|
-
state.cursor = state.input.length
|
|
2399
|
-
render()
|
|
2400
|
-
}
|
|
2401
|
-
return
|
|
2402
|
-
}
|
|
2403
|
-
|
|
2404
|
-
// 光标移动
|
|
2405
|
-
if (key.name === "left") {
|
|
2406
|
-
state.cursor = Math.max(0, state.cursor - 1)
|
|
2407
|
-
render()
|
|
2408
|
-
return
|
|
2409
|
-
}
|
|
2410
|
-
if (key.name === "right") {
|
|
2411
|
-
state.cursor = Math.min(state.input.length, state.cursor + 1)
|
|
2412
|
-
render()
|
|
2413
|
-
return
|
|
2414
|
-
}
|
|
2415
|
-
if (key.name === "home") {
|
|
2416
|
-
state.cursor = 0
|
|
2417
|
-
render()
|
|
2418
|
-
return
|
|
2419
|
-
}
|
|
2420
|
-
if (key.name === "end") {
|
|
2421
|
-
state.cursor = state.input.length
|
|
2422
|
-
render()
|
|
2423
|
-
return
|
|
2424
|
-
}
|
|
2425
|
-
|
|
2426
|
-
// 编辑
|
|
2427
|
-
if (key.name === "backspace") {
|
|
2428
|
-
if (state.cursor > 0) {
|
|
2429
|
-
state.input.splice(state.cursor - 1, 1)
|
|
2430
|
-
state.cursor--
|
|
2431
|
-
render()
|
|
2432
|
-
}
|
|
2433
|
-
return
|
|
2434
|
-
}
|
|
2435
|
-
if (key.name === "delete") {
|
|
2436
|
-
if (state.cursor < state.input.length) {
|
|
2437
|
-
state.input.splice(state.cursor, 1)
|
|
2438
|
-
render()
|
|
2439
|
-
}
|
|
2440
|
-
return
|
|
2441
|
-
}
|
|
2442
|
-
if (key.name === "return") {
|
|
2443
|
-
submit().catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
2444
|
-
return
|
|
2445
|
-
}
|
|
2446
|
-
|
|
2447
|
-
// Ctrl+V (Unix) / Alt+V (Windows):粘贴剪贴板图片 → 存临时文件 → 输入框插入 read_image
|
|
2448
|
-
const isPasteImage = (key.name === "v" && (key.ctrl || key.meta)) || (key.name === "v" && key.alt)
|
|
2449
|
-
if (isPasteImage) {
|
|
2450
|
-
pasteClipboardImage(agent).catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
2451
|
-
return
|
|
2452
|
-
}
|
|
2453
|
-
|
|
2454
|
-
// 可打印字符 / 粘贴 (str 可能一次多个字符);Tab 一律转成两个空格 (\t 显示宽度不定,会顶破输入框)
|
|
2455
|
-
// \r\n 在 Windows raw mode 下可能漏进来冲乱页面
|
|
2456
|
-
if (str && !key.ctrl && !key.meta) {
|
|
2457
|
-
const chars = [...str.replace(/[\r\n]+/g, "").replace(/\t/g, " ")]
|
|
2458
|
-
state.input.splice(state.cursor, 0, ...chars)
|
|
2459
|
-
state.cursor += chars.length
|
|
2460
|
-
render()
|
|
2461
|
-
}
|
|
2462
|
-
})
|
|
2463
|
-
|
|
2464
|
-
// 启动画面
|
|
2465
|
-
if (!agent.provider.apiKey) {
|
|
2466
|
-
pushLabel(`Welcome to ThinCoder!`, ansi.bold + C.tool)
|
|
2467
|
-
pushLine("检测到还没Config API key,进入初始Config (Esc 可随时跳过)", C.text)
|
|
2468
|
-
startWizard()
|
|
2469
|
-
} else {
|
|
2470
|
-
pushLine(`Welcome to ThinCoder. Provider: ${agent.activeProvider} / ${agent.provider.model}`, C.dim)
|
|
2471
|
-
}
|
|
2472
|
-
pushLine(`Tools: ${agent.tools.map((t) => t.name).join(", ")}`, C.dim)
|
|
2473
|
-
// 恢复上次会话:重建对话区显示 (tool 结果行省略,保持清爽)
|
|
2474
|
-
if (opts.restored?.display?.length) {
|
|
2475
|
-
// 用户视角的恢复:display 是退出前对话区的原样快照,所见即所得
|
|
2476
|
-
state.lines = [...opts.restored.display.map((l) => ({ text: l.text, color: l.color })), ...state.lines]
|
|
2477
|
-
pushLabel(`── Restored previous session; /new for a fresh session ──`, C.warn)
|
|
2478
|
-
} else if (opts.restored?.history?.length) {
|
|
2479
|
-
// 重建对话区:user/assistant 消息逐条展示,tool 结果行只保留首行摘要
|
|
2480
|
-
for (let i = 0; i < opts.restored.history.length; i++) {
|
|
2481
|
-
const m = opts.restored.history[i]
|
|
2482
|
-
if (m.role === "user") {
|
|
2483
|
-
if (typeof m.content === "string" && m.content.startsWith("[System reminder:")) continue
|
|
2484
|
-
pushLabel(`❯ You:`, ansi.bold + C.user)
|
|
2485
|
-
if (typeof m.content === "string" && m.content) pushLine(m.content, C.text)
|
|
2486
|
-
} else if (m.role === "assistant") {
|
|
2487
|
-
pushLabel(`❯ ThinCoder:`, ansi.bold + C.assistant)
|
|
2488
|
-
if (typeof m.content === "string" && m.content) pushLine(m.content, C.text)
|
|
2489
|
-
for (const tc of m.tool_calls ?? []) {
|
|
2490
|
-
// 找到下一条对应的 tool 结果,显示首行摘要
|
|
2491
|
-
const toolResult = opts.restored.history[i + 1]
|
|
2492
|
-
const hasResult = toolResult?.role === "tool" && toolResult?.tool_call_id === tc.id
|
|
2493
|
-
const summary = hasResult ? " → " + sliceByWidth(String(toolResult.content).split("\n")[0], 80) : ""
|
|
2494
|
-
pushLine(` [tool] ${tc.function?.name ?? "?"}${summary}`, C.tool)
|
|
2495
|
-
}
|
|
2496
|
-
}
|
|
2497
|
-
// tool 消息本身不单独渲染——已在 assistant 的 tool_calls 后以摘要形式展示
|
|
2498
|
-
}
|
|
2499
|
-
pushLabel(`── Restored previous session (${opts.restored.history.length} messages); /new for a fresh session ──`, C.warn)
|
|
2500
|
-
}
|
|
2501
|
-
// 有归档槽位时给个提示
|
|
2502
|
-
if (listSlots(agent.cwd).length > 0) {
|
|
2503
|
-
pushLine("Tip: archived sessions available — /session to view/switch", C.dim)
|
|
2504
|
-
}
|
|
2505
|
-
render()
|
|
2506
|
-
|
|
2507
|
-
// 后台索引 (进界面后再跑,不阻塞启动);进度走底部状态栏,不往对话区塞行
|
|
2508
|
-
// 优先用 git diff 增量(快),git 不可用或首次运行时退到全量扫描
|
|
2509
|
-
;(async () => {
|
|
2510
|
-
const { codeSync, docSync, gitSync } = await import("./memory.mjs")
|
|
2511
|
-
const cwd = agent.cwd
|
|
2512
|
-
let codeFiles = 0, docFiles = 0
|
|
2513
|
-
|
|
2514
|
-
state.status = "Indexing..."
|
|
2515
|
-
render()
|
|
2516
|
-
|
|
2517
|
-
const gitRes = await gitSync(agent.memory, cwd, {
|
|
2518
|
-
onProgress: (p) => {
|
|
2519
|
-
if (p.phase === "index" && p.current % 5 === 0) {
|
|
2520
|
-
state.status = `Indexing... ${p.current}/${p.total}`
|
|
2521
|
-
render()
|
|
2522
|
-
}
|
|
2523
|
-
}
|
|
2524
|
-
})
|
|
2525
|
-
|
|
2526
|
-
if (gitRes !== null) {
|
|
2527
|
-
// git 增量成功,直接统计
|
|
2528
|
-
codeFiles = agent.memory.db.prepare(`SELECT COUNT(DISTINCT path) AS n FROM code_chunks`).get()?.n ?? 0
|
|
2529
|
-
docFiles = agent.memory.db.prepare(`SELECT COUNT(DISTINCT path) AS n FROM doc_chunks`).get()?.n ?? 0
|
|
2530
|
-
} else {
|
|
2531
|
-
// 退到全量扫描(codeSync 和 docSync 并行——读写不同表,SQLite WAL 天然支持)
|
|
2532
|
-
const [codeRes, docRes] = await Promise.allSettled([
|
|
2533
|
-
codeSync(agent.memory, cwd, {
|
|
2534
|
-
onProgress: (p) => {
|
|
2535
|
-
if (p.phase === "index" && p.current % 30 === 0) {
|
|
2536
|
-
state.status = `Indexing code... ${p.current}/${p.total}`
|
|
2537
|
-
render()
|
|
2538
|
-
}
|
|
2539
|
-
}
|
|
2540
|
-
}),
|
|
2541
|
-
docSync(agent.memory, cwd, {
|
|
2542
|
-
onProgress: (p) => {
|
|
2543
|
-
if (p.phase === "index" && p.current % 10 === 0) {
|
|
2544
|
-
state.status = `Indexing docs... ${p.current}/${p.total}`
|
|
2545
|
-
render()
|
|
2546
|
-
}
|
|
2547
|
-
}
|
|
2548
|
-
}),
|
|
2549
|
-
])
|
|
2550
|
-
if (codeRes.status === "fulfilled") {
|
|
2551
|
-
codeFiles = agent.memory.db.prepare(`SELECT COUNT(DISTINCT path) AS n FROM code_chunks`).get()?.n ?? 0
|
|
2552
|
-
}
|
|
2553
|
-
if (docRes.status === "fulfilled") {
|
|
2554
|
-
docFiles = agent.memory.db.prepare(`SELECT COUNT(DISTINCT path) AS n FROM doc_chunks`).get()?.n ?? 0
|
|
2555
|
-
}
|
|
2556
|
-
}
|
|
2557
|
-
|
|
2558
|
-
state.status = codeFiles || docFiles
|
|
2559
|
-
? `Ready — idx code ${codeFiles} doc ${docFiles}`
|
|
2560
|
-
: "Ready"
|
|
2561
|
-
render()
|
|
2562
|
-
})()
|
|
2563
|
-
}
|
|
2564
|
-
|
|
2565
|
-
function summarize(obj) {
|
|
2566
|
-
const s = JSON.stringify(obj)
|
|
2567
|
-
return s.length > 80 ? s.slice(0, 80) + "…" : s
|
|
2568
|
-
}
|
|
5
|
+
export { startTUI } from "./tui/index.mjs"
|