thincoder 0.12.11 → 0.12.12

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.
@@ -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
  }
@@ -17,17 +17,29 @@ let _convCache = { key: "", cols: 0, lines: [] }
17
17
  * @returns {string} ANSI-rendered line whose display width equals stringWidth(text)
18
18
  */
19
19
  function renderMarkdownPreservingWidth(text) {
20
- const rendered = renderMarkdownInline(renderMarkdownHeading(text))
21
- const diff = stringWidth(text) - stringWidth(rendered)
22
- return diff > 0 ? rendered + " ".repeat(diff) : rendered
20
+ // Line-by-line: render + compensate per line. The per-line padding serves
21
+ // NON-table text (so `**bold** text` next to plain text keeps its width).
22
+ // Table alignment is NOT provided by the padding formatTables strips cell
23
+ // padding during trim and recomputes widths from the RENDERED text (that is
24
+ // the render-before-measure contract).
25
+ return text.split("\n").map((line) => {
26
+ const rendered = renderMarkdownInline(renderMarkdownHeading(line))
27
+ const diff = stringWidth(line) - stringWidth(rendered)
28
+ return diff > 0 ? rendered + " ".repeat(diff) : rendered
29
+ }).join("\n")
23
30
  }
31
+ // Test seam (mirrors the _-prefixed seams in run.mjs).
32
+ export { renderMarkdownPreservingWidth as _renderMarkdownPreservingWidth }
24
33
 
25
34
 
26
35
  export function convCacheKey(state) {
27
36
  const lastLine = state.lines.length > 0 ? state.lines[state.lines.length - 1] : null
28
37
  // expandedBlocks participates: expanding/folding a block must invalidate the cache
29
38
  const exp = state.expandedBlocks ? [...state.expandedBlocks].sort().join(",") : ""
30
- return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${state.advisorStreaming?.length ?? 0}|${state._advisorThink?.length ?? 0}|${state.foldEnabled !== false ? "f" : "u"}|${exp}`
39
+ // Content prefix in the signature: same kind+length with different content
40
+ // would otherwise collide (stale render); 8 chars disambiguate in practice.
41
+ const blocksSig = (state._advisorBlocks ?? []).map((b) => `${b.kind}:${b.text?.length ?? 0}:${String(b.text ?? "").slice(0, 8)}`).join(",")
42
+ return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${blocksSig}|${state.foldEnabled !== false ? "f" : "u"}|${exp}`
31
43
  }
32
44
 
