thincoder 0.12.1 → 0.12.3

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 (77) hide show
  1. package/README.md +18 -5
  2. package/package.json +1 -1
  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 +119 -0
  9. package/src/agent/dispatch.mjs +54 -7
  10. package/src/agent/post-turn.mjs +70 -0
  11. package/src/agent/setup.mjs +93 -5
  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 +110 -150
  19. package/src/cli/make-agent.mjs +1 -0
  20. package/src/cli/setup-wizard.mjs +1 -0
  21. package/src/config.mjs +22 -4
  22. package/src/prompts/advisor-design.md +43 -0
  23. package/src/prompts/advisor-round1.md +11 -4
  24. package/src/prompts/advisor-round2.md +12 -7
  25. package/src/prompts/advisor-round3.md +11 -6
  26. package/src/prompts/coder.md +9 -3
  27. package/src/prompts/discipline.md +12 -96
  28. package/src/prompts/eng-coder.md +34 -0
  29. package/src/prompts/engineering-sub.md +12 -0
  30. package/src/prompts/engineering.md +96 -0
  31. package/src/prompts/main.md +1 -1
  32. package/src/prompts/methodology-template.md +39 -0
  33. package/src/prompts/plan.md +2 -2
  34. package/src/prompts/system.md +43 -61
  35. package/src/session.mjs +270 -89
  36. package/src/skills.mjs +48 -15
  37. package/src/tools/apply_patch.md +1 -1
  38. package/src/tools/codemode.mjs +10 -4
  39. package/src/tools/delete.md +1 -0
  40. package/src/tools/edit.md +1 -1
  41. package/src/tools/execute.md +5 -0
  42. package/src/tools/file.mjs +4 -0
  43. package/src/tools/git.md +15 -0
  44. package/src/tools/git.mjs +1 -6
  45. package/src/tools/lint.md +8 -0
  46. package/src/tools/linter.mjs +1 -5
  47. package/src/tools/lsp.md +7 -0
  48. package/src/tools/lsp.mjs +8 -9
  49. package/src/tools/patch.mjs +1 -29
  50. package/src/tools/read_image.md +5 -1
  51. package/src/tools/system.mjs +1 -1
  52. package/src/tools/web.mjs +3 -3
  53. package/src/tui/agent-turn.mjs +169 -66
  54. package/src/tui/cmd-config.mjs +12 -0
  55. package/src/tui/cmd-eng.mjs +44 -0
  56. package/src/tui/cmd-exit.mjs +6 -16
  57. package/src/tui/cmd-fold.mjs +3 -4
  58. package/src/tui/cmd-model.mjs +11 -6
  59. package/src/tui/cmd-new.mjs +5 -5
  60. package/src/tui/cmd-session.mjs +21 -11
  61. package/src/tui/cmd-think.mjs +1 -0
  62. package/src/tui/index.mjs +7 -6
  63. package/src/tui/key-handler.mjs +132 -4
  64. package/src/tui/layout.mjs +5 -5
  65. package/src/tui/pickers.mjs +184 -44
  66. package/src/tui/render-conversation.mjs +49 -11
  67. package/src/tui/render-frame.mjs +38 -12
  68. package/src/tui/render-loop.mjs +2 -1
  69. package/src/tui/slash-commands.mjs +11 -7
  70. package/src/tui/startup.mjs +4 -3
  71. package/src/tui/wizard.mjs +3 -0
  72. package/src/tools/checkpoint.md +0 -15
  73. package/src/tools/git_diff.md +0 -11
  74. package/src/tools/git_log.md +0 -10
  75. package/src/tools/git_status.md +0 -8
  76. package/src/tools/linter.md +0 -13
  77. package/src/tools/syntax_check.md +0 -10
@@ -122,6 +122,7 @@ export const writeTool = {
122
122
  required: ["path", "content"],
123
123
  },
124
124
  readonly: false,
