thincoder 0.12.11 → 0.12.13

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.
@@ -17,19 +17,31 @@ export const MAX_OUTPUT_CHARS = 200_000
17
17
  const ENCODING_DETECT_MAX_TRIM = 3
18
18
  const SYNTAX_CHECK_TIMEOUT = 10000
19
19
  export const BASH_TIMEOUT_MS = 120_000
20
- export const MAX_RESPONSE_BODY_BYTES = 5_000_000
21
20
  export const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage"])
22
21
 
23
- /** SSRF guard: check if a hostname is private/internal. Shared by web.mjs and codemode.mjs. */
22
+ /** SSRF guard: check if a hostname is private/internal. Shared by web.mjs and codemode.mjs.
23
+ * Returns TRUE for private hosts — callers block them. */
24
24
  export function isPrivateHost(hostname) {
25
25
  const h = hostname.toLowerCase()
26
- if (h === "localhost" || h === "0.0.0.0" || h.endsWith(".localhost")) return false
27
- if (h === "127.0.0.1" || h.startsWith("127.")) return false
26
+ // Local loopback + link-local names: BLOCK (true). These returned false
27
+ // before the guard was inverted for the most common SSRF targets
28
+ // (localhost/127.x reach internal services unchecked).
29
+ if (h === "localhost" || h === "0.0.0.0" || h.endsWith(".localhost")) return true
30
+ if (h === "127.0.0.1" || h.startsWith("127.")) return true
28
31
  if (h === "169.254.169.254" || h === "metadata.google.internal") return true
29
32
  // IPv6 private ranges — only check if host contains ":"
30
- if (h.includes(":") && (h === "::1" || h === "fe80::1" || h.startsWith("fc") || h.startsWith("fd"))) return true
33
+ // fe80::/10 link-local covers fe80:…febf:… startsWith("fe80:") is the
34
+ // practical subset (fe8/fe9/fea/feb all begin fe8/feb — full range regex
35
+ // would be /^fe[89ab][0-9a-f]:/; startsWith fe8 + fe9 + fea + feb covers it).
36
+ if (h.includes(":")) {
37
+ if (h === "::1" || h.startsWith("fc") || h.startsWith("fd")) return true
38
+ if (/^fe[89ab][0-9a-f]:/.test(h)) return true
39
+ }
31
40
  const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
32
41
  if (m) {
42
+ // Octet range is NOT validated (999.10.0.1 parses but matches no private
43
+ // prefix → treated as public). Intentional: the guard checks known-private
44
+ // prefixes; invalid IPs are harmless false-negatives for SSRF purposes.
33
45
  const [a, b] = [Number(m[1]), Number(m[2])]
34
46
  if (a === 10 || (a === 172 && b >= 16 && b <= 31) || a === 192 && b === 168 || a === 169 && b === 254 || a === 0) return true
35
47
  }
@@ -69,24 +81,10 @@ export function truncate(text, max = MAX_OUTPUT_CHARS) {
69
81
  return text.slice(0, max) + `\n[... truncated: ${text.length - max} chars omitted — redirect to a file if you need the full output]`
70
82
  }
71
83
 
72
- /** Read response body with a byte limit */
73
- export async function readBodyText(response, limit = MAX_RESPONSE_BODY_BYTES) {
74
- if (!response.body) return ""
75
- const reader = response.body.getReader()
76
- const chunks = []
77
- let total = 0
78
- try {
79
- for (;;) {
80
- const { done, value } = await reader.read()
81
- if (done) break
82
- if (value) { chunks.push(value); total += value.length }
83
- if (total >= limit) { await reader.cancel(); break }
84
- }
85
- } finally { reader.releaseLock() }
86
- return new TextDecoder("utf-8").decode(Buffer.concat(chunks))
87
- }
88
-
89
84
  /** Streaming decoder: encoding sniffing ASCII→UTF-8→GBK.
85
+ * KNOWN LIMITATION (accepted): the fallback is hardcoded to GBK (Chinese) —
86
+ * Shift-JIS/EUC-KR pages decode as mojibake. Real-world usage is dominated
87
+ * by UTF-8; a charset-aware variant would need the Content-Type header.
90
88
  * Each call creates an independent decoder instance — must not be shared across parallel streams (internal decoder state accumulates). */
91
89
  export function makeDecoder() {
92
90
  let decoder = null
@@ -154,7 +152,7 @@ export async function autoSyntaxCheck(abs) {
154
152
  }
155
153
 
156
154
  /** Resolve realpath by walking up the directory tree */
157
- export function realpathNearest(abs) {
155
+ function realpathNearest(abs) {
158
156
  let cur = abs
159
157
  const tail = []
160
158
  while (!existsSync(cur)) {
@@ -167,15 +165,20 @@ export function realpathNearest(abs) {
167
165
  catch { return abs }
168
166
  }
169
167
 
168
+ // cwd is effectively constant per CLI session — the cache never grows in
169
+ // practice. A long-running server with rotating cwds would leak; revisit with
170
+ // an LRU if that usage ever appears.
170
171
  const realCwdCache = new Map()
171
172
  /** Resolve cwd to realpath, cached */
172
- export function realCwd(cwd) {
173
+ function realCwd(cwd) {
173
174
  if (!realCwdCache.has(cwd)) realCwdCache.set(cwd, realpathNearest(resolve(cwd)))
174
175
  return realCwdCache.get(cwd)
175
176
  }
176
177
 
177
178
  /** Assert that a resolved path is inside cwd; throws on escape */
178
- export function assertInside(cwd, resolved, p) {
179
+ function assertInside(cwd, resolved, p) {
180
+ // relative() returns platform-native separators; ".." + sep therefore
181
+ // matches both / and \ traversal on the respective platform.
179
182
  const rel = relative(cwd, resolved)
180
183
  if (isAbsolute(rel) || rel === ".." || rel.startsWith(".." + sep)) {
181
184
  throw new Error(`Access denied outside working directory: ${p}`)
@@ -215,8 +218,11 @@ function blankQuoted(command) {
215
218
  const ch = command[i]
216
219
  if (quote) {
217
220
  if (ch === "\\" && quote !== "'") { out += " "; i++; out += " "; continue }
218
- if (ch === quote) quote = null
219
- out += " "
221
+ if (ch === quote) { quote = null; out += " "; continue }
222
+ // Backticks are COMMAND SUBSTITUTION — the content executes, so it must
223
+ // stay visible to the redirection check (echo `cat > /tmp/x` writes a
224
+ // file). Only ' and " are literal regions.
225
+ out += quote === "`" ? ch : " "
220
226
  } else if (ch === "'" || ch === '"' || ch === "`") {
221
227
  quote = ch
222
228
  out += " "
@@ -227,17 +233,22 @@ function blankQuoted(command) {
227
233
  return out
228
234
  }
229
235
 
230
- /** Detect shell output/input redirection (> >> < followed by filename) outside quoted regions */
236
+ /** Detect shell output/input redirection (> >> < followed by filename) outside quoted regions.
237
+ * Backtick contents count (command substitution executes); fd-prefixed forms
238
+ * (2> file, 1>> file) count too. */
231
239
  export function hasFileRedirection(command) {
232
240
  const bare = blankQuoted(command)
233
- return /(^|[\s;&|])>{1,2}\s*\S/.test(bare) || /(^|[\s;&|])<\s*\S/.test(bare)
241
+ return /(^|[\s;&|0-9])>{1,2}\s*\S/.test(bare) || /(^|[\s;&|0-9])<\s*\S/.test(bare)
234
242
  }
235
243
 
236
244
  /** Whether a single command segment is a destructive non-git command (conservative: prefer false positives) */
237
245
  export function isDestructiveCommand(seg) {
238
246
  const s = seg
239
- // rm with both recursive (-r/-R) and force (-f) flags: -rf / -fr / -r -f / -Rf etc.
240
- if (/\brm\b/.test(s) && /\s-\S*r/i.test(s) && /\s-\S*f/i.test(s)) return true
247
+ // rm with recursive (-r/-R/--recursive): destructive WITH or WITHOUT -f
248
+ // (recursive delete removes trees non-interactively in many setups; -rf is
249
+ // the classic case). Conservative: prefer blocking. The \s before the flag
250
+ // requires a separator — "rm-rf" is not a valid command (no such program).
251
+ if (/\brm\b/.test(s) && (/\s-\S*r/i.test(s) || /\s--recursive\b/i.test(s))) return true
241
252
  if (/\brmdir\b/i.test(s)) return true
242
253
  if (/\bdel\b/i.test(s) && /\/f\b/i.test(s)) return true
243
254
  if (/\brd\b/i.test(s) && /\/s\b/i.test(s)) return true
@@ -251,28 +262,11 @@ export function isDestructiveCommand(seg) {
251
262
  return false
252
263
  }
253
264
 
254
- /** Whether a single command segment destroys uncommitted changes */
255
- export function isDestructiveGitSegment(seg) {
256
- if (!/^\s*git\s/.test(seg)) return false
257
- if (/\scheckout\s+(?:--|\.(?:\s|$))/.test(seg)) return true
258
- if (/\sreset\s+--hard\b/.test(seg)) return true
259
- if (/\sclean\s+-\S*f/.test(seg)) return true
260
- if (/\srestore\s/.test(seg) && (/--worktree/.test(seg) || !/--staged/.test(seg))) return true
261
- return false
262
- }
263
-
264
- /** Whether cwd is inside a git repository */
265
- export function insideGitRepo(cwd) {
266
- try {
267
- execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
268
- cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
269
- })
270
- return true
271
- } catch { return false }
272
- }
273
265
 
274
266
  /** Convert glob pattern to regex */
275
267
  export function globToRegex(pattern) {
268
+ // Sentinel chars: \u0001/\u0002 never appear in real glob patterns (they
269
+ // come from model output or the filesystem) — safe as **/ and ** placeholders.
276
270
  const DS = "\u0001", DP = "\u0002"
277
271
  const escaped = pattern
278
272
  .replace(/\*\*\//g, DS).replace(/\*\*/g, DP)
@@ -283,12 +277,24 @@ export function globToRegex(pattern) {
283
277
  return new RegExp(`^${escaped}$`)
284
278
  }
285
279
 
286
- /** Strip HTML tags */
280
+ /** Decode a numeric HTML entity to its code point — invalid/out-of-range
281
+ * values (e.g. &#999999999999;) must not throw RangeError; keep the source
282
+ * text as-is (display-only residue is acceptable). */
283
+ function decodeNumericEntity(_, digits) {
284
+ const n = Number(digits)
285
+ return Number.isSafeInteger(n) && n <= 0x10ffff ? String.fromCodePoint(n) : _
286
+ }
287
+
288
+ /** Strip HTML tags. KNOWN LIMITATION (accepted): the `/<[^>]+>/g` regex treats
289
+ * the first `>` (or a `<` inside an attribute value) as the tag boundary —
290
+ * `<img alt="a > b">` truncates the match and leaves text residue. A full
291
+ * HTML parser is out of scope; real-world HTML with angle brackets in
292
+ * attributes is rare and the residue is display-only (never parsed). */
287
293
  export function stripTags(html) {
288
294
  return html
289
295
  .replace(/<[^>]+>/g, "")
290
- .replace(/&#0*(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
291
- .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
296
+ .replace(/&#0*(\d+);/g, (m, n) => decodeNumericEntity(m, n))
297
+ .replace(/&#x([0-9a-fA-F]+);/g, (m, h) => decodeNumericEntity(m, parseInt(h, 16)))
292
298
  .replace(/&nbsp;|&ensp;/g, " ")
293
299
  .replace(/&lt;/g, "<")
294
300
  .replace(/&gt;/g, ">")
@@ -308,8 +314,8 @@ export function htmlToText(html) {
308
314
  .replace(/<br\s*\/?>/gi, "\n")
309
315
  .replace(/<li[^>]*>/gi, "- ")
310
316
  .replace(/<[^>]+>/g, "")
311
- .replace(/&#0*(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
312
- .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
317
+ .replace(/&#0*(\d+);/g, (m, n) => decodeNumericEntity(m, n))
318
+ .replace(/&#x([0-9a-fA-F]+);/g, (m, h) => decodeNumericEntity(m, parseInt(h, 16)))
313
319
  .replace(/&nbsp;|&ensp;/g, " ")
314
320
  .replace(/&lt;/g, "<")
315
321
  .replace(/&gt;/g, ">")
@@ -325,8 +331,13 @@ export function runGit(cwd, cmdArgs) {
325
331
  try {
326
332
  return execFileSync("git", cmdArgs, { cwd, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }).trim().replace(/\r/g, "")
327
333
  } catch (e) {
328
- // ERR_CHILD_PROCESS_STDIO_MAXBUFFER: e.stdout contains partial output, return first 200 lines
329
- if (e.stdout) return String(e.stdout).trim().replace(/\r/g, "").split("\n").slice(0, 200).join("\n")
334
+ // maxBuffer overflow: e.stdout contains partial collected output return it
335
+ // (callers show "(truncated)"-style tails). ALL OTHER errors (non-git repo,
336
+ // permission, bad command) return "" — matching gitDiffOne's pattern: a
337
+ // failed git call must not masquerade as partial success.
338
+ if (e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" && e.stdout) {
339
+ return String(e.stdout).trim().replace(/\r/g, "").split("\n").slice(0, 200).join("\n")
340
+ }
330
341
  return ""
331
342
  }
332
343
  }
@@ -2,11 +2,17 @@ import { runAgent, ContinueError } from "../agent.mjs"
2
2
  import { saveSession } from "../session.mjs"
3
3
  import { sliceByWidth } from "./render.mjs"
4
4
  import { ansi, C } from "./ansi.mjs"
5
+ import { formatToolSummary } from "./tool-summaries.mjs"
6
+ import { ADVISOR_THINKING_PLACEHOLDER } from "../advisor/run.mjs"
5
7
 
6
8
  /** Tool execution start timestamps (performance.now ms), keyed by tool name. */
7
9
  const _toolTicks = Object.create(null)
8
10
 
9
- /** Per-tool streaming preview line limits — tools with verbose output get more lines */
11
+ /** Per-tool streaming preview line limits — tools with verbose output get more lines.
12
+ * NOTE: `advisor` is intentionally NOT pruned by the live-line mechanism: its
13
+ * streaming returns early (kind-split into _advisorThink/advisorStreaming) and
14
+ * is rendered full-length in render-conversation. The entry is kept for
15
+ * symmetry with the map's other tools. */
10
16
  const LIVE_LINE_LIMITS = {
11
17
  bash: 10,
12
18
  advisor: 15,
@@ -43,8 +49,7 @@ export async function runAgentTurn(ctx, text) {
43
49
  state.status = "Processing..."
44
50
  state.streaming = ""
45
51
  state.reasoning = ""
46
- state.advisorStreaming = ""
47
- state._advisorThink = ""
52
+ state._advisorBlocks = []
48
53
  state.subTasks = {}
49
54
  state.currentTool = null
50
55
  state.processingStarted = Date.now()
@@ -56,6 +61,12 @@ export async function runAgentTurn(ctx, text) {
56
61
  }, 1000)
57
62
  render()
58
63
 
64
+ // NOTE: advisor buffers (_advisorThink/advisorStreaming) are cleared here too.
65
+ // Timing safety: onToolResult flushes _advisorThink into history and empties
66
+ // the buffers BEFORE onTurnEnd can call flushStream (tool result is
67
+ // dispatched inside executeToolCalls; onTurnEnd fires after the turn loop
68
+ // resumes). If a future change calls flushStream mid-advisor-execution the
69
+ // in-progress thinking WOULD be lost — keep the ordering, or flush here too.
59
70
  const flushStream = () => {
60
71
  if (state.reasoning) {
61
72
  const idx = state.lines.length
@@ -77,8 +88,7 @@ export async function runAgentTurn(ctx, text) {
77
88
  state._autoExpand.push(idx)
78
89
  state.streaming = ""
79
90
  }
80
- state.advisorStreaming = ""
81
- state._advisorThink = ""
91
+ state._advisorBlocks = []
82
92
  }
83
93
 
84
94
  const callbacks = {
@@ -132,7 +142,10 @@ export async function runAgentTurn(ctx, text) {
132
142
  scheduleRender()
133
143
  return
134
144
  }
135
- if (name === "advisor") { state.advisorStreaming = ""; state._advisorThink = "" }
145
+ // Redundant with flushStream() below (it clears both buffers) kept as
146
+ // defense-in-depth so a future flushStream change cannot leak advisor
147
+ // buffers into the next tool's view.
148
+ if (name === "advisor") { state._advisorBlocks = [] }
136
149
  flushStream()
137
150
  ensureAssistantLabel()
138
151
  state.currentTool = name
@@ -197,7 +210,37 @@ export async function runAgentTurn(ctx, text) {
197
210
  const summary = formatToolSummary(name, result)
198
211
  if (summary) pushLine(` ${summary}`, C.dim)
199
212
  }
200
- if (name === "advisor") { state.advisorStreaming = ""; state._advisorThink = "" }
213
+ if (name === "advisor") {
214
+ // The review's thinking must survive into the conversation history like
215
+ // the main agent's reasoning (flushStream does for state.reasoning) —
216
+ // discarding it left the thought process visible only mid-review, then
217
+ // gone. Flush BEFORE the done line so the block sits above it.
218
+ // NOTE (rendering): the flushed block has NO "│ " gutter prefix while
219
+ // the live streaming view adds one — same convention as the main
220
+ // agent's reasoning (live gutter, history plain). Intentional.
221
+ const blocks = state._advisorBlocks ?? []
222
+ if (blocks.length > 0) {
223
+ // Flush the ordered blocks in sequence — thinking and tool progress
224
+ // alternate in history exactly as they were emitted. The live
225
+ // "[thinking…]" placeholders are stripped (wait indicators, not
226
+ // review content); literal replaceAll of the shared constant can
227
+ // never drift.
228
+ const text = blocks
229
+ .map((b) => b.text.replaceAll(ADVISOR_THINKING_PLACEHOLDER, ""))
230
+ .join("")
231
+ .replace(/\n{3,}/g, "\n\n")
232
+ .trim()
233
+ if (text) {
234
+ const idx = state.lines.length
235
+ pushLine(text, C.reason)
236
+ // Completed review output stays expanded (user is reading it).
237
+ state.expandedBlocks ??= new Set()
238
+ state.expandedBlocks.add("long-" + idx)
239
+ state._autoExpand ??= []
240
+ state._autoExpand.push(idx)
241
+ }
242
+ }
243
+ }
201
244
  // Done line for ALL tools (panel area abolished — inline only).
202
245
  if (!isSubagent) {
203
246
  const elapsed = _toolTicks[name] ? ` (${Math.round(performance.now() - _toolTicks[name])}ms)` : ""
@@ -217,13 +260,23 @@ export async function runAgentTurn(ctx, text) {
217
260
  if (name === "advisor") {
218
261
  // Accumulate to buffer — formatTables + wrapText in render-conversation
219
262
  // handles markdown formatting, same as main agent response.
220
- const raw = typeof chunk === "string" ? chunk : String(chunk?.text ?? "")
221
- const kind = typeof chunk === "string" ? "text" : (chunk?.kind ?? "text")
222
- if (kind === "think") {
223
- state._advisorThink = (state._advisorThink || "") + raw
224
- } else {
225
- state.advisorStreaming += raw
226
- }
263
+ // NOTE: the advisor tool ALWAYS emits {kind, text} objects (run.mjs's
264
+ // emit() wrapper) a raw string chunk is never think; if that ever
265
+ // changes, plain-string think would land in advisorStreaming.
266
+ // ORDERED block buffer preserves the interleaved emission order
267
+ // (think → tool → think → … → final). Two separate buffers (_advisorThink
268
+ // vs advisorStreaming) rendered think-block-then-main-block, which
269
+ // regrouped ALL thinking above ALL tool progress — the alternating
270
+ // timeline was destroyed. Consecutive chunks of the same kind merge
271
+ // into one block; kind flips start a new block; render walks the
272
+ // blocks in order with per-kind colors.
273
+ const isString = typeof chunk === "string"
274
+ const raw = isString ? chunk : String(chunk?.text ?? "")
275
+ const kind = isString ? "text" : (chunk?.kind ?? "text")
276
+ const blocks = state._advisorBlocks ??= []
277
+ const last = blocks.at(-1)
278
+ if (last && last.kind === kind) last.text += raw
279
+ else blocks.push({ kind, text: raw })
227
280
  scheduleRender()
228
281
  return
229
282
  }
@@ -295,7 +348,11 @@ export async function runAgentTurn(ctx, text) {
295
348
  // pushback messages appear in the conversation at the right spot.
296
349
  const last = agent.history.at(-1)
297
350
  if (last?.role === "user" && typeof last.content === "string" && last.content.startsWith("[System reminder:")) {
298
- pushLine(last.content, C.warn)
351
+ // Reminders can embed long prior tables — show only the first lines
352
+ // (the full text is in agent.history); 3 lines + ellipsis.
353
+ const lines = last.content.split("\n")
354
+ const shown = lines.length > 3 ? lines.slice(0, 3).join("\n") + "\n…" : last.content
355
+ pushLine(shown, C.warn)
299
356
  }
300
357
  if (++n % 5 !== 0) return
301
358
  try { saveSessionImpl(agent, state.lines) } catch (e) { console.error(`[session] incremental save failed: ${e.message}`) }
@@ -357,8 +414,7 @@ export async function runAgentTurn(ctx, text) {
357
414
  clearInterval(ticker)
358
415
  state.processing = false
359
416
  state.subTasks = {}
360
- state.advisorStreaming = ""
361
- state._advisorThink = ""
417
+ state._advisorBlocks = []
362
418
  state.controller = null
363
419
  state.status = "Ready"
364
420
  // Auto-collapse todo panel when all tasks done (matching kimi-code TUI; agent.tasks are preserved)
@@ -403,107 +459,3 @@ export async function runAgentTurn(ctx, text) {
403
459
  return
404
460
  }
405
461
  }
406
-
407
- /** Extract a one-line summary from tool output for the done line */
408
- function formatToolSummary(name, result) {
409
- if (name === "verify") return _verifySummary(result)
410
- if (name === "bash") return _bashSummary(result)
411
- if (name === "advisor") return _advisorSummary(result)
412
- if (name === "read" || name === "read_file") return _readSummary(result)
413
- if (name === "write" || name === "write_file") return _writeSummary(result)
414
- if (name === "grep" || name === "search") return _grepSummary(result)
415
- if (name === "glob") return _globSummary(result)
416
- // Default: first non-empty line
417
- const first = result.split("\n").find((l) => l.trim())
418
- return first ? `${name}: ${first.slice(0, 100)}` : null
419
- }
420
-
421
- function _readSummary(result) {
422
- const lines = result.split("\n")
423
- // Look for line count in result
424
- const countMatch = result.match(/(\d+) lines?/)
425
- if (countMatch) return `${countMatch[1]} lines`
426
- // Fallback: count actual lines
427
- return `${lines.length} lines`
428
- }
429
-
430
- function _writeSummary(result) {
431
- // Extract file size or confirmation
432
- if (result.includes("wrote") || result.includes("created")) {
433
- const sizeMatch = result.match(/(\d+)(?:\s*(?:bytes?|chars?))/i)
434
- return sizeMatch ? `wrote ${sizeMatch[1]} bytes` : "wrote file"
435
- }
436
- const first = result.split("\n").find((l) => l.trim())
437
- return first ? first.slice(0, 80) : "wrote"
438
- }
439
-
440
- function _grepSummary(result) {
441
- const lines = result.split("\n").filter((l) => l.trim())
442
- const count = lines.length
443
- if (count === 0) return "no matches"
444
- if (count === 1) return "1 match"
445
- return `${count} matches`
446
- }
447
-
448
- function _globSummary(result) {
449
- const lines = result.split("\n").filter((l) => l.trim())
450
- const count = lines.length
451
- if (count === 0) return "no files"
452
- if (count === 1) return "1 file"
453
- return `${count} files`
454
- }
455
-
456
- /**
457
- * bash result format: "[stdout]:\n<out>\n\n[stderr]:\n<err>\n\n(exit code 0)".
458
- * The first non-empty line is always the "[stdout]:" marker — useless as a summary.
459
- * Show the LAST output line (usually the meaningful tail) plus the exit status.
460
- */
461
- function _bashSummary(result) {
462
- const isMarker = (l) => /^\[(stdout|stderr)\]:$/.test(l) || /^\((exit code|killed)/.test(l)
463
- const lines = result.split("\n").map((l) => l.trim()).filter((l) => l && !isMarker(l))
464
- const status = result.match(/\((?:exit code|killed)[^)]*\)/)?.[0]
465
- const parts = []
466
- if (lines.length > 0) parts.push(lines[lines.length - 1].slice(0, 100))
467
- if (status) parts.push(status)
468
- return parts.length > 0 ? `bash: ${parts.join(" ")}` : null
469
- }
470
-
471
- function _advisorSummary(result) {
472
- const text = String(result ?? "")
473
- if (/no 🔴|all.*(?:resolved|fixed|pass)/im.test(text)) return "advisor: passed"
474
- // Error / skip messages — extract the reason after "Advisor:"
475
- const errMatch = text.trimStart().match(/^Advisor:\s*(.+)/)
476
- if (errMatch) return `advisor: ${errMatch[1].split(".")[0]}`
477
- const critical = (text.match(/\| \d+ \|.*\| 🔴/g) || []).length
478
- const advisory = (text.match(/\| \d+ \|.*\| 🟡/g) || []).length
479
- const style = (text.match(/\| \d+ \|.*\| 🔵/g) || []).length
480
- const parts = []
481
- if (critical) parts.push(`${critical} critical`)
482
- if (advisory) parts.push(`${advisory} advisory`)
483
- if (style) parts.push(`${style} style`)
484
- if (parts.length === 0) return null
485
- return `advisor: ${parts.join(", ")}`
486
- }
487
-
488
- function _verifySummary(result) {
489
- const lines = result.split("\n")
490
- const summary = []
491
- // Changed files count
492
- const changed = lines.find((l) => l.startsWith("Changed files:"))
493
- if (changed) {
494
- const m = changed.match(/files changed/) ? changed.replace(/^Changed files \(.*?\)/, "Changed files") : changed
495
- summary.push(m)
496
- }
497
- // Syntax check results
498
- const syntax = lines.filter((l) => l.startsWith(" ✗"))
499
- if (syntax.length > 0) {
500
- summary.push(`${syntax.length} syntax error(s)`)
501
- }
502
- // Test results
503
- const testLine = lines.find((l) => l.startsWith("✓ Tests passed.") || l.startsWith("✗ Tests FAILED"))
504
- if (testLine) summary.push(testLine.trim())
505
- // Task list
506
- const taskLine = lines.find((l) => l.startsWith("Task list:"))
507
- if (taskLine) summary.push(taskLine)
508
- return summary.length > 0 ? `verify: ${summary.join(" — ")}` : ""
509
- }
package/src/tui/index.mjs CHANGED
@@ -60,7 +60,7 @@ export async function startTUI(agent, opts = {}) {
60
60
  const state = {
61
61
  lines: [], // conversation lines: { text, color }
62
62
  streaming: "", // current streaming buffer
63
- advisorStreaming: "", // advisor streaming buffer (formatted like main response)
63
+ _advisorBlocks: [], // advisor ordered blocks: [{ kind: "think"|"text", text }] — preserves emission order (think tool interleaving)
64
64
  input: [], // input buffer (codepoint array)
65
65
  cursor: 0,
66
66
  history: [],
@@ -5,9 +5,11 @@
5
5
  * model replies stop showing literal `**`, `##`, backtick markers (IK5VW3).
6
6
  *
7
7
  * Design constraints:
8
- * - Operates on ALREADY-WRAPPED single lines (call after wrapText): inserting ANSI
9
- * here cannot break width math, because wrapping already happened.
10
- * - Uses narrow-scope SGR resets (22 = bold off, 27 = reverse off, 29 = strikethrough off)
8
+ * - Display-only rendering. renderMarkdownHeading handles multi-line input
9
+ * (splits internally); renderMarkdownInline expects single lines (its
10
+ * regexes use [^*\n]+ no cross-line matches). Callers pass pre-wrapped
11
+ * lines so the inserted ANSI never skews width math.
12
+ * - Uses narrow-scope SGR resets (22 = bold off, 24 = underline off, 29 = strikethrough off)
11
13
  * instead of reset(0), so the line's base color (C.text etc.) survives.
12
14
  * - Code spans are extracted FIRST: anything inside backticks is styled as code and
13
15
  * its `**`/`__` markers are NOT interpreted (markdown semantics).
@@ -15,6 +17,10 @@
15
17
  */
16
18
 
17
19
  const BOLD = "\x1b[1m"
20
+ // NOTE: \x1b[22m resets BOTH bold and faint/dim (SGR 2). Today no C.reason
21
+ // (dim) line passes through markdown rendering (reasoning/think blocks skip
22
+ // it), so this is latent — if dim text ever gains markdown, bold segments
23
+ // would clear the dim effect after them.
18
24
  const BOLD_OFF = "\x1b[22m"
19
25
  const UNDERLINE = "\x1b[4m"
20
26
  const UNDERLINE_OFF = "\x1b[24m"
@@ -23,7 +29,10 @@ const STRIKE_OFF = "\x1b[29m"
23
29
 
24
30
  /** Render inline markers on a single text line: `code` spans, **bold**, __bold__, ~~strike~~. */
25
31
  export function renderMarkdownInline(line) {
26
- if (!line || line.indexOf("*") === -1 && line.indexOf("`") === -1 && line.indexOf("_") === -1 && line.indexOf("~") === -1) {
32
+ // Single underscore lines (snake_case identifiers) must short-circuit too
33
+ // __bold__ needs a DOUBLE underscore; a lone "_" would otherwise run the
34
+ // whole split/replace pipeline for nothing.
35
+ if (!line || (line.indexOf("*") === -1 && line.indexOf("`") === -1 && line.indexOf("__") === -1 && line.indexOf("~") === -1)) {
27
36
  return line
28
37
  }
29
38
 
@@ -44,9 +53,18 @@ export function renderMarkdownInline(line) {
44
53
  return out
45
54
  }
46
55
 
47
- /** Render a heading line: strip leading `#` markers and bold the whole line. Returns original when not a heading. */
56
+ /** Render heading markers: strip leading `#` markers and bold the heading.
57
+ * Inline markers inside the heading are stripped too — the heading is already
58
+ * fully bold, so `**bold**` inside it would wrap another bold sequence whose
59
+ * `\x1b[22m` turns bold OFF for the rest of the heading text.
60
+ * Line-by-line (split on \n): without the m flag, `^`/`$` anchor the whole
61
+ * string, so a multi-line input never matched and headings stayed raw —
62
+ * the old call sites passed single wrapped lines and hid the defect.
63
+ * Returns the original text when no line is a heading. */
48
64
  export function renderMarkdownHeading(line) {
49
- const m = /^\s{0,3}(#{1,6})\s+(.*)$/.exec(line)
50
- if (!m || !m[2]) return line
51
- return `${BOLD}${m[2]}${BOLD_OFF}`
65
+ return line.split("\n").map((l) => {
66
+ const m = /^\s{0,3}(#{1,6})\s+(.*)$/.exec(l)
67
+ if (!m || !m[2]) return l
68
+ return `${BOLD}${m[2].replace(/\*\*|__|~~/g, "")}${BOLD_OFF}`
69
+ }).join("\n")
52
70
  }