thincoder 0.12.50 → 0.12.52

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 (49) hide show
  1. package/CHANGELOG.md +64 -3
  2. package/README.md +2 -2
  3. package/package.json +4 -3
  4. package/src/acp/bridge.mjs +5 -0
  5. package/src/agent/dispatch.mjs +19 -7
  6. package/src/agent/helpers.mjs +13 -1
  7. package/src/agent/record-results.mjs +130 -0
  8. package/src/agent/setup.mjs +4 -7
  9. package/src/agent/spawn-child.mjs +159 -0
  10. package/src/agent-tools/consult.mjs +94 -73
  11. package/src/agent-tools/escalate.mjs +53 -62
  12. package/src/agent-tools/skill.mjs +1 -1
  13. package/src/agent-tools/subagent.mjs +39 -38
  14. package/src/agent-tools/task.mjs +0 -2
  15. package/src/agent-tools/verify.mjs +0 -1
  16. package/src/agent.mjs +27 -112
  17. package/src/config.mjs +8 -103
  18. package/src/generate-title.mjs +30 -1
  19. package/src/model-specs.mjs +108 -0
  20. package/src/prompts/advisor-round1.md +5 -6
  21. package/src/prompts/advisor-round2.md +3 -4
  22. package/src/prompts/advisor-round3.md +3 -4
  23. package/src/prompts/eng-coder.md +9 -0
  24. package/src/prompts/engineering.md +61 -9
  25. package/src/prompts/system.md +2 -2
  26. package/src/provider/core.mjs +5 -71
  27. package/src/provider/normalize.mjs +81 -0
  28. package/src/session.mjs +40 -1
  29. package/src/tools/git.mjs +3 -3
  30. package/src/tools/shared.mjs +1 -0
  31. package/src/tools/system.mjs +3 -1
  32. package/src/tui/agent-turn.mjs +37 -364
  33. package/src/tui/clipboard.mjs +3 -1
  34. package/src/tui/dims.mjs +47 -0
  35. package/src/tui/fold-block.mjs +208 -0
  36. package/src/tui/index.mjs +33 -17
  37. package/src/tui/key-handler-search.mjs +1 -1
  38. package/src/tui/key-handler.mjs +10 -6
  39. package/src/tui/layout.mjs +21 -20
  40. package/src/tui/mouse.mjs +9 -6
  41. package/src/tui/pickers.mjs +1 -1
  42. package/src/tui/render-conversation.mjs +367 -113
  43. package/src/tui/render-frame.mjs +16 -90
  44. package/src/tui/render-loop.mjs +12 -8
  45. package/src/tui/render.mjs +16 -0
  46. package/src/tui/startup.mjs +66 -13
  47. package/src/tui/subagent-blocks.mjs +327 -0
  48. package/src/tui/tool-args.mjs +67 -0
  49. package/src/tui/tool-events.mjs +459 -0
@@ -1,70 +1,86 @@
1
1
  /**
2
2
  * render-conversation.mjs — conversation panel line builder
3
3
  * Extracted from render-frame.mjs.
4
+ *
5
+ * 2026-08-30: all six fold sites (frozen/running subagent blocks, frozen/live
6
+ * advisor, long-message, consecutive-dim) delegate their EXPANDED-state
7
+ * rendering to the shared fold-block.mjs component, which caps expansion at
8
+ * 60% of the terminal height so the collapse control always stays reachable
9
+ * (user report: an expanded block could push its own control off-screen).
10
+ * maxRows flows in from every caller; omitted/0 → uncapped (tests, odd envs).
4
11
  */
5
12
  import { ansi, C } from "./ansi.mjs"
6
- import { formatTables, sanitizeDisplay, stringWidth, wrapText } from "./render.mjs"
7
- import { renderMarkdownInline, renderMarkdownHeading } from "./markdown.mjs"
8
- import { renderMathInline, renderMathBlock } from "./math.mjs"
13
+ import { formatTables, sanitizeDisplay, sliceByWidth, wrapText } from "./render.mjs"
14
+ import {
15
+ isExpanded, foldHintLine, renderExpandedBlock, renderBlockTimeline,
16
+ renderMathAndMarkdown, foldCapRows, renderFoldedHead, foldTailLines,
17
+ } from "./fold-block.mjs"
18
+ import { ADVISOR_THINKING_PLACEHOLDER } from "../advisor/run.mjs"
9
19
 
10
- let _convCache = { key: "", cols: 0, lines: [] }
11
-
12
- /**
13
- * Render markdown markers to ANSI, then pad the line tail back to the pre-render
14
- * display width. Markers (`` ` ``, `**`, `~~`) vanish on render — without the
15
- * compensation, table rows containing them display shorter than the column widths
16
- * computed by formatTables and the borders misalign (reported regression).
17
- * @param {string} text — plain text line (no ANSI yet), already wrapped
18
- * @returns {string} ANSI-rendered line whose display width equals stringWidth(text)
19
- */
20
- function renderMarkdownPreservingWidth(text) {
21
- // Line-by-line: render + compensate per line. The per-line padding serves
22
- // NON-table text (so `**bold** text` next to plain text keeps its width).
23
- // Table alignment is NOT provided by the padding — formatTables strips cell
24
- // padding during trim and recomputes widths from the RENDERED text (that is
25
- // the render-before-measure contract).
26
- return text.split("\n").map((line) => {
27
- const rendered = renderMarkdownInline(renderMarkdownHeading(line))
28
- const diff = stringWidth(line) - stringWidth(rendered)
29
- return diff > 0 ? rendered + " ".repeat(diff) : rendered
30
- }).join("\n")
31
- }
32
20
 