125
+ touchedPaths(args) { return args.path ? [args.path] : [] },
125
126
  async execute(args, ctx) {
126
127
  const abs = resolveInCwd(ctx, args.path)
127
128
  await mkdir(dirname(abs), { recursive: true })
@@ -149,6 +150,7 @@ export const editTool = {
149
150
  required: ["path", "old_string", "new_string"],
150
151
  },
151
152
  readonly: false,
153
+ touchedPaths(args) { return args.path ? [args.path] : [] },
152
154
  async execute(args, ctx) {
153
155
  const abs = resolveInCwd(ctx, args.path)
154
156
  if (!args.old_string) {
@@ -194,6 +196,7 @@ export const insertAfterTool = {
194
196
  required: ["path", "content"],
195
197
  },
196
198
  readonly: false,
199
+ touchedPaths(args) { return args.path ? [args.path] : [] },
197
200
  async execute(args, ctx) {
198
201
  const abs = resolveInCwd(ctx, args.path)
199
202
  const text = normalizeEOL(await readFile(abs, "utf8"))
@@ -257,6 +260,7 @@ export const hashlineEditTool = {
257
260
  required: ["path", "old_hashes", "new_content"],
258
261
  },
259
262
  readonly: false,
263
+ touchedPaths(args) { return args.path ? [args.path] : [] },
260
264
  async execute(args, ctx) {
261
265
  const abs = resolveInCwd(ctx, args.path)
262
266
  if (!args.old_hashes?.length) throw new Error("old_hashes must not be empty — read the file with hashes=true to get line hashes")
@@ -0,0 +1,15 @@
1
+ Run a git command. Use this to see uncommitted changes, staged changes, diff against a ref, recent commits, or manage checkpoints. Only works inside a git repository.
2
+ - action='diff': Show unified diff — what changed since last commit. Set staged=true for staged-only diff, ref=<ref> to compare against a specific commit/branch, path=<dir> to scope to a file or directory.
3
+ - action='status': Show working tree state — staged, unstaged, untracked files, and conflicts. Returns categorized lists.
4
+ - action='log': Show recent commit history. Set count to limit, oneline=true for compact format, path=<file> to see history of one file.
5
+ - action='checkpoint': Manage git-based snapshots. Use checkpointAction to choose: list (overview), create (snapshot now), rewind (restore snapshot by id), cat (read a file from a snapshot).
6
+
7
+ Parameters:
8
+ - action (required): diff / status / log / checkpoint
9
+ - staged: (diff) Show staged changes instead of working tree
10
+ - path: (diff/log/checkpoint:cat/checkpoint:rewind) File or directory to scope to
11
+ - ref: (diff) Compare against this ref (default HEAD)
12
+ - count: (log) Number of commits (default 10)
13
+ - oneline: (log) One-line-per-commit format
14
+ - checkpointAction: (checkpoint) list snapshots / create one / restore by id / read file from snapshot
15
+ - checkpointId: (checkpoint) Snapshot id — required for rewind and cat; optional for list (shows file tree)
package/src/tools/git.mjs CHANGED
@@ -9,12 +9,7 @@ import { join } from "node:path";
9
9
 
10
10
  export const gitTool = {
11
11
  name: "git",
12
- description:
13
- "Run a git command. Use this to see uncommitted changes, staged changes, diff against a ref, recent commits, or manage checkpoints. Only works inside a git repository.\n" +
14
- "- action='diff': Show unified diff — what changed since last commit. Set staged=true for staged-only diff, ref=<ref> to compare against a specific commit/branch, path=<dir> to scope to a file or directory.\n" +
15
- "- action='status': Show working tree state — staged, unstaged, untracked files, and conflicts. Returns categorized lists.\n" +
16
- "- action='log': Show recent commit history. Set count to limit, oneline=true for compact format, path=<file> to see history of one file.\n" +
17
- "- action='checkpoint': Manage git-based snapshots. Use checkpointAction to choose: list (overview), create (snapshot now), rewind (restore snapshot by id), cat (read a file from a snapshot).",
12
+ description: DESC("git"),
18
13
  parameters: {
19
14
  type: "object",
20
15
  properties: {
@@ -0,0 +1,8 @@
1
+ Run the appropriate linter/checker for a file. Auto-detects based on file extension and project config.
2
+ Without 'full', runs a fast node --check (JS/TS syntax only, catches parse errors in milliseconds).
3
+ With 'full', runs the language-aware cascade: eslint → tsc –noEmit → node --check (JS/TS/TSX); ruff (Python); cargo check (Rust); go vet (Go).
4
+ Use the fast default after every write/edit; use 'full' before declaring a task complete.
5
+
6
+ Parameters:
7
+ - path: File to check (default: most recently modified file)
8
+ - full: Run the full language-aware cascade instead of just node --check (default false)
@@ -5,11 +5,7 @@ import { join, relative } from "node:path"
5
5
 
6
6
  export const lintTool = {
7
7
  name: "lint",
8
- description:
9
- "Run the appropriate linter/checker for a file. Auto-detects based on file extension and project config.\n" +
10
- "Without 'full', runs a fast node --check (JS/TS syntax only, catches parse errors in milliseconds).\n" +
11
- "With 'full', runs the language-aware cascade: eslint → tsc –noEmit → node --check (JS/TS/TSX); ruff (Python); cargo check (Rust); go vet (Go).\n" +
12
- "Use the fast default after every write/edit; use 'full' before declaring a task complete.",
8
+ description: DESC("lint"),
13
9
  parameters: {
14
10
  type: "object",
15
11
  properties: {
@@ -0,0 +1,7 @@
1
+ LSP code intelligence: go to definition, find references, hover info, document symbols, diagnostics. Use this to understand code structure without grep-guessing function locations or type shapes.
2
+
3
+ Parameters:
4
+ - subcommand (required): LSP operation — "definition" | "references" | "hover" | "symbols" | "diagnostics"
5
+ - uri (required): Target file path (relative to project root)
6
+ - line: 1-based line number (for definition/references/hover)
7
+ - character: 1-based character offset (for definition/references/hover)
package/src/tools/lsp.mjs CHANGED
@@ -17,6 +17,7 @@
17
17
  import { spawn } from "node:child_process"
18
18
  import { existsSync, readFileSync } from "node:fs"
19
19
  import { join, extname } from "node:path"
20
+ import { DESC } from "./shared.mjs"
20
21
 
21
22
  // ---- JSON-RPC transport over stdio ----
22
23
 
@@ -184,9 +185,7 @@ async function ensureOpen(proc, uri, ext) {
184
185
 
185
186
  export const lspTool = {
186
187
  name: "lsp",
187
- description:
188
- "LSP code intelligence: go to definition, find references, hover info, document symbols, diagnostics. " +
189
- "Use this to understand code structure without grep-guessing function locations or type shapes.",
188
+ description: DESC("lsp"),
190
189
  parameters: {
191
190
  type: "object",
192
191
  properties: {
@@ -240,7 +239,7 @@ export const lspTool = {
240
239
  textDocument: { uri },
241
240
  position: { line: args.line - 1, character: args.character - 1 },
242
241
  }, 10)
243
- if (!res?.result) return "No definition found."
242
+ if (!res?.result) return "(no definition found)"
244
243
  const locs = Array.isArray(res.result) ? res.result : [res.result]
245
244
  return locs.map((l) => {
246
245
  const path = l.uri.replace(/^file:\/\/\//, "").replace(/%3A/, ":")
@@ -255,7 +254,7 @@ export const lspTool = {
255
254
  position: { line: args.line - 1, character: args.character - 1 },
256
255
  context: { includeDeclaration: false },
257
256
  }, 10)
258
- if (!res?.result?.length) return "No references found."
257
+ if (!res?.result?.length) return "(no references found)"
259
258
  return res.result.slice(0, 50).map((l) => {
260
259
  const path = l.uri.replace(/^file:\/\/\//, "").replace(/%3A/, ":")
261
260
  return `${path}:${l.range.start.line + 1}:${l.range.start.character + 1}`
@@ -268,7 +267,7 @@ export const lspTool = {
268
267
  textDocument: { uri },
269
268
  position: { line: args.line - 1, character: args.character - 1 },
270
269
  }, 10)
271
- if (!res?.result?.contents) return "No hover info available."
270
+ if (!res?.result?.contents) return "(no hover info)"
272
271
  const contents = res.result.contents
273
272
  if (typeof contents === "string") return contents
274
273
  if (Array.isArray(contents)) return contents.map((c) => typeof c === "string" ? c : c.value).join("\n")
@@ -280,7 +279,7 @@ export const lspTool = {
280
279
  const res = await request(proc, "textDocument/documentSymbol", {
281
280
  textDocument: { uri },
282
281
  }, 10)
283
- if (!res?.result?.length) return "No symbols found."
282
+ if (!res?.result?.length) return "(no symbols found)"
284
283
  function render(nodes, depth) {
285
284
  const lines = []
286
285
  for (const n of nodes) {
@@ -295,7 +294,7 @@ export const lspTool = {
295
294
 
296
295
  case "diagnostics": {
297
296
  const diags = proc._diagnostics?.[uri]
298
- if (!diags?.length) return "No diagnostics."
297
+ if (!diags?.length) return "(no diagnostics)"
299
298
  return diags.slice(0, 30).map((d) => {
300
299
  const sev = { 1: "ERROR", 2: "WARN", 3: "INFO", 4: "HINT" }[d.severity] || "?"
301
300
  return `L${d.range.start.line + 1}: ${sev}: ${d.message}${d.code ? ` [${d.code}]` : ""}`
@@ -306,7 +305,7 @@ export const lspTool = {
306
305
  return `Unknown subcommand: ${args.subcommand}`
307
306
  }
308
307
  } catch (err) {
309
- return `LSP error: ${err.message}`
308
+ return `lsp error: ${err.message}`
310
309
  }
311
310
  },
312
311
  }
@@ -159,36 +159,7 @@ export const applyPatchTool = {
159
159
  },
160
160
  }
161
161
 
162
- // ---------------------------------------------------------------- syntax_check
163
162
 
164
- export const syntaxCheckTool = {
165
- name: "syntax_check",
166
- description: DESC("syntax_check"),
167
- parameters: {
168
- type: "object",
169
- properties: {
170
- path: { type: "string", description: "File path (.js/.mjs/.cjs only)" },
171
- },
172
- required: ["path"],
173
- },
174
- readonly: true,
175
- execute(args, ctx) {
176
- const abs = resolveInCwd(ctx, args.path)
177
- if (!/\.(?:[mc]?js)$/.test(abs)) {
178
- return `syntax_check only supports .js/.mjs/.cjs files; ${args.path} skipped.`
179
- }
180
- try {
181
- execFileSync(process.execPath, ["--check", abs], {
182
- cwd: ctx.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
183
- })
184
- return `Syntax OK: ${args.path}`
185
- } catch (e) {
186
- // node --check writes errors to stderr
187
- const msg = (e.stderr || e.stdout || e.message || "").trim()
188
- return `Syntax error in ${args.path}:\n${msg || "(unknown)"}`
189
- }
190
- },
191
- }
192
163
 
193
164
  // ---------------------------------------------------------------- bash
194
165
 
@@ -204,6 +175,7 @@ export const deleteTool = {
204
175
  required: ["path"],
205
176
  },
206
177
  readonly: false,
178
+ touchedPaths(args) { return args.path ? [args.path] : [] },
207
179
  async execute(args, ctx) {
208
180
  const abs = resolveInCwd(ctx, args.path)
209
181
  let s
@@ -1,3 +1,7 @@
1
1
  Read an image file and return it as multimodal content visible to the model. Use this to view screenshots, UI mockups, diagrams, or any visual content. The model only sees images through this tool — it cannot "see" files directly. Supports png, jpg, gif, webp, bmp, svg. The image is base64-encoded and included in the response. Large images (>20MB) are rejected.
2
2
 
3
- Note: this tool only works with models that support vision/image input (Kimi K3, Qwen3.7, MiniMax M3). Pure text models (DeepSeek V4, GLM-5) will receive an error.
3
+ Parameters:
4
+ - path (required): Path to image file (relative to cwd or absolute). Supports png, jpg, gif, webp, bmp, svg.
5
+
6
+ Notes:
7
+ - This tool only works with models that support vision/image input (Kimi K3, Qwen3.7, MiniMax M3). Pure text models (DeepSeek V4, GLM-5) will receive an error.
@@ -378,7 +378,7 @@ export const lsTool = {
378
378
  }),
379
379
  )
380
380
  rows.sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1))
381
- if (rows.length === 0) return "(empty directory)"
381
+ if (rows.length === 0) return "(no entries)"
382
382
  const out = rows.map((r) => `${r.dir ? "d" : "-"} ${r.name.padEnd(40)} ${String(r.size).padStart(10)} ${r.mtime}`)
383
383
  return truncate(out.join("\n"))
384
384
  },
package/src/tools/web.mjs CHANGED
@@ -77,12 +77,12 @@ export const websearchTool = {
77
77
  const engine = ENGINES.find(e => e.name === args.engine)
78
78
  if (!engine) return `Unknown engine '${args.engine}'. Available: ${ENGINE_NAMES.join(", ")}`
79
79
  const fetched = await fetchEngine(engine, args.query, page, ctx)
80
- if (!fetched || fetched.results.length === 0) return `(no results from ${engine.label})`
81
- return truncate(fetched.results.slice(0, limit).map((r, i) => `${i + 1}. ${r.title}\n ${r.href}\n ${r.snippet}`).join("\n\n"))
80
+ if (!fetched || fetched.results.length === 0) return "(no results)"
81
+ return truncate(fetched.results.slice(0, limit).map((r, i) => `${i + 1}. [${engine.label}] ${r.title}\n ${r.href}\n ${r.snippet}`).join("\n\n"))
82
82
  }
83
83
  const promises = ENGINES.map(e => fetchEngine(e, args.query, 1, ctx))
84
84
  const fetched = (await Promise.all(promises)).filter(Boolean)
85
- if (fetched.length === 0) return "(no results — all search engines failed)"
85
+ if (fetched.length === 0) return "(no results)"
86
86
  const merged = [], indexes = fetched.map(() => 0)
87
87
  let done = false
88
88
  while (!done && merged.length < limit) {
@@ -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,6 +347,8 @@ 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)
@@ -333,15 +379,55 @@ export async function runAgentTurn(ctx, text) {
333
379
  }
334
380
  }
335
381
 
336
- /** Extract a one-line summary from a panel tool's output */
337
- function formatPanelSummary(name, result) {
382
+ /** Extract a one-line summary from tool output for the done line */
383
+ function formatToolSummary(name, result) {
338
384
  if (name === "verify") return _verifySummary(result)
339
385
  if (name === "bash") return _bashSummary(result)
386
+ if (name === "advisor") return _advisorSummary(result)
387
+ if (name === "read" || name === "read_file") return _readSummary(result)
388
+ if (name === "write" || name === "write_file") return _writeSummary(result)
389
+ if (name === "grep" || name === "search") return _grepSummary(result)
390
+ if (name === "glob") return _globSummary(result)
340
391
  // Default: first non-empty line
341
392
  const first = result.split("\n").find((l) => l.trim())
342
393
  return first ? `${name}: ${first.slice(0, 100)}` : null
343
394
  }
344
395
 
396
+ function _readSummary(result) {
397
+ const lines = result.split("\n")
398
+ // Look for line count in result
399
+ const countMatch = result.match(/(\d+) lines?/)
400
+ if (countMatch) return `${countMatch[1]} lines`
401
+ // Fallback: count actual lines
402
+ return `${lines.length} lines`
403
+ }
404
+
405
+ function _writeSummary(result) {
406
+ // Extract file size or confirmation
407
+ if (result.includes("wrote") || result.includes("created")) {
408
+ const sizeMatch = result.match(/(\d+)(?:\s*(?:bytes?|chars?))/i)
409
+ return sizeMatch ? `wrote ${sizeMatch[1]} bytes` : "wrote file"
410
+ }
411
+ const first = result.split("\n").find((l) => l.trim())
412
+ return first ? first.slice(0, 80) : "wrote"
413
+ }
414
+
415
+ function _grepSummary(result) {
416
+ const lines = result.split("\n").filter((l) => l.trim())
417
+ const count = lines.length
418
+ if (count === 0) return "no matches"
419
+ if (count === 1) return "1 match"
420
+ return `${count} matches`
421
+ }
422
+
423
+ function _globSummary(result) {
424
+ const lines = result.split("\n").filter((l) => l.trim())
425
+ const count = lines.length
426
+ if (count === 0) return "no files"
427
+ if (count === 1) return "1 file"
428
+ return `${count} files`
429
+ }
430
+
345
431
  /**
346
432
  * bash result format: "[stdout]:\n<out>\n\n[stderr]:\n<err>\n\n(exit code 0)".
347
433
  * The first non-empty line is always the "[stdout]:" marker — useless as a summary.
@@ -357,6 +443,23 @@ function _bashSummary(result) {
357
443
  return parts.length > 0 ? `bash: ${parts.join(" ")}` : null
358
444
  }
359
445
 
446
+ function _advisorSummary(result) {
447
+ const text = String(result ?? "")
448
+ if (/no 🔴|all.*(?:resolved|fixed|pass)/im.test(text)) return "advisor: passed"
449
+ // Error / skip messages — extract the reason after "Advisor:"
450
+ const errMatch = text.trimStart().match(/^Advisor:\s*(.+)/)
451
+ if (errMatch) return `advisor: ${errMatch[1].split(".")[0]}`
452
+ const critical = (text.match(/\| \d+ \|.*\| 🔴/g) || []).length
453
+ const advisory = (text.match(/\| \d+ \|.*\| 🟡/g) || []).length
454
+ const style = (text.match(/\| \d+ \|.*\| 🔵/g) || []).length
455
+ const parts = []
456
+ if (critical) parts.push(`${critical} critical`)
457
+ if (advisory) parts.push(`${advisory} advisory`)
458
+ if (style) parts.push(`${style} style`)
459
+ if (parts.length === 0) return null
460
+ return `advisor: ${parts.join(", ")}`
461
+ }
462
+
360
463
  function _verifySummary(result) {
361
464
  const lines = result.split("\n")
362
465
  const summary = []
@@ -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
  }
@@ -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
+ }