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,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
+ }