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.
Files changed (85) hide show
  1. package/README.md +29 -6
  2. package/package.json +3 -3
  3. package/src/advisor/history.mjs +112 -0
  4. package/src/advisor/messages.mjs +182 -0
  5. package/src/advisor/repos.mjs +133 -0
  6. package/src/advisor/run.mjs +346 -0
  7. package/src/advisor.mjs +109 -509
  8. package/src/agent/completion.mjs +134 -0
  9. package/src/agent/dispatch.mjs +54 -7
  10. package/src/agent/post-turn.mjs +70 -0
  11. package/src/agent/setup.mjs +95 -6
  12. package/src/agent-tools/advisor.mjs +159 -12
  13. package/src/agent-tools/eng.mjs +64 -0
  14. package/src/agent-tools/subagent.mjs +73 -3
  15. package/src/agent-tools/task.mjs +45 -6
  16. package/src/agent-tools/verify.mjs +18 -0
  17. package/src/agent-tools.mjs +1 -0
  18. package/src/agent.mjs +152 -161
  19. package/src/cli/make-agent.mjs +1 -0
  20. package/src/cli/setup-wizard.mjs +1 -0
  21. package/src/config.mjs +34 -4
  22. package/src/context.mjs +47 -13
  23. package/src/generate-title.mjs +44 -0
  24. package/src/prompts/advisor-design.md +43 -0
  25. package/src/prompts/advisor-round1.md +11 -4
  26. package/src/prompts/advisor-round2.md +12 -7
  27. package/src/prompts/advisor-round3.md +11 -6
  28. package/src/prompts/coder.md +9 -3
  29. package/src/prompts/discipline.md +12 -96
  30. package/src/prompts/eng-coder.md +34 -0
  31. package/src/prompts/engineering-sub.md +12 -0
  32. package/src/prompts/engineering.md +96 -0
  33. package/src/prompts/main.md +1 -1
  34. package/src/prompts/methodology-template.md +39 -0
  35. package/src/prompts/plan.md +2 -2
  36. package/src/prompts/system.md +43 -61
  37. package/src/provider/core.mjs +58 -2
  38. package/src/session.mjs +291 -94
  39. package/src/skills.mjs +48 -15
  40. package/src/tools/apply_patch.md +1 -1
  41. package/src/tools/checklist.mjs +4 -3
  42. package/src/tools/codemode.mjs +23 -11
  43. package/src/tools/delete.md +1 -0
  44. package/src/tools/edit.md +1 -1
  45. package/src/tools/execute.md +5 -0
  46. package/src/tools/file.mjs +4 -0
  47. package/src/tools/git.md +15 -0
  48. package/src/tools/git.mjs +1 -6
  49. package/src/tools/lint.md +8 -0
  50. package/src/tools/linter.mjs +1 -5
  51. package/src/tools/lsp.md +7 -0
  52. package/src/tools/lsp.mjs +8 -9
  53. package/src/tools/patch.mjs +1 -29
  54. package/src/tools/read_image.md +5 -1
  55. package/src/tools/system.mjs +1 -1
  56. package/src/tools/web.mjs +3 -3
  57. package/src/tui/agent-turn.mjs +184 -66
  58. package/src/tui/ansi.mjs +4 -0
  59. package/src/tui/clipboard.mjs +9 -0
  60. package/src/tui/cmd-config.mjs +14 -26
  61. package/src/tui/cmd-eng.mjs +44 -0
  62. package/src/tui/cmd-exit.mjs +1 -1
  63. package/src/tui/cmd-fold.mjs +3 -4
  64. package/src/tui/cmd-model.mjs +11 -6
  65. package/src/tui/cmd-new.mjs +5 -5
  66. package/src/tui/cmd-session.mjs +21 -11
  67. package/src/tui/cmd-think.mjs +1 -0
  68. package/src/tui/index.mjs +20 -9
  69. package/src/tui/key-handler.mjs +177 -9
  70. package/src/tui/layout.mjs +5 -5
  71. package/src/tui/markdown.mjs +52 -0
  72. package/src/tui/pickers.mjs +190 -45
  73. package/src/tui/render-conversation.mjs +54 -13
  74. package/src/tui/render-frame.mjs +39 -12
  75. package/src/tui/render-loop.mjs +2 -1
  76. package/src/tui/render.mjs +13 -7
  77. package/src/tui/slash-commands.mjs +11 -7
  78. package/src/tui/startup.mjs +4 -3
  79. package/src/tui/wizard.mjs +3 -0
  80. package/src/tools/checkpoint.md +0 -15
  81. package/src/tools/git_diff.md +0 -11
  82. package/src/tools/git_log.md +0 -10
  83. package/src/tools/git_status.md +0 -8
  84. package/src/tools/linter.md +0 -13
  85. package/src/tools/syntax_check.md +0 -10
@@ -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
- pushLine(` [tool] ${name}${roundTag}${argSummary ? ` ${argSummary}` : ""}`, C.tool)
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
- const stream = state.toolStreams[name]
141
- const panel = state.outputPanels[name]
142
- if (panel) {
143
- delete state.toolStreams[name]
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
- // Trigger a repaint after the grace period so the pruned panel disappears
163
- setTimeout(() => render(), 3000)
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 (!isSubagent && !panel) {
170
- const first = result.split("\n")[0]
171
- pushLine(` [done] ${name} → ${sliceByWidth(first, 100)}`, C.dim)
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
- // Route streaming output to a panel if one exists or was requested via outputPanel flag.
176
- // Chunk may be a string or { kind, text } kind ("think" | "text" | "tool") drives
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
- // Separate phase transitions with a newline — think → answer → tool progress
190
- // would otherwise glue onto each other mid-line.
191
- const last = panel.parts[panel.parts.length - 1]
192
- if (last && last.kind !== part.kind && !last.text.endsWith("\n") && !part.text.startsWith("\n")) {
193
- part.text = "\n" + part.text
194
- }
195
- panel.parts.push(part)
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
- first.text = first.text.slice(excess)
206
- panel.len -= excess
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 a panel tool's output */
337
- function formatPanelSummary(name, result) {
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`,
@@ -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 } */
@@ -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
- if (choice.action === "embedding.model") {
193
- const models = [
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
+ }
@@ -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
- * (archiveCurrent + saveSession + closeMcp + terminal reset) runs once
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) {
@@ -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
  }
@@ -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 name = args[0]?.toLowerCase()
7
- if (!name) {
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
- const target = ctx.agent.providers.find((p) => p.name.toLowerCase() === name)
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: ${args[0]} (available: ${available})`, C.error)
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
  }
@@ -1,12 +1,13 @@
1
- import { clearSession } from "../session.mjs"
1
+ import { newSession } from "../session.mjs"
2
2
  import { C } from "./ansi.mjs"
3
3
 
4
- /** /new command: start new session (old session archived to slot).
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("New session started (old session archived to slot; /session to view)", C.dim)
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, archive current and start new", action: "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()
@@ -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 archived session slots.
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 archived sessions (use /new and old sessions auto-archive to slots)", C.dim)
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: `Archived sessions (↑↓ select, Enter switch, Esc cancel)` },
15
- ...slots.map((s) => ({
16
- type: "item",
17
- text: `Slot ${s.slot} ${s.date}`,
18
- slot: s.slot,
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 = []
@@ -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 {