33
- // Math runs BEFORE markdown (TUI.md §9.1D): `$...$`/`$$...$$` are opaque to markdown
34
- // (so `x**2` inside a formula isn't misread as bold), and the Unicode approximation
35
- // is measured by renderMarkdownPreservingWidth's width-compensation math.
36
- function renderMathAndMarkdown(text) {
37
- return renderMarkdownPreservingWidth(renderMathInline(renderMathBlock(text)))
38
- }
39
- // Test seam (mirrors the _-prefixed seams in run.mjs).
40
- export { renderMarkdownPreservingWidth as _renderMarkdownPreservingWidth }
21
+ // Test seam (mirrors the _-prefixed seams in run.mjs) moved to fold-block.mjs.
22
+ import { renderMarkdownPreservingWidth as _rmpw } from "./fold-block.mjs"
23
+ export { _rmpw as _renderMarkdownPreservingWidth }
41
24
 
25
+ let _convCache = { key: "", cols: 0, lines: [] }
42
26
 
43
- export function convCacheKey(state) {
27
+ export function convCacheKey(state, maxRows) {
44
28
  const lastLine = state.lines.length > 0 ? state.lines[state.lines.length - 1] : null
45
29
  // expandedBlocks participates: expanding/folding a block must invalidate the cache
46
30
  const exp = state.expandedBlocks ? [...state.expandedBlocks].sort().join(",") : ""
47
31
  // Content prefix in the signature: same kind+length with different content
48
32
  // would otherwise collide (stale render); 8 chars disambiguate in practice.
49
33
  const blocksSig = (state._advisorBlocks ?? []).map((b) => `${b.kind}:${b.text?.length ?? 0}:${String(b.text ?? "").slice(0, 8)}`).join(",")
50
- return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${blocksSig}|${state.foldEnabled !== false ? "f" : "u"}|${exp}`
34
+ // Subagent activity blocks (§7.2 D5): O(1) counter-style signature a running
35
+ // epoch (any block/header change bumps it, subagent-blocks.mjs appendSubBlock /
36
+ // finishSubTask) + per-child totals. Running children also fold in a
37
+ // second-granularity elapsed part (see below). NOT a full text concat: N3
38
+ // forbids O(n) signature cost on every token.
39
+ let subSig = ""
40
+ for (const key in state.subTasks) {
41
+ const s = state.subTasks[key]
42
+ // Running children include a second-granularity elapsed component: the 1s
43
+ // ticker re-renders to tick the header countdown — without this the cache
44
+ // would hit and the "45s" display would freeze during silent stretches
45
+ // (long child tool runs with no chunks). Done children are constant — no
46
+ // per-second invalidation for them.
47
+ const elapsedPart = s.done ? "" : `:${Math.floor((Date.now() - s.started) / 1000)}`
48
+ subSig += `${key}:${s.done ? 1 : 0}${elapsedPart}:${s.turn}/${s.maxTurns}:${s.currentTool ?? ""}:${s.approval ?? ""}:${s.blockEpoch ?? 0}:${s.model ?? ""};`
49
+ }
50
+ // Frozen blocks ride state.lines ({_frozenSubTask}) — the lines.length part of
51
+ // this key covers their existence; expanding/collapsing one flips expandedBlocks
52
+ // (covered by `exp`). One extra: the last frozen payload's header depends on
53
+ // blocks content which never changes post-freeze — nothing more needed.
54
+ // Same single pass also builds the per-line COLOR-CLASS signature: foldability
55
+ // is decided by color class since main output (C.text) never folds while
56
+ // thinking/dim do (2026-08-30) — two states differing only in line color used
57
+ // to collide on this key and serve a stale cached render.
58
+ // Tool-block carriers ({_toolBlock}) contribute their BUFFER SIZE signature:
59
+ // output/result arrays mutate in place (streaming appends, result landing),
60
+ // and carrier text is always "" — without this the cache serves a stale block
61
+ // while a tool runs (and across live→restore with equal line counts).
62
+ let frozenSig = ""
63
+ let colorSig = ""
64
+ let toolSig = ""
65
+ for (const l of state.lines) {
66
+ if (l._frozenSubTask) frozenSig += `${l._frozenSubTask.key};`
67
+ if (l._toolBlock) toolSig += `${l._toolBlock.done ? 1 : 0}:${l._toolBlock.output.length}:${l._toolBlock.result ? l._toolBlock.result.length : 0}:${l._toolBlock.summary ?? ""}:${l._toolBlock.elapsed ?? ""};`
68
+ colorSig += l.color === C.text ? "T" : l.color === C.dim ? "D" : l.color === C.reason ? "R" : "o"
69
+ }
70
+ // Expansion cap participates: a terminal resize changes the cap, which changes
71
+ // the rendered height of every expanded block — the cache must not survive it.
72
+ const capPart = maxRows ? `cap${foldCapRows(maxRows)}` : "cap∞"
73
+ // Search state participates: highlightSearchMatches re-renders the matching
74
+ // lines, but performSearch only mutates state.search/_searchMatches — without
75
+ // this the cache would serve the pre-search rows and highlight would never
76
+ // appear (P0-1, 2026-08-30 consult). query+index covers match navigation.
77
+ const searchPart = state.search?.query ? `${state.search.query}:${state.search.index ?? 0}` : ""
78
+ return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${blocksSig}|${subSig}|${frozenSig}|${toolSig}|${colorSig}|${state.foldEnabled !== false ? "f" : "u"}|${exp}|${capPart}|${searchPart}`
51
79
  }
52
80
 
53
81
  /** Fold marker line: bold-cyan icon + "click to …" phrase underlined (clickable affordance).
54
82
  * No indent — flush with the content below it; the caller adds a blank line BEFORE it
55
83
  * so the control line stands apart from unrelated content (reported UX). */
56
- function foldHintLine(text, foldKey, srcIdx) {
57
- // Underline just the actionable phrase — link/button convention
58
- const withUnderline = text.replace(/(click to (?:expand|collapse))/, "\x1b[4m$1\x1b[24m")
59
- return { text: withUnderline, color: C.fold, _foldToggle: foldKey, _src: srcIdx }
60
- }
61
-
62
- /** Blank separator before a fold control line (uncolored — must not join consecutive-dim folding).
63
- * Only the EXPANDED state uses it (▼ sits at the block head); the folded state's ▶
64
- * control line sits mid-block where the ellipsis used to be, so no separator needed. */
65
- function blankLine() {
66
- return { text: "", color: "" }
67
- }
68
84
 
69
85
  function highlightSearchMatches(text, query, matchesInLine, globalCurrentIndex, allMatches, lineIndex) {
70
86
  if (!matchesInLine || matchesInLine.length === 0 || !query) return text
@@ -90,24 +106,157 @@ function highlightSearchMatches(text, query, matchesInLine, globalCurrentIndex,
90
106
  return result
91
107
  }
92
108
 
109
+ /** Render a FROZEN child activity block carried on a state.lines entry
110
+ * (subagent-blocks.mjs freezeSubTaskLines pushes {_frozenSubTask: sub}). Identical
111
+ * interaction to the running tail section: folded = `[✓ coder#1 · glm-5.3 ·
112
+ * done 45s · turn 12/100] … click to expand` header + tail 3 block lines;
113
+ * expanded = blank + ▼ control + full timeline (60% screen cap via the shared
114
+ * component — capped view ends in a reachable collapse control). Toggle key
115
+ * `sub-${key}` — the SAME key the live section uses, so fold state carries
116
+ * across the freeze boundary seamlessly (user ruled 2026-08-30: frozen stays
117
+ * clickable — full design interaction, not a dim-lines fallback). */
118
+ function frozenSubTaskLines(state, sub, cols, maxRows) {
119
+ const foldKey = `sub-${sub.key}`
120
+ const elapsed = Math.floor(((sub.doneAt ?? Date.now()) - sub.started) / 1000)
121
+ const modelPart = sub.model ? ` · ${sub.model}` : ""
122
+ const turnPart = sub.maxTurns > 0 ? ` · turn ${sub.turn}/${sub.maxTurns}` : ""
123
+ const errPart = sub.lastError ? ` — ${sub.lastError}` : ""
124
+ const icon = sub.approval ? "⏸" : "✓"
125
+ const header = `[${icon} ${sub.key}${modelPart} · done ${elapsed}s${turnPart}${errPart}]`
126
+ const out = []
127
+ if (isExpanded(state, foldKey)) {
128
+ // Expanded: shared component renders blank + ▼ control + full timeline,
129
+ // capped at 60% of the screen with a bottom collapse control.
130
+ const body = renderBlockTimeline(sub.blocks, cols)
131
+ out.push(...renderExpandedBlock({ body, foldKey, state, maxRows, cols, label: "subagent activity" }))
132
+ } else {
133
+ // Folded: the header line itself is the control (▶ affordance), then tail 3.
134
+ out.push({
135
+ text: `▶ ${header} … subagent activity — click to expand`,
136
+ color: C.dim,
137
+ _foldToggle: foldKey,
138
+ })
139
+ for (const line of foldTailLines(sub.blocks)) {
140
+ out.push({ text: `│ ${sliceByWidth(line, cols - 4)}`, color: C.dim, _skipDimFold: true })
141
+ }
142
+ }
143
+ return out
144
+ }
145
+
93
146
  /**
94
147
  * Build the conversation lines for the given state.
148
+ * maxRows: terminal rows for the 60% expansion cap (undefined → uncapped —
149
+ * unit tests and callers without a terminal rely on that).
95
150
  * NOTE: module-level _convCache is read/written as a side effect (keyed by
96
151
  * convCacheKey + cols) — the function is pure w.r.t. its input except for
97
152
  * that cache; direct callers outside renderConversation/countConvLines
98
153
  * should be aware the cache persists across calls.
99
154
  */
100
- function buildConvLines(state, cols) {
101
- const key = convCacheKey(state)
155
+ function buildConvLines(state, cols, maxRows) {
156
+ const key = convCacheKey(state, maxRows)
102
157
  if (_convCache.key === key && _convCache.cols === cols) return _convCache.lines
103
158
 
104
159
  const convLines = []
105
- // Folding constants (function scope — used by both the long-message fold below
106
- // and the consecutive-dim fold at the bottom)
160
+ // Folding constants (function scope)
107
161
  const LONG_FOLD_LINES = 12
108
- const FOLD_KEEP = 5 // content lines kept in the folded state (first 4 + last 1)
162
+ let blankAfter = false
163
+ // THINKING ALWAYS FOLDS (user ruling 2026-08-30, final): no threshold of any
164
+ // kind — row thresholds died twice on the user's real screen (12 never met on
165
+ // narrow, 3 never met on wide), a char threshold missed typical sentences.
166
+ // Thinking is process content: it renders as the named "▶ thinking" block,
167
+ // expand ≤60%, click back. No exceptions, streaming included.
109
168
  for (let i = 0; i < state.lines.length; i++) {
110
169
  const l = state.lines[i]
170
+ // Main-output breathing room (user request 2026-08-30): a blank line before
171
+ // and after each main-output segment (assistant replies / user text — the
172
+ // C.text rows) so the conversation body stands apart from thinking / tool /
173
+ // subagent blocks. Blank lines are RENDER-only (never written to
174
+ // state.lines) — convCacheKey is unaffected, and adjacent segments share
175
+ // one blank line (the trailing blank of segment N and the leading blank of
176
+ // segment N+1 must not stack into a double row).
177
+ const isMain = l._kind === "text" || (l._kind === undefined && l.color === C.text)
178
+ const pushBlank = () => {
179
+ if (convLines.at(-1)?.text !== "") convLines.push({ text: "", color: C.text })
180
+ }
181
+ if (isMain) {
182
+ const prev = i > 0 ? state.lines[i - 1] : null
183
+ const next = state.lines[i + 1]
184
+ const prevMain = prev && (prev._kind === "text" || (prev._kind === undefined && prev.color === C.text))
185
+ const nextMain = next && (next._kind === "text" || (next._kind === undefined && next.color === C.text))
186
+ if (!prevMain) pushBlank()
187
+ blankAfter = !nextMain
188
+ }
189
+ // Frozen subagent activity block (§7.2 D4, 2026-08-30): rendered as its own
190
+ // collapsible section — clickable expand/collapse like the running block.
191
+ if (l._frozenSubTask) {
192
+ convLines.push(...frozenSubTaskLines(state, l._frozenSubTask, cols, maxRows))
193
+ continue
194
+ }
195
+ // ONE BLOCK PER TOOL CALL (2026-08-30 user ruling): header = name+args+
196
+ // live status, body = args JSON + streaming output + result. Folded =
197
+ // ▶ name args · status/summary; expanded = 60%-capped body (shared component).
198
+ if (l._toolBlock) {
199
+ const b = l._toolBlock
200
+ // Stable key from the line's own id (P1 2026-08-30): the line may shift
201
+ // index when loadOlder unshifts older pages — positional tool-${i} would
202
+ // re-bind the expand state to a different tool block.
203
+ const foldKey = `tool-${l._lineId ?? i}`
204
+ const status = !b.done
205
+ ? "running"
206
+ : `${b.elapsed !== null ? b.elapsed + "ms" : ""}${b.summary ? (b.elapsed !== null ? " · " : "") + sliceByWidth(b.summary, 50) : ""}`.trim() || "done"
207
+ if (isExpanded(state, foldKey)) {
208
+ const body = []
209
+ const pushWrapped = (raw, color) => {
210
+ for (const w of wrapText(raw, cols - 4)) body.push({ text: " " + w, color, _skipDimFold: true })
211
+ }
212
+ for (const jl of b.argsJson) pushWrapped(jl, C.dim)
213
+ for (const ol of b.output) pushWrapped(ol, C.tool)
214
+ if (b.result) for (const rl of b.result) pushWrapped(rl, C.dim)
215
+ convLines.push(...renderExpandedBlock({ body, foldKey, state, maxRows, cols, label: `${b.name}${b.roundTag || ""} ${b.argsSummary}`.trim() }))
216
+ } else {
217
+ // Head MUST be width-bounded: argsSummary for unknown/MCP tools is a
218
+ // JSON.stringify dump that can be thousands of chars — an overwide header
219
+ // row makes the terminal soft-wrap mid-frame, shifting every panel below
220
+ // (the "code breaks the input box border" report, 2026-08-30).
221
+ const headText = sliceByWidth(
222
+ `❯ ${b.name}${b.roundTag || ""}${b.argsSummary ? " " + b.argsSummary : ""} · ${status}`,
223
+ Math.max(20, cols - 2),
224
+ )
225
+ const body = []
226
+ for (const jl of b.argsJson) for (const w of wrapText(jl, cols - 4)) body.push({ text: w, color: C.dim, _skipDimFold: true })
227
+ for (const ol of b.output.slice(-3)) for (const w of wrapText(ol, cols - 4)) body.push({ text: w, color: C.dim, _skipDimFold: true })
228
+ // Result lines join the tail pool too — restore carrier has no output
229
+ // rows, so without this its folded tail showed only args JSON and the
230
+ // result vanished from the folded view (parity bug, 2026-08-30).
231
+ if (b.result) for (const rl of b.result) for (const w of wrapText(rl, cols - 4)) body.push({ text: w, color: C.dim, _skipDimFold: true })
232
+ convLines.push(...renderFoldedHead({ header: { text: headText, color: C.tool, _foldToggle: foldKey }, body, cols }))
233
+ }
234
+ continue
235
+ }
236
+ // Frozen advisor review (2026-08-30): same collapsible-box treatment —
237
+ // folded = one control line; expanded = the full review text (markdown
238
+ // rendered, no gutter — review history convention kept from the flat era),
239
+ // 60% cap via the shared component.
240
+ if (l._frozenAdvisor) {
241
+ const frozenAdvKey = `advisor-done-${i}`
242
+ if (isExpanded(state, frozenAdvKey)) {
243
+ const body = []
244
+ const rendered = renderMathAndMarkdown(sanitizeDisplay(l._frozenAdvisor))
245
+ for (const line of formatTables(rendered, cols - 1)) {
246
+ for (const wrapped of wrapText(line, cols - 1)) {
247
+ body.push({ text: wrapped, color: C.reason, _skipDimFold: true })
248
+ }
249
+ }
250
+ convLines.push(...renderExpandedBlock({ body, foldKey: frozenAdvKey, state, maxRows, cols, label: "[advisor · review done]" }))
251
+ } else {
252
+ convLines.push({
253
+ text: `▶ [advisor · review done] … click to expand`,
254
+ color: C.fold,
255
+ _foldToggle: frozenAdvKey,
256
+ })
257
+ }
258
+ continue
259
+ }
111
260
  let text = l.text
112
261
 
113
262
  // Apply search highlighting
@@ -115,16 +264,27 @@ function buildConvLines(state, cols) {
115
264
  text = highlightSearchMatches(text, state.search.query, l._searchMatches, state.search.index, state.search.matches, i)
116
265
  }
117
266
 
118
- // Long-message folding: ANY single line (main output C.text, thinking C.reason,
119
- // tool summaries C.dimwhatever wraps beyond LONG_FOLD_LINES display rows)
120
- // collapses to [first 4, ▶, last]; expanded long blocks render as
121
- // [blank, ▼, every line]. Main output and thinking are the REAL long
122
- // content; bidirectional folding (collapse markers + click toggle) keeps
123
- // them readable the 0.12.7 dim-only restriction was a temporary fix for
124
- // the single-direction era and is now reverted. Keyed by the source-line
125
- // index (`long-${i}`) so the toggle survives re-renders.
267
+ // Long-message folding (2026-08-30 user ruling): MAIN OUTPUT / user messages
268
+ // (C.text) NEVER fold primary conversation content is read by scrolling,
269
+ // not by expanding; a folded core answer hid the actual result behind a
270
+ // click. Foldable subjects narrow to THINKING (C.reason) and dim tool
271
+ // summaries the auxiliary streams. (This re-enacts the pre-0.12.7 rule
272
+ // for main output only; the 0.12.7 "revert" had reopened folding for it.)
273
+ // Keyed by the source-line index (`long-${i}`) so the toggle survives
274
+ // re-renders.
126
275
  const longKey = `long-${i}`
127
- const folded = state.foldEnabled !== false && !state.expandedBlocks?.has(longKey)
276
+ // Single source of truth: the producer stamps _kind ("thinking" / "text" /
277
+ // "tool") — buildConvLines READS the stamp instead of GUESSING from color.
278
+ // Three producers (live flushStream / restored historyToLines / injected
279
+ // lines) now emit the identical grammar; the renderer is one place.
280
+ // Fallback: unstamped lines keep the legacy color-based inference (defensive
281
+ // for any path this refactor missed — empty until proven otherwise).
282
+ const isReasoning = l._kind === "thinking" || (l._kind === undefined && l.color === C.reason)
283
+ // Foldable classes: thinking (ALWAYS — threshold 0) and dim auxiliaries.
284
+ // "text" (main output / user messages) NEVER folds.
285
+ const foldable = isReasoning || (l._kind === "tool" || (l._kind === undefined && l.color === C.dim) || (l._kind === undefined && l.color !== C.text && l.color !== C.reason))
286
+ const threshold = isReasoning ? 0 : LONG_FOLD_LINES
287
+ const folded = foldable && state.foldEnabled !== false && !state.expandedBlocks?.has(longKey)
128
288
  const block = []
129
289
  // Lightweight markdown display (IK5VW3): render BEFORE measuring — the
130
290
  // table column math (formatTables) and wrapping must see the RENDERED
@@ -138,69 +298,162 @@ function buildConvLines(state, cols) {
138
298
  block.push({ text: wrapped, color: l.color, _foldId: l._foldId, _src: i })
139
299
  }
140
300
  }
141
- if (folded && block.length > LONG_FOLD_LINES) {
142
- // Folded state: first 4 content lines, then the ▶ control line where the
143
- // ellipsis used to be (the marker itself reads "… N more lines" — ellipsis
144
- // semantics built in), then the last line. No leading blank line needed:
145
- // the block starts with real content now.
146
- convLines.push(...block.slice(0, FOLD_KEEP - 1))
147
- convLines.push(foldHintLine(`▶ … ${block.length - FOLD_KEEP} more lines — click to expand`, longKey, i))
148
- convLines.push(block[block.length - 1])
149
- } else if (block.length > LONG_FOLD_LINES) {
301
+ if (folded && block.length > threshold) {
302
+ // FOLDED unified form (fold-block.mjs renderFoldedHead, 2026-08-30 user
303
+ // ruling): named identity header + last 3 lines. Replaces the legacy
304
+ // [first 4, anonymous at the ellipsis, last] whose orphaned-looking
305
+ // "… N more lines" segment confused the scrollback.
306
+ const kind = l.color === C.reason ? "thinking" : l.color === C.dim ? "tool output" : "message"
307
+ convLines.push(...renderFoldedHead({
308
+ header: foldHintLine(`▶ ${kind} · ${block.length} lines — click to expand`, longKey, i),
309
+ body: block, cols,
310
+ }))
311
+ } else if (foldable && block.length > threshold) {
150
312
  if (state.foldEnabled === false) {
151
313
  // Folding fully off — content already fully visible; a "click to
152
314
  // collapse" hint would be misleading (toggling has no effect).
153
315
  convLines.push(...block)
154
316
  } else {
155
- // EXPANDED long block: blank line + ▼ control line at the HEAD, directly
156
- // before the content. DIM blocks must not re-trigger the consecutive-dim
157
- // folding below (folding stacked on folding reported regression).
317
+ // EXPANDED thinking/dim long block via the shared component: blank + ▼
318
+ // control at the HEAD, content, 60% cap with a bottom collapse control.
319
+ // DIM blocks must not re-trigger the consecutive-dim folding below
320
+ // (folding stacked on folding — reported regression).
158
321
  if (l.color === C.dim) {
159
322
  for (const line of block) line._skipDimFold = true
160
323
  }
161
- convLines.push(blankLine())
162
- convLines.push(foldHintLine(`▼ … ${block.length} lines — click to collapse`, longKey, i))
163
- convLines.push(...block)
324
+ convLines.push(...renderExpandedBlock({ body: block, foldKey: longKey, state, maxRows, cols, label: `${block.length} lines` }))
164
325
  }
165
326
  } else {
166
327
  convLines.push(...block)
167
328
  }
329
+ // Trailing blank after a main-output segment (user request 2026-08-30) —
330
+ // landed after the segment's rendered content.
331
+ if (blankAfter) {
332
+ pushBlank()
333
+ blankAfter = false
334
+ }
335
+ }
336
+ // ── Subagent activity blocks (§7.2 D4) — RUNNING blocks only ──────────────
337
+ // Rendered BEFORE the advisor blocks section. A child's block lives here only
338
+ // while it runs: on completion onToolResult freezes the block into state.lines
339
+ // (subagent-blocks.mjs freezeSubTaskLines) so it scrolls away with the conversation
340
+ // instead of staying pinned above the input box ("ghost" report 2026-08-30).
341
+ // Default folded = header summary line (▶ role#id · model · elapsed · turn
342
+ // n/max | current state) + tail 3 block lines; expanded = shared component
343
+ // (full timeline, 60% screen cap).
344
+ const runningSubs = Object.values(state.subTasks ?? {}).filter((s) => !s.done)
345
+ if (runningSubs.length > 0) {
346
+ // Divider between the conversation body and the running-subagent band
347
+ // (user request 2026-08-30, mirroring the task-panel divider in
348
+ // render-frame renderTodo). Only when at least one block actually renders —
349
+ // done children are frozen into state.lines above, so a divider for an
350
+ // empty band would hang over the section boundary.
351
+ // Unconditional divider (task-panel style): the preceding main-output
352
+ // trailing blank is breathing room, the divider is the section boundary —
353
+ // both belong. Only dedupe against another divider (idempotent re-render).
354
+ if (convLines.at(-1)?.color !== C.dim || !convLines.at(-1)?.text?.startsWith("─")) {
355
+ convLines.push({ text: "─".repeat(Math.max(1, cols - 1)), color: C.dim, _skipDimFold: true })
356
+ }
357
+ for (const sub of runningSubs) {
358
+ // Done children never reach this loop (filtered above): they are frozen
359
+ // into state.lines by subagent-blocks.mjs freezeSubTaskLines and removed
360
+ // from subTasks — a restored leftover would otherwise pin at the tail.
361
+ const foldKey = `sub-${sub.key}`
362
+ // Header summary: `[▶ coder#1 · glm-5.3 · 45s · turn 12/100] bash — npm test`
363
+ const icon = sub.approval ? "⏸" : "▶"
364
+ const elapsed = Math.floor(((sub.done ? sub.doneAt : Date.now()) - sub.started) / 1000)
365
+ const modelPart = sub.model ? ` · ${sub.model}` : ""
366
+ const turnPart = sub.maxTurns > 0 ? ` · turn ${sub.turn}/${sub.maxTurns}` : ""
367
+ let statePart
368
+ if (sub.approval) statePart = `等待审批: ${sub.approval}`
369
+ else if (sub.done) {
370
+ statePart = `done ${elapsed}s${sub.lastError ? ` — ${sub.lastError}` : ""}`
371
+ } else if (sub.currentTool) statePart = sub.currentTool
372
+ else statePart = "thinking..."
373
+ const argSummary = sub.currentTool && sub.toolArgs?.command
374
+ ? ` — ${String(sub.toolArgs.command).replace(/\s+/g, " ").trim().slice(0, 60)}`
375
+ : ""
376
+ convLines.push({
377
+ text: `[${icon} ${sub.key}${modelPart} · ${elapsed}s${turnPart}] ${sliceByWidth(statePart + argSummary, Math.max(20, cols - 30))}`,
378
+ color: sub.done ? C.dim : C.tool,
379
+ _foldToggle: foldKey,
380
+ })
381
+ if (isExpanded(state, foldKey)) {
382
+ // Full activity timeline via the shared component (per-kind colors,
383
+ // 60% screen cap — the header control may sit above the viewport once
384
+ // expanded, the capped bottom control stays reachable).
385
+ const body = renderBlockTimeline(sub.blocks, cols)
386
+ convLines.push(...renderExpandedBlock({ body, foldKey, state, maxRows, cols, label: "subagent activity" }))
387
+ } else {
388
+ // Folded: tail 3 non-empty block lines (most recent activity), dim.
389
+ for (const line of foldTailLines(sub.blocks)) {
390
+ convLines.push({ text: `│ ${sliceByWidth(line, cols - 4)}`, color: C.dim })
391
+ }
392
+ }
393
+ }
168
394
  }
169
395
  if (state.reasoning) {
396
+ // Live thinking streams INSIDE the unified box (user ruling 2026-08-30:
397
+ // "思考过程中为什么不是直接进这个框" — the flat tail render was a
398
+ // pre-fold-era leftover). Same folded form as flushed blocks: named header
399
+ // + tail 3, click expands to the 60%-capped live view. The buffer grows
400
+ // per token; convCacheKey already includes state.reasoning.length so the
401
+ // box updates live. Single instance key — one live stream at a time; on
402
+ // flush the block re-keys to `long-{idx}` with the identical form (seamless).
403
+ const liveKey = "thinking-live"
404
+ const body = []
170
405
  for (const wrapped of wrapText(sanitizeDisplay(state.reasoning), cols - 1)) {
171
- convLines.push({ text: wrapped, color: C.reason })
406
+ body.push({ text: wrapped, color: C.reason, _skipDimFold: true })
407
+ }
408
+ const expanded = isExpanded(state, liveKey)
409
+ if (expanded) {
410
+ convLines.push(...renderExpandedBlock({ body, foldKey: liveKey, state, maxRows, cols, label: "thinking (streaming)" }))
411
+ } else {
412
+ convLines.push(...renderFoldedHead({
413
+ header: foldHintLine(`▶ thinking · ${body.length} lines — click to expand`, liveKey),
414
+ body, cols,
415
+ }))
172
416
  }
173
417
  }
174
418
  const advisorBlocks = state._advisorBlocks ?? []
175
419
  if (advisorBlocks.length > 0) {
176
- // ORDERED block display the blocks preserve the emission order
177
- // (think tool think final) and render as one interleaved
178
- // stream: thinking in reasoning color, tool progress/final in text color.
179
- // Full-length, no preview truncation; long content scrolls via the
180
- // conversation window like everything else.
181
- // NOTE: formatTables returns an ARRAY of lines (not a string) calling
182
- // .split on it crashed the whole render (tools/final never displayed).
183
- for (const block of advisorBlocks) {
184
- const color = { think: C.reason, tool: C.tool, text: C.text }[block.kind] ?? C.text
185
- const source = sanitizeDisplay(block.text)
186
- // kind:"text" (the final review prose) gets the same lightweight markdown
187
- // styling as the main agent response. Rendered BEFORE measuring: the
188
- // width math (formatTables / wrapText) must see the RENDERED text —
189
- // measuring raw markdown (`**bold**` = 8) against displayed text (4)
190
- // misaligned table columns; wrapping raw markdown sliced markers
191
- // mid-sequence (`**bo` + `ld**`) so the renderer never saw complete ones.
192
- const rows = block.kind === "think"
193
- ? source.split("\n")
194
- : formatTables(block.kind === "text" ? renderMathAndMarkdown(source) : source, cols - 3)
195
- for (const line of rows) {
196
- for (const wrapped of wrapText(line, cols - 3)) {
197
- convLines.push({ text: `│ ${wrapped}`, color })
198
- }
420
+ // ── Advisor review block (2026-08-30): collapsible in-conversation box ──
421
+ // The live stream used to render flat into the conversation and flooded it
422
+ // (user report). Same interaction as subagent blocks: default FOLDED =
423
+ // header (running/done + total line count) + tail 3 block lines; expanded =
424
+ // shared component (▼ control + ordered per-kind timeline — think/tool/text
425
+ // colors kept, T-F regression; placeholder markers stripped in every view
426
+ // unified with the folded tail which always stripped them). Toggle key
427
+ // `advisor-blocks` (single instance one advisor runs at a time).
428
+ const advKey = "advisor-blocks"
429
+ // Total rendered line count of the timeline (cheap: rough split, no wrap math)
430
+ const advLineCount = advisorBlocks.reduce((n, b) => n + sanitizeDisplay(b.text).split("\n").filter((l) => l.trim()).length, 0)
431
+ const advHeader = `[advisor · review] ${advLineCount} lines`
432
+ if (isExpanded(state, advKey)) {
433
+ const body = renderBlockTimeline(advisorBlocks, cols, { strip: [ADVISOR_THINKING_PLACEHOLDER] })
434
+ convLines.push(...renderExpandedBlock({ body, foldKey: advKey, state, maxRows, cols, label: advHeader }))
435
+ } else {
436
+ // Folded: header control line + tail 3 non-empty lines from the tail
437
+ // blocks (most recent activity), dim — mirrors the subagent block fold.
438
+ convLines.push({
439
+ text: `▶ ${advHeader} click to expand`,
440
+ color: C.fold,
441
+ _foldToggle: advKey,
442
+ })
443
+ for (const line of foldTailLines(advisorBlocks, 3, { strip: [ADVISOR_THINKING_PLACEHOLDER] })) {
444
+ convLines.push({ text: `│ ${sliceByWidth(line, cols - 4)}`, color: C.dim, _skipDimFold: true })
199
445
  }
200
446
  }
201
447
  }
202
448
  if (state.streaming) {
203
- // Rendered BEFORE formatTables see the advisor-block comment above.
449
+ // Main-output breathing room (2026-08-30): the streaming path renders OUTSIDE
450
+ // the line loop (the reply is still in its buffer, not yet in state.lines),
451
+ // so the loop's leading blank never applied — a streamed reply showed no
452
+ // blank until flush landed it in lines and a later frame re-rendered. Apply
453
+ // the same leading blank here (the trailing blank belongs to the line path
454
+ // once flushed — mid-stream there is still content to come).
455
+ if (convLines.at(-1)?.text !== "") convLines.push({ text: "", color: C.text })
456
+ // Rendered BEFORE formatTables — see fold-block.mjs for the width contract.
204
457
  const rendered = renderMathAndMarkdown(sanitizeDisplay(state.streaming))
205
458
  for (const line of formatTables(rendered, cols - 1)) {
206
459
  for (const wrapped of wrapText(line, cols - 1)) {
@@ -224,21 +477,22 @@ function buildConvLines(state, cols) {
224
477
  if (blockLen > FOLD_LINES && !hasExpandedLong) {
225
478
  const foldKey = `fold-${foldCounter++}`
226
479
  if (state.foldEnabled !== false && !state.expandedBlocks?.has(foldKey)) {
227
- // First 4 lines, then the control line (ellipsis position), then the last line
228
- folded.push(...convLines.slice(i, i + FOLD_KEEP - 1))
229
- folded.push(foldHintLine(`▶ … ${blockLen - FOLD_KEEP} more lines — click to expand`, foldKey))
230
- folded.push(convLines[j - 1])
480
+ // FOLDED unified named-header + last-3 form (same ruling as the
481
+ // long-message fold above).
482
+ folded.push(...renderFoldedHead({
483
+ header: foldHintLine(`▶ tool output · ${blockLen} lines — click to expand`, foldKey),
484
+ body: convLines.slice(i, j), cols,
485
+ }))
231
486
  i = j
232
487
  continue
233
488
  }
234
- // EXPANDED consecutive-dim block: blank + ▼ at the HEAD, then every line.
489
+ // EXPANDED consecutive-dim block via the shared component: blank + ▼ at
490
+ // the HEAD, then every line, 60% cap with a bottom collapse control.
235
491
  // foldEnabled=false → raw block, no hint (toggling would be a no-op).
236
492
  if (state.foldEnabled === false) {
237
493
  for (let k = i; k < j; k++) folded.push(convLines[k])
238
494
  } else {
239
- folded.push(blankLine())
240
- folded.push(foldHintLine(`▼ … ${blockLen} lines — click to collapse`, foldKey))
241
- for (let k = i; k < j; k++) folded.push(convLines[k])
495
+ folded.push(...renderExpandedBlock({ body: convLines.slice(i, j), foldKey, state, maxRows, cols, label: `${blockLen} lines` }))
242
496
  }
243
497
  i = j
244
498
  continue
@@ -252,14 +506,14 @@ function buildConvLines(state, cols) {
252
506
  return folded
253
507
  }
254
508
 
255
- export function countConvLines(state, cols) {
256
- return buildConvLines(state, cols).length
509
+ export function countConvLines(state, cols, maxRows) {
510
+ return buildConvLines(state, cols, maxRows).length
257
511
  }
258
512
 
259
513
  export { buildConvLines }
260
514
 
261
- export function renderConversation(state, cols, visibleH, scroll) {
262
- const convLines = buildConvLines(state, cols)
515
+ export function renderConversation(state, cols, visibleH, scroll, maxRows) {
516
+ const convLines = buildConvLines(state, cols, maxRows)
263
517
  const maxScroll = Math.max(0, convLines.length - visibleH)
264
518
  const clamped = Math.min(scroll, maxScroll)
265
519
  const end = convLines.length - clamped