33
45
  /** Fold marker line: bold-cyan icon + "click to …" phrase underlined (clickable affordance).
@@ -70,6 +82,13 @@ function highlightSearchMatches(text, query, matchesInLine, globalCurrentIndex,
70
82
  return result
71
83
  }
72
84
 
85
+ /**
86
+ * Build the conversation lines for the given state.
87
+ * NOTE: module-level _convCache is read/written as a side effect (keyed by
88
+ * convCacheKey + cols) — the function is pure w.r.t. its input except for
89
+ * that cache; direct callers outside renderConversation/countConvLines
90
+ * should be aware the cache persists across calls.
91
+ */
73
92
  function buildConvLines(state, cols) {
74
93
  const key = convCacheKey(state)
75
94
  if (_convCache.key === key && _convCache.cols === cols) return _convCache.lines
@@ -90,19 +109,25 @@ function buildConvLines(state, cols) {
90
109
 
91
110
  // Long-message folding: ANY single line (main output C.text, thinking C.reason,
92
111
  // tool summaries C.dim — whatever wraps beyond LONG_FOLD_LINES display rows)
93
- // collapses to [blank, ▶, first 4, last] 5 content lines. Main output and
94
- // thinking are the REAL long content; bidirectional folding (collapse markers
95
- // + click toggle) keeps them readable — the 0.12.7 dim-only restriction was a
96
- // temporary fix for the single-direction era and is now reverted. Keyed by the
97
- // source-line index (`long-${i}`) so the toggle survives re-renders.
112
+ // collapses to [first 4, ▶, last]; expanded long blocks render as
113
+ // [blank, ▼, every line]. Main output and thinking are the REAL long
114
+ // content; bidirectional folding (collapse markers + click toggle) keeps
115
+ // them readable the 0.12.7 dim-only restriction was a temporary fix for
116
+ // the single-direction era and is now reverted. Keyed by the source-line
117
+ // index (`long-${i}`) so the toggle survives re-renders.
98
118
  const longKey = `long-${i}`
99
119
  const folded = state.foldEnabled !== false && !state.expandedBlocks?.has(longKey)
100
120
  const block = []
101
- for (const line of formatTables(sanitizeDisplay(text), cols - 1)) {
121
+ // Lightweight markdown display (IK5VW3): render BEFORE measuring — the
122
+ // table column math (formatTables) and wrapping must see the RENDERED
123
+ // text (ANSI consumes zero display width; the width functions are
124
+ // ANSI-aware). Rendering after wrapping measured raw markdown
125
+ // (`**bold**` = 8) against displayed text (4) and sliced markers
126
+ // mid-sequence — the table misalignment the user kept reporting.
127
+ const renderedText = renderMarkdownPreservingWidth(sanitizeDisplay(text))
128
+ for (const line of formatTables(renderedText, cols - 1)) {
102
129
  for (const wrapped of wrapText(line, cols - 1)) {
103
- // Lightweight markdown display (IK5VW3): headings bold + inline markers styled.
104
- // Runs AFTER wrapping so the ANSI it inserts never skews width math.
105
- block.push({ text: renderMarkdownPreservingWidth(wrapped), color: l.color, _foldId: l._foldId, _src: i })
130
+ block.push({ text: wrapped, color: l.color, _foldId: l._foldId, _src: i })
106
131
  }
107
132
  }
108
133
  if (folded && block.length > LONG_FOLD_LINES) {
@@ -114,15 +139,21 @@ function buildConvLines(state, cols) {
114
139
  convLines.push(foldHintLine(`▶ … ${block.length - FOLD_KEEP} more lines — click to expand`, longKey, i))
115
140
  convLines.push(block[block.length - 1])
116
141
  } else if (block.length > LONG_FOLD_LINES) {
117
- // EXPANDED long block: blank line + ▼ control line at the HEAD, directly
118
- // before the content. DIM blocks must not re-trigger the consecutive-dim
119
- // folding below (folding stacked on folding reported regression).
120
- if (l.color === C.dim) {
121
- for (const line of block) line._skipDimFold = true
142
+ if (state.foldEnabled === false) {
143
+ // Folding fully off — content already fully visible; a "click to
144
+ // collapse" hint would be misleading (toggling has no effect).
145
+ convLines.push(...block)
146
+ } else {
147
+ // EXPANDED long block: blank line + ▼ control line at the HEAD, directly
148
+ // before the content. DIM blocks must not re-trigger the consecutive-dim
149
+ // folding below (folding stacked on folding — reported regression).
150
+ if (l.color === C.dim) {
151
+ for (const line of block) line._skipDimFold = true
152
+ }
153
+ convLines.push(blankLine())
154
+ convLines.push(foldHintLine(`▼ … ${block.length} lines — click to collapse`, longKey, i))
155
+ convLines.push(...block)
122
156
  }
123
- convLines.push(blankLine())
124
- convLines.push(foldHintLine(`▼ … ${block.length} lines — click to collapse`, longKey, i))
125
- convLines.push(...block)
126
157
  } else {
127
158
  convLines.push(...block)
128
159
  }
@@ -132,25 +163,40 @@ function buildConvLines(state, cols) {
132
163
  convLines.push({ text: wrapped, color: C.reason })
133
164
  }
134
165
  }
135
- if (state._advisorThink || state.advisorStreaming) {
136
- const thinkLines = state._advisorThink ? sanitizeDisplay(state._advisorThink).split("\n") : []
137
- const mainLines = state.advisorStreaming
138
- ? formatTables(sanitizeDisplay(state.advisorStreaming), cols - 3)
139
- : []
140
- const allLines = [...thinkLines.map(l => ({ text: l, color: C.reason })), ...mainLines.map(l => ({ text: l, color: C.text }))]
141
- const truncated = allLines.length > 5
142
- if (truncated) convLines.push({ text: "│ …", color: C.dim })
143
- const shown = truncated ? allLines.slice(-5) : allLines
144
- for (const { text, color } of shown) {
145
- for (const wrapped of wrapText(text, cols - 3)) {
146
- convLines.push({ text: `│ ${wrapped}`, color })
166
+ const advisorBlocks = state._advisorBlocks ?? []
167
+ if (advisorBlocks.length > 0) {
168
+ // ORDERED block display — the blocks preserve the emission order
169
+ // (think → tool → think → … → final) and render as one interleaved
170
+ // stream: thinking in reasoning color, tool progress/final in text color.
171
+ // Full-length, no preview truncation; long content scrolls via the
172
+ // conversation window like everything else.
173
+ // NOTE: formatTables returns an ARRAY of lines (not a string) — calling
174
+ // .split on it crashed the whole render (tools/final never displayed).
175
+ for (const block of advisorBlocks) {
176
+ const color = { think: C.reason, tool: C.tool, text: C.text }[block.kind] ?? C.text
177
+ const source = sanitizeDisplay(block.text)
178
+ // kind:"text" (the final review prose) gets the same lightweight markdown
179
+ // styling as the main agent response. Rendered BEFORE measuring: the
180
+ // width math (formatTables / wrapText) must see the RENDERED text —
181
+ // measuring raw markdown (`**bold**` = 8) against displayed text (4)
182
+ // misaligned table columns; wrapping raw markdown sliced markers
183
+ // mid-sequence (`**bo` + `ld**`) so the renderer never saw complete ones.
184
+ const rows = block.kind === "think"
185
+ ? source.split("\n")
186
+ : formatTables(block.kind === "text" ? renderMarkdownPreservingWidth(source) : source, cols - 3)
187
+ for (const line of rows) {
188
+ for (const wrapped of wrapText(line, cols - 3)) {
189
+ convLines.push({ text: `│ ${wrapped}`, color })
190
+ }
147
191
  }
148
192
  }
149
193
  }
150
194
  if (state.streaming) {
151
- for (const line of formatTables(sanitizeDisplay(state.streaming), cols - 1)) {
195
+ // Rendered BEFORE formatTables see the advisor-block comment above.
196
+ const rendered = renderMarkdownPreservingWidth(sanitizeDisplay(state.streaming))
197
+ for (const line of formatTables(rendered, cols - 1)) {
152
198
  for (const wrapped of wrapText(line, cols - 1)) {
153
- convLines.push({ text: renderMarkdownPreservingWidth(wrapped), color: C.text })
199
+ convLines.push({ text: wrapped, color: C.text })
154
200
  }
155
201
  }
156
202
  }
@@ -177,10 +223,15 @@ function buildConvLines(state, cols) {
177
223
  i = j
178
224
  continue
179
225
  }
180
- // EXPANDED consecutive-dim block: blank + ▼ at the HEAD, then every line
181
- folded.push(blankLine())
182
- folded.push(foldHintLine(`▼ ${blockLen} lines — click to collapse`, foldKey))
183
- for (let k = i; k < j; k++) folded.push(convLines[k])
226
+ // EXPANDED consecutive-dim block: blank + ▼ at the HEAD, then every line.
227
+ // foldEnabled=false → raw block, no hint (toggling would be a no-op).
228
+ if (state.foldEnabled === false) {
229
+ for (let k = i; k < j; k++) folded.push(convLines[k])
230
+ } else {
231
+ folded.push(blankLine())
232
+ folded.push(foldHintLine(`▼ … ${blockLen} lines — click to collapse`, foldKey))
233
+ for (let k = i; k < j; k++) folded.push(convLines[k])
234
+ }
184
235
  i = j
185
236
  continue
186
237
  }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * tool-summaries.mjs — one-line tool-result summaries for the TUI "done" lines.
3
+ * Extracted from agent-turn.mjs (file-size split): pure functions, no state.
4
+ */
5
+
6
+ /** Extract a one-line summary from tool output for the done line */
7
+ export function formatToolSummary(name, result) {
8
+ if (name === "verify") return _verifySummary(result)
9
+ if (name === "bash") return _bashSummary(result)
10
+ if (name === "advisor") return _advisorSummary(result)
11
+ if (name === "read" || name === "read_file") return _readSummary(result)
12
+ if (name === "write" || name === "write_file") return _writeSummary(result)
13
+ if (name === "grep" || name === "search") return _grepSummary(result)
14
+ if (name === "glob") return _globSummary(result)
15
+ // Default: first non-empty line
16
+ const first = result.split("\n").find((l) => l.trim())
17
+ return first ? `${name}: ${first.slice(0, 100)}` : null
18
+ }
19
+
20
+ function _readSummary(result) {
21
+ const lines = result.split("\n")
22
+ // Look for line count in result
23
+ const countMatch = result.match(/(\d+) lines?/)
24
+ if (countMatch) return `${countMatch[1]} lines`
25
+ // Fallback: count actual lines
26
+ return `${lines.length} lines`
27
+ }
28
+
29
+ function _writeSummary(result) {
30
+ // Extract file size or confirmation
31
+ if (result.includes("wrote") || result.includes("created")) {
32
+ const sizeMatch = result.match(/(\d+)(?:\s*(?:bytes?|chars?))/i)
33
+ return sizeMatch ? `wrote ${sizeMatch[1]} bytes` : "wrote file"
34
+ }
35
+ const first = result.split("\n").find((l) => l.trim())
36
+ return first ? first.slice(0, 80) : "wrote"
37
+ }
38
+
39
+ function _grepSummary(result) {
40
+ const lines = result.split("\n").filter((l) => l.trim())
41
+ const count = lines.length
42
+ if (count === 0) return "no matches"
43
+ if (count === 1) return "1 match"
44
+ return `${count} matches`
45
+ }
46
+
47
+ function _globSummary(result) {
48
+ const lines = result.split("\n").filter((l) => l.trim())
49
+ const count = lines.length
50
+ if (count === 0) return "no files"
51
+ if (count === 1) return "1 file"
52
+ return `${count} files`
53
+ }
54
+
55
+ /**
56
+ * bash result format: "[stdout]:\n<out>\n\n[stderr]:\n<err>\n\n(exit code 0)".
57
+ * The first non-empty line is always the "[stdout]:" marker — useless as a summary.
58
+ * Show the LAST output line (usually the meaningful tail) plus the exit status.
59
+ */
60
+ function _bashSummary(result) {
61
+ const isMarker = (l) => /^\[(stdout|stderr)\]:$/.test(l) || /^\((exit code|killed)/.test(l)
62
+ const lines = result.split("\n").map((l) => l.trim()).filter((l) => l && !isMarker(l))
63
+ const status = result.match(/\((?:exit code|killed)[^)]*\)/)?.[0]
64
+ const parts = []
65
+ if (lines.length > 0) parts.push(lines[lines.length - 1].slice(0, 100))
66
+ if (status) parts.push(status)
67
+ return parts.length > 0 ? `bash: ${parts.join(" ")}` : null
68
+ }
69
+
70
+ function _advisorSummary(result) {
71
+ const text = String(result ?? "")
72
+ // Error / skip messages — extract the reason after "Advisor:"
73
+ const errMatch = text.trimStart().match(/^Advisor:\s*(.+)/)
74
+ if (errMatch) return `advisor: ${errMatch[1].split(".")[0]}`
75
+ const critical = (text.match(/\| \d+ \|.*\| 🔴/g) || []).length
76
+ const advisory = (text.match(/\| \d+ \|.*\| 🟡/g) || []).length
77
+ const style = (text.match(/\| \d+ \|.*\| 🔵/g) || []).length
78
+ // Protocol: zero 🔴 rows in the review table = pass (phrase fallback for
79
+ // table-free summaries like "No issues found").
80
+ if (critical === 0 && (/\| \d+ \|/.test(text)
81
+ || /no\s+🔴|all.*(?:resolved|fixed|pass)|pass(?:es|ed)?\b|no\s+(?:critical\s+)?issues?/i.test(text))) {
82
+ return "advisor: passed"
83
+ }
84
+ const parts = []
85
+ if (critical) parts.push(`${critical} critical`)
86
+ if (advisory) parts.push(`${advisory} advisory`)
87
+ if (style) parts.push(`${style} style`)
88
+ if (parts.length === 0) return null
89
+ return `advisor: ${parts.join(", ")}`
90
+ }
91
+
92
+ function _verifySummary(result) {
93
+ const lines = result.split("\n")
94
+ const summary = []
95
+ // Changed files count
96
+ const changed = lines.find((l) => l.startsWith("Changed files:"))
97
+ if (changed) {
98
+ const m = changed.match(/files changed/) ? changed.replace(/^Changed files \(.*?\)/, "Changed files") : changed
99
+ summary.push(m)
100
+ }
101
+ // Syntax check results
102
+ const syntax = lines.filter((l) => l.startsWith(" ✗"))
103
+ if (syntax.length > 0) {
104
+ summary.push(`${syntax.length} syntax error(s)`)
105
+ }
106
+ // Test results
107
+ const testLine = lines.find((l) => l.startsWith("✓ Tests passed.") || l.startsWith("✗ Tests FAILED"))
108
+ if (testLine) summary.push(testLine.trim())
109
+ // Task list
110
+ const taskLine = lines.find((l) => l.startsWith("Task list:"))
111
+ if (taskLine) summary.push(taskLine)
112
+ return summary.length > 0 ? `verify: ${summary.join(" — ")}` : ""
113
+ }