thincoder 0.12.2 → 0.12.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -6
- package/package.json +3 -3
- package/src/advisor/history.mjs +112 -0
- package/src/advisor/messages.mjs +182 -0
- package/src/advisor/repos.mjs +133 -0
- package/src/advisor/run.mjs +346 -0
- package/src/advisor.mjs +109 -509
- package/src/agent/completion.mjs +134 -0
- package/src/agent/dispatch.mjs +54 -7
- package/src/agent/post-turn.mjs +70 -0
- package/src/agent/setup.mjs +95 -6
- package/src/agent-tools/advisor.mjs +159 -12
- package/src/agent-tools/eng.mjs +64 -0
- package/src/agent-tools/subagent.mjs +73 -3
- package/src/agent-tools/task.mjs +45 -6
- package/src/agent-tools/verify.mjs +18 -0
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +152 -161
- package/src/cli/make-agent.mjs +1 -0
- package/src/cli/setup-wizard.mjs +1 -0
- package/src/config.mjs +34 -4
- package/src/context.mjs +47 -13
- package/src/generate-title.mjs +44 -0
- package/src/prompts/advisor-design.md +43 -0
- package/src/prompts/advisor-round1.md +11 -4
- package/src/prompts/advisor-round2.md +12 -7
- package/src/prompts/advisor-round3.md +11 -6
- package/src/prompts/coder.md +9 -3
- package/src/prompts/discipline.md +12 -96
- package/src/prompts/eng-coder.md +34 -0
- package/src/prompts/engineering-sub.md +12 -0
- package/src/prompts/engineering.md +96 -0
- package/src/prompts/main.md +1 -1
- package/src/prompts/methodology-template.md +39 -0
- package/src/prompts/plan.md +2 -2
- package/src/prompts/system.md +43 -61
- package/src/provider/core.mjs +58 -2
- package/src/session.mjs +291 -94
- package/src/skills.mjs +48 -15
- package/src/tools/apply_patch.md +1 -1
- package/src/tools/checklist.mjs +4 -3
- package/src/tools/codemode.mjs +23 -11
- package/src/tools/delete.md +1 -0
- package/src/tools/edit.md +1 -1
- package/src/tools/execute.md +5 -0
- package/src/tools/file.mjs +4 -0
- package/src/tools/git.md +15 -0
- package/src/tools/git.mjs +1 -6
- package/src/tools/lint.md +8 -0
- package/src/tools/linter.mjs +1 -5
- package/src/tools/lsp.md +7 -0
- package/src/tools/lsp.mjs +8 -9
- package/src/tools/patch.mjs +1 -29
- package/src/tools/read_image.md +5 -1
- package/src/tools/system.mjs +1 -1
- package/src/tools/web.mjs +3 -3
- package/src/tui/agent-turn.mjs +184 -66
- package/src/tui/ansi.mjs +4 -0
- package/src/tui/clipboard.mjs +9 -0
- package/src/tui/cmd-config.mjs +14 -26
- package/src/tui/cmd-eng.mjs +44 -0
- package/src/tui/cmd-exit.mjs +1 -1
- package/src/tui/cmd-fold.mjs +3 -4
- package/src/tui/cmd-model.mjs +11 -6
- package/src/tui/cmd-new.mjs +5 -5
- package/src/tui/cmd-session.mjs +21 -11
- package/src/tui/cmd-think.mjs +1 -0
- package/src/tui/index.mjs +20 -9
- package/src/tui/key-handler.mjs +177 -9
- package/src/tui/layout.mjs +5 -5
- package/src/tui/markdown.mjs +52 -0
- package/src/tui/pickers.mjs +190 -45
- package/src/tui/render-conversation.mjs +54 -13
- package/src/tui/render-frame.mjs +39 -12
- package/src/tui/render-loop.mjs +2 -1
- package/src/tui/render.mjs +13 -7
- package/src/tui/slash-commands.mjs +11 -7
- package/src/tui/startup.mjs +4 -3
- package/src/tui/wizard.mjs +3 -0
- package/src/tools/checkpoint.md +0 -15
- package/src/tools/git_diff.md +0 -11
- package/src/tools/git_log.md +0 -10
- package/src/tools/git_status.md +0 -8
- package/src/tools/linter.md +0 -13
- package/src/tools/syntax_check.md +0 -10
package/src/tui/agent-turn.mjs
CHANGED
|
@@ -3,6 +3,22 @@ import { saveSession } from "../session.mjs"
|
|
|
3
3
|
import { sliceByWidth } from "./render.mjs"
|
|
4
4
|
import { ansi, C } from "./ansi.mjs"
|
|
5
5
|
|
|
6
|
+
/** Tool execution start timestamps (performance.now ms), keyed by tool name. */
|
|
7
|
+
const _toolTicks = Object.create(null)
|
|
8
|
+
|
|
9
|
+
/** Per-tool streaming preview line limits — tools with verbose output get more lines */
|
|
10
|
+
const LIVE_LINE_LIMITS = {
|
|
11
|
+
bash: 10,
|
|
12
|
+
advisor: 15,
|
|
13
|
+
read: 3,
|
|
14
|
+
grep: 8,
|
|
15
|
+
glob: 8,
|
|
16
|
+
search: 8,
|
|
17
|
+
websearch: 8,
|
|
18
|
+
code_search: 8,
|
|
19
|
+
doc_search: 8,
|
|
20
|
+
}
|
|
21
|
+
|
|
6
22
|
/** Execute one agent conversation turn (triggered by submit or queue).
|
|
7
23
|
* Extracted from index.mjs: agent loop + callback construction + error handling + queue processing.
|
|
8
24
|
* ctx: { agent, state, pushLine, pushLabel, render, scheduleRender,
|
|
@@ -29,6 +45,8 @@ export async function runAgentTurn(ctx, text) {
|
|
|
29
45
|
state.status = "Processing..."
|
|
30
46
|
state.streaming = ""
|
|
31
47
|
state.reasoning = ""
|
|
48
|
+
state.advisorStreaming = ""
|
|
49
|
+
state._advisorThink = ""
|
|
32
50
|
state.subTasks = {}
|
|
33
51
|
state.currentTool = null
|
|
34
52
|
state.processingStarted = Date.now()
|
|
@@ -49,6 +67,8 @@ export async function runAgentTurn(ctx, text) {
|
|
|
49
67
|
pushLine(state.streaming, C.text)
|
|
50
68
|
state.streaming = ""
|
|
51
69
|
}
|
|
70
|
+
state.advisorStreaming = ""
|
|
71
|
+
state._advisorThink = ""
|
|
52
72
|
}
|
|
53
73
|
|
|
54
74
|
const callbacks = {
|
|
@@ -102,14 +122,36 @@ export async function runAgentTurn(ctx, text) {
|
|
|
102
122
|
scheduleRender()
|
|
103
123
|
return
|
|
104
124
|
}
|
|
125
|
+
if (name === "advisor") { state.advisorStreaming = ""; state._advisorThink = "" }
|
|
105
126
|
flushStream()
|
|
106
127
|
ensureAssistantLabel()
|
|
107
128
|
state.currentTool = name
|
|
129
|
+
// Update status bar with current tool and key arguments for user visibility
|
|
130
|
+
if (name === "bash" && args.command) {
|
|
131
|
+
const cmd = args.command.replace(/\s+/g, " ").trim()
|
|
132
|
+
state.status = `Running: ${cmd.length > 50 ? cmd.slice(0, 50) + "…" : cmd}`
|
|
133
|
+
} else if ((name === "read" || name === "write" || name === "edit" || name === "grep" || name === "glob") && args.path) {
|
|
134
|
+
state.status = `${name}: ${args.path}`
|
|
135
|
+
} else if (name === "grep" && args.pattern) {
|
|
136
|
+
state.status = `grep: ${args.pattern}`
|
|
137
|
+
} else if (name === "glob" && args.pattern) {
|
|
138
|
+
state.status = `glob: ${args.pattern}`
|
|
139
|
+
} else if (name === "websearch" && args.query) {
|
|
140
|
+
state.status = `search: ${args.query.length > 40 ? args.query.slice(0, 40) + "…" : args.query}`
|
|
141
|
+
} else if (name === "advisor") {
|
|
142
|
+
state.status = `advisor review (round ${(agent._advisorRound || 0) + 1})`
|
|
143
|
+
} else {
|
|
144
|
+
state.status = `tool: ${name}`
|
|
145
|
+
}
|
|
108
146
|
// Advisor: tag the round in the tool title — the model's own "第N轮" narration
|
|
109
147
|
// is unreliable (it glues onto the previous line), so the round belongs here.
|
|
110
148
|
const roundTag = name === "advisor" ? ` (round ${(agent._advisorRound || 0) + 1})` : ""
|
|
111
149
|
const argSummary = summarize(args)
|
|
112
|
-
|
|
150
|
+
// Inline block title — panel tools get both the title AND the
|
|
151
|
+
// streaming output panel, complementary display.
|
|
152
|
+
const color = ({ advisor: C.advisor, bash: C.warn, verify: C.tool }[name] ?? C.text)
|
|
153
|
+
pushLine(`❯ ${name}${roundTag}${argSummary ? ` ${argSummary}` : ""}`, color)
|
|
154
|
+
_toolTicks[name] = performance.now()
|
|
113
155
|
},
|
|
114
156
|
onToolResult: (name, result) => {
|
|
115
157
|
state.currentTool = null
|
|
@@ -137,83 +179,74 @@ export async function runAgentTurn(ctx, text) {
|
|
|
137
179
|
if (state.processing) render()
|
|
138
180
|
}, 3000)
|
|
139
181
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
// Keep the panel visible for a 3s grace period (layout filters by closeAt);
|
|
145
|
-
// the render loop prunes it once expired. No defer hacks needed — row-diff
|
|
146
|
-
// repaints whatever should be on screen.
|
|
147
|
-
panel.done = true
|
|
148
|
-
panel.closeAt = Date.now() + 3000
|
|
149
|
-
scheduleRender()
|
|
150
|
-
if (name === "advisor") {
|
|
151
|
-
const text = String(result ?? "")
|
|
152
|
-
const lines = text.split("\n")
|
|
153
|
-
const maxShow = Math.min(60, lines.length)
|
|
154
|
-
// Push as single multiline block so formatTables aligns MD table columns
|
|
155
|
-
const shown = lines.slice(0, maxShow).map((l) => ` ${l.slice(0, 200)}`).join("\n")
|
|
156
|
-
pushLine(shown, C.advisor)
|
|
157
|
-
if (lines.length > maxShow) pushLine(` ... (${lines.length - maxShow} more lines — call advisor again or scroll through the tool result for full output)`, C.dim)
|
|
158
|
-
} else {
|
|
159
|
-
const summary = formatPanelSummary(name, result)
|
|
160
|
-
if (summary) pushLine(` ${summary}`, C.dim)
|
|
182
|
+
if (!isSubagent && name !== "advisor") {
|
|
183
|
+
// Remove live streaming lines — done line handles the summary.
|
|
184
|
+
for (let i = state.lines.length - 1; i >= 0; i--) {
|
|
185
|
+
if (state.lines[i]._live === name) state.lines.splice(i, 1)
|
|
161
186
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
} else if (stream) {
|
|
165
|
-
const tail = stream.trimEnd().slice(-4000)
|
|
166
|
-
if (tail) pushLine(tail, C.dim)
|
|
167
|
-
delete state.toolStreams[name]
|
|
187
|
+
const summary = formatToolSummary(name, result)
|
|
188
|
+
if (summary) pushLine(` ${summary}`, C.dim)
|
|
168
189
|
}
|
|
169
|
-
if (
|
|
170
|
-
|
|
171
|
-
|
|
190
|
+
if (name === "advisor") { state.advisorStreaming = ""; state._advisorThink = "" }
|
|
191
|
+
// Done line for ALL tools (panel area abolished — inline only).
|
|
192
|
+
if (!isSubagent) {
|
|
193
|
+
const elapsed = _toolTicks[name] ? ` (${Math.round(performance.now() - _toolTicks[name])}ms)` : ""
|
|
194
|
+
const summary = formatToolSummary(name, result)
|
|
195
|
+
const tail = summary ? ` → ${sliceByWidth(summary, 60)}` : ""
|
|
196
|
+
pushLine(`❯ ${name} — done${elapsed}${tail}`, C.dim)
|
|
172
197
|
}
|
|
198
|
+
delete _toolTicks[name]
|
|
173
199
|
},
|
|
174
200
|
onToolOutput: (name, chunk) => {
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
// per-kind coloring in renderOutput so reasoning / answer / tool progress are distinct.
|
|
178
|
-
let panel = state.outputPanels[name]
|
|
179
|
-
if (!panel) {
|
|
180
|
-
// Lazy-create panel: defensive against race conditions where setupOutputPanel
|
|
181
|
-
// hasn't fired yet or the callbacks chain dropped it (subagent relay, reconnect, etc.)
|
|
182
|
-
state.outputPanels[name] = { parts: [], len: 0, done: false }
|
|
183
|
-
panel = state.outputPanels[name]
|
|
184
|
-
}
|
|
201
|
+
// All tools use inline conversation blocks — panel area is abolished.
|
|
202
|
+
// Stream up to 5 preview lines; the full result is in the tool message.
|
|
185
203
|
const part = typeof chunk === "string"
|
|
186
|
-
? { kind: "text", text: chunk }
|
|
187
|
-
: { kind: chunk?.kind ?? "text", text: String(chunk?.text ?? "") }
|
|
204
|
+
? { kind: "text", text: chunk.trimEnd() }
|
|
205
|
+
: { kind: chunk?.kind ?? "text", text: String(chunk?.text ?? "").trimEnd() }
|
|
188
206
|
if (!part.text) return
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
panel.len += part.text.length
|
|
197
|
-
// Cap at 4000 chars, trimming oldest parts first
|
|
198
|
-
while (panel.len > 4000 && panel.parts.length > 1) {
|
|
199
|
-
const first = panel.parts[0]
|
|
200
|
-
const excess = panel.len - 4000
|
|
201
|
-
if (first.text.length <= excess) {
|
|
202
|
-
panel.len -= first.text.length
|
|
203
|
-
panel.parts.shift()
|
|
207
|
+
if (name === "advisor") {
|
|
208
|
+
// Accumulate to buffer — formatTables + wrapText in render-conversation
|
|
209
|
+
// handles markdown formatting, same as main agent response.
|
|
210
|
+
const raw = typeof chunk === "string" ? chunk : String(chunk?.text ?? "")
|
|
211
|
+
const kind = typeof chunk === "string" ? "text" : (chunk?.kind ?? "text")
|
|
212
|
+
if (kind === "think") {
|
|
213
|
+
state._advisorThink = (state._advisorThink || "") + raw
|
|
204
214
|
} else {
|
|
205
|
-
|
|
206
|
-
|
|
215
|
+
state.advisorStreaming += raw
|
|
216
|
+
}
|
|
217
|
+
scheduleRender()
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
// Rolling output — show latest N lines with fold marker per tool.
|
|
221
|
+
// _live marker per tool enables per-tool pruning without affecting other content.
|
|
222
|
+
const color = ({ think: C.reason, tool: C.tool }[part.kind] ?? C.dim)
|
|
223
|
+
for (const line of part.text.split("\n")) {
|
|
224
|
+
const trimmed = line.trimEnd()
|
|
225
|
+
if (!trimmed) continue
|
|
226
|
+
state.lines.push({ text: `│ ${trimmed}`, color, _live: name })
|
|
227
|
+
}
|
|
228
|
+
// Prune: keep at most N lines + "│ …" fold marker per tool (configurable, tool-specific)
|
|
229
|
+
const configLimit = agent.config?.agent?.streamPreviewLines
|
|
230
|
+
const toolLimit = LIVE_LINE_LIMITS[name]
|
|
231
|
+
const previewLines = configLimit ?? toolLimit ?? 5
|
|
232
|
+
let count = 0
|
|
233
|
+
let hasFold = false
|
|
234
|
+
for (let i = state.lines.length - 1; i >= 0; i--) {
|
|
235
|
+
if (state.lines[i]._live === name) {
|
|
236
|
+
if (++count > previewLines) {
|
|
237
|
+
if (!hasFold) {
|
|
238
|
+
state.lines[i] = { text: "│ …", color: C.dim, _live: name }
|
|
239
|
+
hasFold = true; count = previewLines
|
|
240
|
+
} else {
|
|
241
|
+
state.lines.splice(i, 1)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
207
244
|
}
|
|
208
245
|
}
|
|
209
246
|
scheduleRender()
|
|
210
247
|
},
|
|
211
248
|
onPermissionRequest: (name, args) => askPermission(name, args),
|
|
212
249
|
onQuestion: (text, options) => askQuestion(text, options),
|
|
213
|
-
setupOutputPanel: (name) => {
|
|
214
|
-
state.outputPanels[name] = { parts: [], len: 0, done: false }
|
|
215
|
-
scheduleRender()
|
|
216
|
-
},
|
|
217
250
|
onCompress: () => {
|
|
218
251
|
pushLine(" [context] Context too long, auto-compacted (early conversation summarized by LLM, task state preserved)", C.warn)
|
|
219
252
|
},
|
|
@@ -243,6 +276,17 @@ export async function runAgentTurn(ctx, text) {
|
|
|
243
276
|
onTurnEnd: (() => {
|
|
244
277
|
let n = 0
|
|
245
278
|
return () => {
|
|
279
|
+
// Flush pending reasoning/streaming before the next turn starts.
|
|
280
|
+
// Guard pushbacks (verify/advisor) continue the agent loop without
|
|
281
|
+
// returning to the TUI — without flushing, old thinking bleeds into
|
|
282
|
+
// the next turn and the guard reminder is invisible.
|
|
283
|
+
flushStream()
|
|
284
|
+
// Mirror the last system-reminder from agent.history so guard
|
|
285
|
+
// pushback messages appear in the conversation at the right spot.
|
|
286
|
+
const last = agent.history.at(-1)
|
|
287
|
+
if (last?.role === "user" && typeof last.content === "string" && last.content.startsWith("[System reminder:")) {
|
|
288
|
+
pushLine(last.content, C.warn)
|
|
289
|
+
}
|
|
246
290
|
if (++n % 5 !== 0) return
|
|
247
291
|
try { saveSessionImpl(agent, state.lines) } catch (e) { console.error(`[session] incremental save failed: ${e.message}`) }
|
|
248
292
|
}
|
|
@@ -303,12 +347,29 @@ export async function runAgentTurn(ctx, text) {
|
|
|
303
347
|
clearInterval(ticker)
|
|
304
348
|
state.processing = false
|
|
305
349
|
state.subTasks = {}
|
|
350
|
+
state.advisorStreaming = ""
|
|
351
|
+
state._advisorThink = ""
|
|
306
352
|
state.controller = null
|
|
307
353
|
state.status = "Ready"
|
|
308
354
|
// Auto-collapse todo panel when all tasks done (matching kimi-code TUI; agent.tasks are preserved)
|
|
309
355
|
if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
|
|
310
356
|
state.tasks = []
|
|
311
357
|
}
|
|
358
|
+
// Auto-generate session title from the first user message (once per session)
|
|
359
|
+
if (!agent.title) {
|
|
360
|
+
try {
|
|
361
|
+
const { generateTitle } = await import("../generate-title.mjs")
|
|
362
|
+
const firstUser = (agent._fullHistory ?? agent.history).find(
|
|
363
|
+
(m) => m.role === "user" && typeof m.content === "string" && !m.content.startsWith("[System reminder:"),
|
|
364
|
+
)
|
|
365
|
+
if (firstUser) {
|
|
366
|
+
const title = await generateTitle(firstUser.content, agent.provider)
|
|
367
|
+
if (title) agent.title = title
|
|
368
|
+
}
|
|
369
|
+
} catch {
|
|
370
|
+
// Title generation failure is non-fatal
|
|
371
|
+
}
|
|
372
|
+
}
|
|
312
373
|
// Save session after every turn (survives crashes)
|
|
313
374
|
try {
|
|
314
375
|
saveSessionImpl(agent, state.lines)
|
|
@@ -333,15 +394,55 @@ export async function runAgentTurn(ctx, text) {
|
|
|
333
394
|
}
|
|
334
395
|
}
|
|
335
396
|
|
|
336
|
-
/** Extract a one-line summary from
|
|
337
|
-
function
|
|
397
|
+
/** Extract a one-line summary from tool output for the done line */
|
|
398
|
+
function formatToolSummary(name, result) {
|
|
338
399
|
if (name === "verify") return _verifySummary(result)
|
|
339
400
|
if (name === "bash") return _bashSummary(result)
|
|
401
|
+
if (name === "advisor") return _advisorSummary(result)
|
|
402
|
+
if (name === "read" || name === "read_file") return _readSummary(result)
|
|
403
|
+
if (name === "write" || name === "write_file") return _writeSummary(result)
|
|
404
|
+
if (name === "grep" || name === "search") return _grepSummary(result)
|
|
405
|
+
if (name === "glob") return _globSummary(result)
|
|
340
406
|
// Default: first non-empty line
|
|
341
407
|
const first = result.split("\n").find((l) => l.trim())
|
|
342
408
|
return first ? `${name}: ${first.slice(0, 100)}` : null
|
|
343
409
|
}
|
|
344
410
|
|
|
411
|
+
function _readSummary(result) {
|
|
412
|
+
const lines = result.split("\n")
|
|
413
|
+
// Look for line count in result
|
|
414
|
+
const countMatch = result.match(/(\d+) lines?/)
|
|
415
|
+
if (countMatch) return `${countMatch[1]} lines`
|
|
416
|
+
// Fallback: count actual lines
|
|
417
|
+
return `${lines.length} lines`
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function _writeSummary(result) {
|
|
421
|
+
// Extract file size or confirmation
|
|
422
|
+
if (result.includes("wrote") || result.includes("created")) {
|
|
423
|
+
const sizeMatch = result.match(/(\d+)(?:\s*(?:bytes?|chars?))/i)
|
|
424
|
+
return sizeMatch ? `wrote ${sizeMatch[1]} bytes` : "wrote file"
|
|
425
|
+
}
|
|
426
|
+
const first = result.split("\n").find((l) => l.trim())
|
|
427
|
+
return first ? first.slice(0, 80) : "wrote"
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function _grepSummary(result) {
|
|
431
|
+
const lines = result.split("\n").filter((l) => l.trim())
|
|
432
|
+
const count = lines.length
|
|
433
|
+
if (count === 0) return "no matches"
|
|
434
|
+
if (count === 1) return "1 match"
|
|
435
|
+
return `${count} matches`
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function _globSummary(result) {
|
|
439
|
+
const lines = result.split("\n").filter((l) => l.trim())
|
|
440
|
+
const count = lines.length
|
|
441
|
+
if (count === 0) return "no files"
|
|
442
|
+
if (count === 1) return "1 file"
|
|
443
|
+
return `${count} files`
|
|
444
|
+
}
|
|
445
|
+
|
|
345
446
|
/**
|
|
346
447
|
* bash result format: "[stdout]:\n<out>\n\n[stderr]:\n<err>\n\n(exit code 0)".
|
|
347
448
|
* The first non-empty line is always the "[stdout]:" marker — useless as a summary.
|
|
@@ -357,6 +458,23 @@ function _bashSummary(result) {
|
|
|
357
458
|
return parts.length > 0 ? `bash: ${parts.join(" ")}` : null
|
|
358
459
|
}
|
|
359
460
|
|
|
461
|
+
function _advisorSummary(result) {
|
|
462
|
+
const text = String(result ?? "")
|
|
463
|
+
if (/no 🔴|all.*(?:resolved|fixed|pass)/im.test(text)) return "advisor: passed"
|
|
464
|
+
// Error / skip messages — extract the reason after "Advisor:"
|
|
465
|
+
const errMatch = text.trimStart().match(/^Advisor:\s*(.+)/)
|
|
466
|
+
if (errMatch) return `advisor: ${errMatch[1].split(".")[0]}`
|
|
467
|
+
const critical = (text.match(/\| \d+ \|.*\| 🔴/g) || []).length
|
|
468
|
+
const advisory = (text.match(/\| \d+ \|.*\| 🟡/g) || []).length
|
|
469
|
+
const style = (text.match(/\| \d+ \|.*\| 🔵/g) || []).length
|
|
470
|
+
const parts = []
|
|
471
|
+
if (critical) parts.push(`${critical} critical`)
|
|
472
|
+
if (advisory) parts.push(`${advisory} advisory`)
|
|
473
|
+
if (style) parts.push(`${style} style`)
|
|
474
|
+
if (parts.length === 0) return null
|
|
475
|
+
return `advisor: ${parts.join(", ")}`
|
|
476
|
+
}
|
|
477
|
+
|
|
360
478
|
function _verifySummary(result) {
|
|
361
479
|
const lines = result.split("\n")
|
|
362
480
|
const summary = []
|
package/src/tui/ansi.mjs
CHANGED
|
@@ -13,6 +13,10 @@ export const ansi = {
|
|
|
13
13
|
mouseOff: `${ESC}[?1000l${ESC}[?1006l`,
|
|
14
14
|
bracketedPasteOn: `${ESC}[?2004h`,
|
|
15
15
|
bracketedPasteOff: `${ESC}[?2004l`,
|
|
16
|
+
keyboardPush: `${ESC}[>1u`, // kitty keyboard protocol: push disambiguate mode (Shift+Enter → CSI-u)
|
|
17
|
+
keyboardPop: `${ESC}[<u`, // pop keyboard mode (restore terminal defaults on exit)
|
|
18
|
+
modifyOtherKeysOn: `${ESC}[>4;2m`, // xterm modifyOtherKeys level 2 (Shift+Enter → \x1b[27;2;13~), mintty/Git Bash path
|
|
19
|
+
modifyOtherKeysOff: `${ESC}[>4m`, // reset modifyOtherKeys
|
|
16
20
|
home: `${ESC}[H`,
|
|
17
21
|
clearLine: `${ESC}[K`,
|
|
18
22
|
clearToEnd: `${ESC}[J`,
|
package/src/tui/clipboard.mjs
CHANGED
|
@@ -38,6 +38,15 @@ export function insertPastedText(state, rawText) {
|
|
|
38
38
|
state.cursor += chars.length
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** Translate Shift+Enter sequences from keyboard-enhanced terminals into the Alt+Enter path.
|
|
42
|
+
* kitty/CSI-u: \x1b[13;2u; xterm modifyOtherKeys: \x1b[27;2;13~. Both become \x1b\r,
|
|
43
|
+
* which readline parses reliably as meta+return (the multiline branch in key-handler).
|
|
44
|
+
* Terminals without enhancement send a bare \r for Shift+Enter — nothing to translate
|
|
45
|
+
* (degrades to a normal submit; Alt+Enter remains the fallback). */
|
|
46
|
+
export function translateShiftEnter(text) {
|
|
47
|
+
return text.replace(/\x1b\[13;2u/g, "\x1b\r").replace(/\x1b\[27;2;13~/g, "\x1b\r")
|
|
48
|
+
}
|
|
49
|
+
|
|
41
50
|
/** Ctrl+V / Alt+V: read clipboard image → write temp file in working directory → insert read_image command into input box.
|
|
42
51
|
* Extracted from index.mjs.
|
|
43
52
|
* ctx: { agent, state, pushLine, render } */
|
package/src/tui/cmd-config.mjs
CHANGED
|
@@ -38,6 +38,7 @@ export async function handleConfigCommand(ctx, args = []) {
|
|
|
38
38
|
const cfg = loadConfig()
|
|
39
39
|
injectProxy(cfg.providersList, cfg)
|
|
40
40
|
const runtimeName = agent.activeProvider
|
|
41
|
+
const runtimeModel = agent.activeModel
|
|
41
42
|
agent.providers = cfg.providersList
|
|
42
43
|
agent.config = cfg
|
|
43
44
|
agent.config.agent ??= {}
|
|
@@ -45,9 +46,20 @@ export async function handleConfigCommand(ctx, args = []) {
|
|
|
45
46
|
if (runtimeName && runtimeName !== cfg.activeProvider && keep) {
|
|
46
47
|
// 运行时选择在新配置里仍存在 → 保持(provider 为注入 proxyUri 后的新对象)
|
|
47
48
|
agent.activeProvider = runtimeName
|
|
49
|
+
agent.activeModel = runtimeModel
|
|
48
50
|
agent.provider = { ...keep }
|
|
51
|
+
if (agent.activeModel) agent.provider.model = agent.activeModel
|
|
52
|
+
} else if (runtimeName && runtimeName === cfg.activeProvider && runtimeModel) {
|
|
53
|
+
// Same provider, runtime had a model override — keep it
|
|
54
|
+
agent.activeProvider = cfg.activeProvider
|
|
55
|
+
agent.activeModel = runtimeModel
|
|
56
|
+
const p = cfg.providersList.find((pr) => pr.name === cfg.activeProvider)
|
|
57
|
+
agent.provider = p ? { ...p } : cfg.provider
|
|
58
|
+
agent.provider.model = runtimeModel
|
|
59
|
+
agent.provider.proxyUri = p?.proxyUri
|
|
49
60
|
} else {
|
|
50
61
|
agent.activeProvider = cfg.activeProvider
|
|
62
|
+
agent.activeModel = cfg.activeModel ?? null
|
|
51
63
|
agent.provider = cfg.provider
|
|
52
64
|
agent.provider.proxyUri = cfg.providersList.find((p) => p.name === cfg.activeProvider)?.proxyUri
|
|
53
65
|
}
|
|
@@ -141,7 +153,6 @@ export async function handleConfigCommand(ctx, args = []) {
|
|
|
141
153
|
{ type: "item", text: `agent.compactThreshold = ${ac.compactThreshold ?? 100000}${agent.config?.agent?.compactThresholdAuto ? " (auto)" : ""}`, action: "agent.compactThreshold" },
|
|
142
154
|
{ type: "item", text: `agent.verifyGuard = ${ac.verifyGuard === true ? "on" : "off"}`, action: "agent.verifyGuard" },
|
|
143
155
|
{ type: "item", text: "Set embedding API key", action: "embedkey" },
|
|
144
|
-
{ type: "item", text: `embedding.model = ${ec.model ?? "BAAI/bge-m3"}`, action: "embedding.model" },
|
|
145
156
|
{ type: "item", text: `proxy = ${proxySummary()}`, action: "proxy" },
|
|
146
157
|
{ type: "item", text: "View full config", action: "view" },
|
|
147
158
|
]
|
|
@@ -189,31 +200,8 @@ export async function handleConfigCommand(ctx, args = []) {
|
|
|
189
200
|
continue
|
|
190
201
|
}
|
|
191
202
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
{ label: "BAAI/bge-m3 (multilingual, 1024d)", value: "BAAI/bge-m3" },
|
|
195
|
-
{ label: "BAAI/bge-large-zh-v1.5 (Chinese, 1024d)", value: "BAAI/bge-large-zh-v1.5" },
|
|
196
|
-
{ label: "BAAI/bge-large-en-v1.5 (English, 1024d)", value: "BAAI/bge-large-en-v1.5" },
|
|
197
|
-
{ label: "text-embedding-3-small (OpenAI, 1536d)", value: "text-embedding-3-small" },
|
|
198
|
-
{ label: "text-embedding-3-large (OpenAI, 3072d)", value: "text-embedding-3-large" },
|
|
199
|
-
]
|
|
200
|
-
const currentVal = ec.model ?? "BAAI/bge-m3"
|
|
201
|
-
const modelChoice = await showPicker("Embedding Model", [
|
|
202
|
-
{ type: "header", text: `Current: ${currentVal}` },
|
|
203
|
-
...models.map(m => ({ type: "item", text: m.label, action: m.value })),
|
|
204
|
-
])
|
|
205
|
-
if (!modelChoice) continue
|
|
206
|
-
try {
|
|
207
|
-
await saveProxy((raw) => {
|
|
208
|
-
raw.embedding ??= {}
|
|
209
|
-
raw.embedding.model = modelChoice.action
|
|
210
|
-
})
|
|
211
|
-
pushLabel("❯ Config", ansi.bold + C.tool)
|
|
212
|
-
pushLine(`embedding.model = ${modelChoice.action}`, C.tool)
|
|
213
|
-
running = false
|
|
214
|
-
} catch (error) { pushLine(`Save failed: ${error.message}`, C.error) }
|
|
215
|
-
continue
|
|
216
|
-
}
|
|
203
|
+
// Embedding model is fixed (BAAI/bge-m3, SiliconFlow) — no picker; it's over-engineering
|
|
204
|
+
// to expose model choice when the vector index format assumes one embedding space.
|
|
217
205
|
|
|
218
206
|
// Numeric config items
|
|
219
207
|
const label = choice.action
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** /eng command: toggle engineering mode.
|
|
2
|
+
* Requires METHODOLOGY.md in project root. Offers to create one if missing.
|
|
3
|
+
* ctx: { agent, pushLine, pushLabel, persistRaw, showPicker } */
|
|
4
|
+
import { existsSync, copyFileSync } from "node:fs"
|
|
5
|
+
import { join } from "node:path"
|
|
6
|
+
import { fileURLToPath } from "node:url"
|
|
7
|
+
import { ansi, C } from "./ansi.mjs"
|
|
8
|
+
|
|
9
|
+
const templateDir = join(fileURLToPath(import.meta.url), "..", "..", "prompts")
|
|
10
|
+
|
|
11
|
+
export async function handleEngCommand(ctx) {
|
|
12
|
+
const { agent, pushLine, pushLabel, persistRaw, showPicker } = ctx
|
|
13
|
+
agent.config.agent ??= {}
|
|
14
|
+
const methodologyPath = join(agent.cwd, "METHODOLOGY.md")
|
|
15
|
+
|
|
16
|
+
// Toggle on: check METHODOLOGY.md exists
|
|
17
|
+
if (!agent.config.agent.engineering) {
|
|
18
|
+
if (!existsSync(methodologyPath)) {
|
|
19
|
+
pushLabel("❯ Eng", ansi.bold + C.tool)
|
|
20
|
+
pushLine("METHODOLOGY.md not found in project root.", C.warn)
|
|
21
|
+
const choice = await showPicker("Create METHODOLOGY.md?", [
|
|
22
|
+
{ type: "header", text: "Engineering mode requires a methodology file" },
|
|
23
|
+
{ type: "item", text: "Yes, create from template", action: "create" },
|
|
24
|
+
{ type: "item", text: "No, cancel", action: "cancel" },
|
|
25
|
+
])
|
|
26
|
+
if (!choice || choice.action !== "create") return
|
|
27
|
+
const src = join(templateDir, "methodology-template.md")
|
|
28
|
+
copyFileSync(src, methodologyPath)
|
|
29
|
+
pushLine(`Created METHODOLOGY.md (from template) → edit it to fit your project`, C.tool)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
agent.config.agent.engineering = !agent.config.agent.engineering
|
|
34
|
+
if (!agent.config.agent.engineering) agent._engDesignToken = null // invalidate stale token
|
|
35
|
+
await persistRaw((raw) => {
|
|
36
|
+
raw.agent ??= {}
|
|
37
|
+
raw.agent.engineering = agent.config.agent.engineering
|
|
38
|
+
})
|
|
39
|
+
pushLabel("❯ Eng", ansi.bold + C.tool)
|
|
40
|
+
pushLine(`Engineering mode: ${agent.config.agent.engineering ? "ON" : "OFF"}`, C.tool)
|
|
41
|
+
if (agent.config.agent.engineering) {
|
|
42
|
+
pushLine(` → strictly following ${methodologyPath}`, C.dim)
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/tui/cmd-exit.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** /exit command: exit TUI (same path as Ctrl+C).
|
|
2
2
|
* Direct synchronous process.exit — prevents the post-handler render()
|
|
3
3
|
* from redrawing the TUI over the cleaned terminal. The actual cleanup
|
|
4
|
-
* (
|
|
4
|
+
* (saveSession + closeMcp + terminal reset) runs once
|
|
5
5
|
* via the process.on("exit") handler registered in index.mjs.
|
|
6
6
|
*/
|
|
7
7
|
export async function handleExitCommand(_ctx) {
|
package/src/tui/cmd-fold.mjs
CHANGED
|
@@ -4,17 +4,16 @@
|
|
|
4
4
|
import { C } from "./ansi.mjs"
|
|
5
5
|
|
|
6
6
|
export async function handleFoldCommand(ctx, args = []) {
|
|
7
|
-
const { state } = ctx
|
|
7
|
+
const { state, pushLabel } = ctx
|
|
8
8
|
const arg = args[0]?.toLowerCase()
|
|
9
9
|
if (arg === "on") {
|
|
10
10
|
state.foldEnabled = true
|
|
11
|
-
ctx.pushLine("Folding: on (long tool results are collapsed)", C.dim)
|
|
12
11
|
} else if (arg === "off") {
|
|
13
12
|
state.foldEnabled = false
|
|
14
|
-
ctx.pushLine("Folding: off (all results shown in full)", C.dim)
|
|
15
13
|
} else {
|
|
16
14
|
state.foldEnabled = !state.foldEnabled
|
|
17
|
-
ctx.pushLine(`Folding: ${state.foldEnabled ? "on" : "off"}`, C.dim)
|
|
18
15
|
}
|
|
16
|
+
pushLabel("❯ Fold", C.bold + C.tool)
|
|
17
|
+
ctx.pushLine(`Folding: ${state.foldEnabled ? "on" : "off"}`, C.tool)
|
|
19
18
|
ctx.render()
|
|
20
19
|
}
|
package/src/tui/cmd-model.mjs
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import { C } from "./ansi.mjs"
|
|
2
2
|
|
|
3
|
-
/** /model command: open model picker, or switch provider directly via `/model <provider
|
|
3
|
+
/** /model command: open model picker, or switch provider directly via `/model <provider>[:model]`.
|
|
4
4
|
* ctx: { agent, openModelPicker, selectModel, pushLine } */
|
|
5
5
|
export async function handleModelCommand(ctx, args = []) {
|
|
6
|
-
const
|
|
7
|
-
if (!
|
|
6
|
+
const raw = args[0]?.toLowerCase()
|
|
7
|
+
if (!raw) {
|
|
8
8
|
ctx.openModelPicker().catch((e) => ctx.pushLine(`[error] ${e.message}`, C.error))
|
|
9
9
|
return
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
// Parse "provider:model" syntax
|
|
12
|
+
const colonIdx = raw.indexOf(":")
|
|
13
|
+
const providerName = colonIdx >= 0 ? raw.slice(0, colonIdx) : raw
|
|
14
|
+
const modelName = colonIdx >= 0 ? raw.slice(colonIdx + 1) : null
|
|
15
|
+
|
|
16
|
+
const target = ctx.agent.providers.find((p) => p.name.toLowerCase() === providerName)
|
|
12
17
|
if (!target) {
|
|
13
18
|
const available = ctx.agent.providers.map((p) => p.name).join(", ")
|
|
14
|
-
ctx.pushLine(`Unknown provider: ${
|
|
19
|
+
ctx.pushLine(`Unknown provider: ${providerName} (available: ${available})`, C.error)
|
|
15
20
|
return
|
|
16
21
|
}
|
|
17
|
-
await ctx.selectModel({ provider: target.name, model: target.model }).catch((e) => ctx.pushLine(`[error] ${e.message}`, C.error))
|
|
22
|
+
await ctx.selectModel({ provider: target.name, model: modelName || target.model }).catch((e) => ctx.pushLine(`[error] ${e.message}`, C.error))
|
|
18
23
|
}
|
package/src/tui/cmd-new.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { newSession } from "../session.mjs"
|
|
2
2
|
import { C } from "./ansi.mjs"
|
|
3
3
|
|
|
4
|
-
/** /new command: start new session
|
|
4
|
+
/** /new command: start a new session in a fresh slot.
|
|
5
5
|
* ctx: { agent, state, pushLine, showPicker, render } */
|
|
6
6
|
export async function handleNewCommand(ctx) {
|
|
7
7
|
const { agent, state, pushLine, showPicker, render } = ctx
|
|
8
8
|
|
|
9
9
|
const doNewSession = () => {
|
|
10
|
+
const slot = newSession(agent.cwd)
|
|
10
11
|
agent.history = []
|
|
11
12
|
agent.tasks = []
|
|
12
13
|
agent.planMode = false
|
|
@@ -15,14 +16,13 @@ export async function handleNewCommand(ctx) {
|
|
|
15
16
|
state.tasks = []
|
|
16
17
|
state.lines = []
|
|
17
18
|
state.streaming = ""
|
|
18
|
-
clearSession(agent.cwd)
|
|
19
19
|
render()
|
|
20
|
-
pushLine(
|
|
20
|
+
pushLine(`New session started (slot ${slot}; /session to switch back)`, C.dim)
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
if (agent.history.length > 0) {
|
|
24
24
|
const e = await showPicker("Start new session?", [
|
|
25
|
-
{ type: "item", text: "Yes,
|
|
25
|
+
{ type: "item", text: "Yes, start new session in a new slot", action: "yes" },
|
|
26
26
|
{ type: "item", text: "Cancel", action: "no" },
|
|
27
27
|
], { defaultIndex: 1 })
|
|
28
28
|
if (e?.action === "yes") doNewSession()
|
package/src/tui/cmd-session.mjs
CHANGED
|
@@ -1,22 +1,34 @@
|
|
|
1
1
|
import { listSlots, switchToSlot, applySession } from "../session.mjs"
|
|
2
2
|
import { ansi, C } from "./ansi.mjs"
|
|
3
3
|
|
|
4
|
-
/** /session command: list/switch
|
|
4
|
+
/** /session command: list/switch session slots.
|
|
5
5
|
* ctx: { agent, state, showPicker, pushLine, pushLabel, render } */
|
|
6
6
|
export async function handleSessionCommand(ctx) {
|
|
7
7
|
const { agent, state, showPicker, pushLine, pushLabel, render } = ctx
|
|
8
8
|
const slots = listSlots(agent.cwd)
|
|
9
9
|
if (slots.length === 0) {
|
|
10
|
-
pushLine("No
|
|
10
|
+
pushLine("No sessions (use /new to start a new session)", C.dim)
|
|
11
11
|
return
|
|
12
12
|
}
|
|
13
|
+
const shortDate = (d) => {
|
|
14
|
+
const dt = new Date(d)
|
|
15
|
+
return `${dt.getMonth() + 1}/${dt.getDate()} ${String(dt.getHours()).padStart(2, "0")}:${String(dt.getMinutes()).padStart(2, "0")}`
|
|
16
|
+
}
|
|
17
|
+
const truncate = (s, n) => s.length <= n ? s : s.slice(0, n - 1) + "…"
|
|
13
18
|
const entries = [
|
|
14
|
-
{ type: "header", text: `
|
|
15
|
-
...slots.map((s) =>
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
{ type: "header", text: `Sessions (● = active, ↑↓ select, Enter switch, Esc cancel)` },
|
|
20
|
+
...slots.map((s) => {
|
|
21
|
+
const label = s.title || (s.firstMessage ? `"${truncate(s.firstMessage, 40)}"` : "(empty)")
|
|
22
|
+
const turns = s.turnCount > 0 ? `${s.turnCount} turns` : "0 turns"
|
|
23
|
+
const when = shortDate(s.updatedAt)
|
|
24
|
+
const model = s.activeProvider ? ` — ${s.activeProvider}` : ""
|
|
25
|
+
const marker = s.isActive ? " ●" : ""
|
|
26
|
+
return {
|
|
27
|
+
type: "item",
|
|
28
|
+
text: `Slot ${s.slot} │ ${turns} │ ${when} │ ${label}${model}${marker}`,
|
|
29
|
+
slot: s.slot,
|
|
30
|
+
}
|
|
31
|
+
}),
|
|
20
32
|
]
|
|
21
33
|
const e = await showPicker("Sessions", entries)
|
|
22
34
|
if (!e) return
|
|
@@ -26,9 +38,7 @@ export async function handleSessionCommand(ctx) {
|
|
|
26
38
|
return
|
|
27
39
|
}
|
|
28
40
|
applySession(agent, data)
|
|
29
|
-
state.lines = data.display.length
|
|
30
|
-
? data.display.map((l) => ({ text: l.text, color: l.color }))
|
|
31
|
-
: []
|
|
41
|
+
state.lines = data.display.length ? [...data.display] : []
|
|
32
42
|
state.tasks = agent.tasks ?? []
|
|
33
43
|
if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
|
|
34
44
|
state.tasks = []
|
package/src/tui/cmd-think.mjs
CHANGED
|
@@ -74,6 +74,7 @@ export async function handleThinkCommand(ctx, args = []) {
|
|
|
74
74
|
if (e.action === "auto") {
|
|
75
75
|
const newAuto = agent.config?.agent?.autoThink === true
|
|
76
76
|
pushLine(`Auto-think: ${newAuto ? "ON" : "OFF"}`, C.tool)
|
|
77
|
+
return // exit loop — no useful actions remain when auto mode just changed
|
|
77
78
|
} else if (e.action === "effort") {
|
|
78
79
|
pushLine(`Reasoning effort: ${e.level}`, C.tool)
|
|
79
80
|
} else {
|