thincoder 0.12.49 → 0.12.51

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 (41) hide show
  1. package/CHANGELOG.md +45 -2
  2. package/package.json +4 -3
  3. package/src/acp/bridge.mjs +4 -0
  4. package/src/agent/dispatch.mjs +19 -7
  5. package/src/agent/helpers.mjs +12 -0
  6. package/src/agent/record-results.mjs +130 -0
  7. package/src/agent/setup.mjs +5 -8
  8. package/src/agent/spawn-child.mjs +159 -0
  9. package/src/agent-tools/consult.mjs +95 -73
  10. package/src/agent-tools/escalate.mjs +53 -62
  11. package/src/agent-tools/subagent.mjs +39 -38
  12. package/src/agent.mjs +25 -109
  13. package/src/generate-title.mjs +30 -1
  14. package/src/prompts/advisor-round1.md +5 -6
  15. package/src/prompts/advisor-round2.md +3 -4
  16. package/src/prompts/advisor-round3.md +3 -4
  17. package/src/prompts/eng-coder.md +10 -0
  18. package/src/prompts/engineering.md +81 -14
  19. package/src/prompts/methodology-template.md +8 -3
  20. package/src/prompts/system.md +1 -1
  21. package/src/session.mjs +48 -1
  22. package/src/tools/system.mjs +3 -1
  23. package/src/tui/agent-turn.mjs +44 -363
  24. package/src/tui/cmd-advisor.mjs +20 -2
  25. package/src/tui/cmd-eng.mjs +44 -7
  26. package/src/tui/dims.mjs +74 -0
  27. package/src/tui/fold-block.mjs +208 -0
  28. package/src/tui/index.mjs +53 -16
  29. package/src/tui/key-handler-search.mjs +1 -1
  30. package/src/tui/key-handler.mjs +9 -6
  31. package/src/tui/layout.mjs +21 -20
  32. package/src/tui/mouse.mjs +8 -6
  33. package/src/tui/pickers.mjs +1 -1
  34. package/src/tui/render-conversation.mjs +368 -113
  35. package/src/tui/render-frame.mjs +9 -88
  36. package/src/tui/render-loop.mjs +12 -8
  37. package/src/tui/render.mjs +5 -0
  38. package/src/tui/startup.mjs +67 -13
  39. package/src/tui/subagent-blocks.mjs +326 -0
  40. package/src/tui/tool-args.mjs +67 -0
  41. package/src/tui/tool-events.mjs +461 -0
@@ -1,70 +1,87 @@
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, blankLine, 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
- }
84
+ // foldHintLine/blankLine moved to fold-block.mjs (shared with the component).
68
85
 
69
86
  function highlightSearchMatches(text, query, matchesInLine, globalCurrentIndex, allMatches, lineIndex) {
70
87
  if (!matchesInLine || matchesInLine.length === 0 || !query) return text
@@ -90,24 +107,157 @@ function highlightSearchMatches(text, query, matchesInLine, globalCurrentIndex,
90
107
  return result
91
108
  }
92
109
 
110
+ /** Render a FROZEN child activity block carried on a state.lines entry
111
+ * (subagent-blocks.mjs freezeSubTaskLines pushes {_frozenSubTask: sub}). Identical
112
+ * interaction to the running tail section: folded = `[✓ coder#1 · glm-5.3 ·
113
+ * done 45s · turn 12/100] … click to expand` header + tail 3 block lines;
114
+ * expanded = blank + ▼ control + full timeline (60% screen cap via the shared
115
+ * component — capped view ends in a reachable collapse control). Toggle key
116
+ * `sub-${key}` — the SAME key the live section uses, so fold state carries
117
+ * across the freeze boundary seamlessly (user ruled 2026-08-30: frozen stays
118
+ * clickable — full design interaction, not a dim-lines fallback). */
119
+ function frozenSubTaskLines(state, sub, cols, maxRows) {
120
+ const foldKey = `sub-${sub.key}`
121
+ const elapsed = Math.floor(((sub.doneAt ?? Date.now()) - sub.started) / 1000)
122
+ const modelPart = sub.model ? ` · ${sub.model}` : ""
123
+ const turnPart = sub.maxTurns > 0 ? ` · turn ${sub.turn}/${sub.maxTurns}` : ""
124
+ const errPart = sub.lastError ? ` — ${sub.lastError}` : ""
125
+ const icon = sub.approval ? "⏸" : "✓"
126
+ const header = `[${icon} ${sub.key}${modelPart} · done ${elapsed}s${turnPart}${errPart}]`
127
+ const out = []
128
+ if (isExpanded(state, foldKey)) {
129
+ // Expanded: shared component renders blank + ▼ control + full timeline,
130
+ // capped at 60% of the screen with a bottom collapse control.
131
+ const body = renderBlockTimeline(sub.blocks, cols)
132
+ out.push(...renderExpandedBlock({ body, foldKey, state, maxRows, cols, label: "subagent activity" }))
133
+ } else {
134
+ // Folded: the header line itself is the control (▶ affordance), then tail 3.
135
+ out.push({
136
+ text: `▶ ${header} … subagent activity — click to expand`,
137
+ color: C.dim,
138
+ _foldToggle: foldKey,
139
+ })
140
+ for (const line of foldTailLines(sub.blocks)) {
141
+ out.push({ text: `│ ${sliceByWidth(line, cols - 4)}`, color: C.dim, _skipDimFold: true })
142
+ }
143
+ }
144
+ return out
145
+ }
146
+
93
147
  /**
94
148
  * Build the conversation lines for the given state.
149
+ * maxRows: terminal rows for the 60% expansion cap (undefined → uncapped —
150
+ * unit tests and callers without a terminal rely on that).
95
151
  * NOTE: module-level _convCache is read/written as a side effect (keyed by
96
152
  * convCacheKey + cols) — the function is pure w.r.t. its input except for
97
153
  * that cache; direct callers outside renderConversation/countConvLines
98
154
  * should be aware the cache persists across calls.
99
155
  */
100
- function buildConvLines(state, cols) {
101
- const key = convCacheKey(state)
156
+ function buildConvLines(state, cols, maxRows) {
157
+ const key = convCacheKey(state, maxRows)
102
158
  if (_convCache.key === key && _convCache.cols === cols) return _convCache.lines
103
159
 
104
160
  const convLines = []
105
- // Folding constants (function scope — used by both the long-message fold below
106
- // and the consecutive-dim fold at the bottom)
161
+ // Folding constants (function scope)
107
162
  const LONG_FOLD_LINES = 12
108
- const FOLD_KEEP = 5 // content lines kept in the folded state (first 4 + last 1)
163
+ let blankAfter = false
164
+ // THINKING ALWAYS FOLDS (user ruling 2026-08-30, final): no threshold of any
165
+ // kind — row thresholds died twice on the user's real screen (12 never met on
166
+ // narrow, 3 never met on wide), a char threshold missed typical sentences.
167
+ // Thinking is process content: it renders as the named "▶ thinking" block,
168
+ // expand ≤60%, click back. No exceptions, streaming included.
109
169
  for (let i = 0; i < state.lines.length; i++) {
110
170
  const l = state.lines[i]
171
+ // Main-output breathing room (user request 2026-08-30): a blank line before
172
+ // and after each main-output segment (assistant replies / user text — the
173
+ // C.text rows) so the conversation body stands apart from thinking / tool /
174
+ // subagent blocks. Blank lines are RENDER-only (never written to
175
+ // state.lines) — convCacheKey is unaffected, and adjacent segments share
176
+ // one blank line (the trailing blank of segment N and the leading blank of
177
+ // segment N+1 must not stack into a double row).
178
+ const isMain = l._kind === "text" || (l._kind === undefined && l.color === C.text)
179
+ const pushBlank = () => {
180
+ if (convLines.at(-1)?.text !== "") convLines.push({ text: "", color: C.text })
181
+ }
182
+ if (isMain) {
183
+ const prev = i > 0 ? state.lines[i - 1] : null
184
+ const next = state.lines[i + 1]
185
+ const prevMain = prev && (prev._kind === "text" || (prev._kind === undefined && prev.color === C.text))
186
+ const nextMain = next && (next._kind === "text" || (next._kind === undefined && next.color === C.text))
187
+ if (!prevMain) pushBlank()
188
+ blankAfter = !nextMain
189
+ }
190
+ // Frozen subagent activity block (§7.2 D4, 2026-08-30): rendered as its own
191
+ // collapsible section — clickable expand/collapse like the running block.
192
+ if (l._frozenSubTask) {
193
+ convLines.push(...frozenSubTaskLines(state, l._frozenSubTask, cols, maxRows))
194
+ continue
195
+ }
196
+ // ONE BLOCK PER TOOL CALL (2026-08-30 user ruling): header = name+args+
197
+ // live status, body = args JSON + streaming output + result. Folded =
198
+ // ▶ name args · status/summary; expanded = 60%-capped body (shared component).
199
+ if (l._toolBlock) {
200
+ const b = l._toolBlock
201
+ // Stable key from the line's own id (P1 2026-08-30): the line may shift
202
+ // index when loadOlder unshifts older pages — positional tool-${i} would
203
+ // re-bind the expand state to a different tool block.
204
+ const foldKey = `tool-${l._lineId ?? i}`
205
+ const status = !b.done
206
+ ? "running"
207
+ : `${b.elapsed !== null ? b.elapsed + "ms" : ""}${b.summary ? (b.elapsed !== null ? " · " : "") + sliceByWidth(b.summary, 50) : ""}`.trim() || "done"
208
+ if (isExpanded(state, foldKey)) {
209
+ const body = []
210
+ const pushWrapped = (raw, color) => {
211
+ for (const w of wrapText(raw, cols - 4)) body.push({ text: " " + w, color, _skipDimFold: true })
212
+ }
213
+ for (const jl of b.argsJson) pushWrapped(jl, C.dim)
214
+ for (const ol of b.output) pushWrapped(ol, C.tool)
215
+ if (b.result) for (const rl of b.result) pushWrapped(rl, C.dim)
216
+ convLines.push(...renderExpandedBlock({ body, foldKey, state, maxRows, cols, label: `${b.name}${b.roundTag || ""} ${b.argsSummary}`.trim() }))
217
+ } else {
218
+ // Head MUST be width-bounded: argsSummary for unknown/MCP tools is a
219
+ // JSON.stringify dump that can be thousands of chars — an overwide header
220
+ // row makes the terminal soft-wrap mid-frame, shifting every panel below
221
+ // (the "code breaks the input box border" report, 2026-08-30).
222
+ const headText = sliceByWidth(
223
+ `❯ ${b.name}${b.roundTag || ""}${b.argsSummary ? " " + b.argsSummary : ""} · ${status}`,
224
+ Math.max(20, cols - 2),
225
+ )
226
+ const body = []
227
+ for (const jl of b.argsJson) for (const w of wrapText(jl, cols - 4)) body.push({ text: w, color: C.dim, _skipDimFold: true })
228
+ 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 })
229
+ // Result lines join the tail pool too — restore carrier has no output
230
+ // rows, so without this its folded tail showed only args JSON and the
231
+ // result vanished from the folded view (parity bug, 2026-08-30).
232
+ 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 })
233
+ convLines.push(...renderFoldedHead({ header: { text: headText, color: C.tool, _foldToggle: foldKey }, body, cols }))
234
+ }
235
+ continue
236
+ }
237
+ // Frozen advisor review (2026-08-30): same collapsible-box treatment —
238
+ // folded = one control line; expanded = the full review text (markdown
239
+ // rendered, no gutter — review history convention kept from the flat era),
240
+ // 60% cap via the shared component.
241
+ if (l._frozenAdvisor) {
242
+ const frozenAdvKey = `advisor-done-${i}`
243
+ if (isExpanded(state, frozenAdvKey)) {
244
+ const body = []
245
+ const rendered = renderMathAndMarkdown(sanitizeDisplay(l._frozenAdvisor))
246
+ for (const line of formatTables(rendered, cols - 1)) {
247
+ for (const wrapped of wrapText(line, cols - 1)) {
248
+ body.push({ text: wrapped, color: C.reason, _skipDimFold: true })
249
+ }
250
+ }
251
+ convLines.push(...renderExpandedBlock({ body, foldKey: frozenAdvKey, state, maxRows, cols, label: "[advisor · review done]" }))
252
+ } else {
253
+ convLines.push({
254
+ text: `▶ [advisor · review done] … click to expand`,
255
+ color: C.fold,
256
+ _foldToggle: frozenAdvKey,
257
+ })
258
+ }
259
+ continue
260
+ }
111
261
  let text = l.text
112
262
 
113
263
  // Apply search highlighting
@@ -115,16 +265,27 @@ function buildConvLines(state, cols) {
115
265
  text = highlightSearchMatches(text, state.search.query, l._searchMatches, state.search.index, state.search.matches, i)
116
266
  }
117
267
 
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.
268
+ // Long-message folding (2026-08-30 user ruling): MAIN OUTPUT / user messages
269
+ // (C.text) NEVER fold primary conversation content is read by scrolling,
270
+ // not by expanding; a folded core answer hid the actual result behind a
271
+ // click. Foldable subjects narrow to THINKING (C.reason) and dim tool
272
+ // summaries the auxiliary streams. (This re-enacts the pre-0.12.7 rule
273
+ // for main output only; the 0.12.7 "revert" had reopened folding for it.)
274
+ // Keyed by the source-line index (`long-${i}`) so the toggle survives
275
+ // re-renders.
126
276
  const longKey = `long-${i}`
127
- const folded = state.foldEnabled !== false && !state.expandedBlocks?.has(longKey)
277
+ // Single source of truth: the producer stamps _kind ("thinking" / "text" /
278
+ // "tool") — buildConvLines READS the stamp instead of GUESSING from color.
279
+ // Three producers (live flushStream / restored historyToLines / injected
280
+ // lines) now emit the identical grammar; the renderer is one place.
281
+ // Fallback: unstamped lines keep the legacy color-based inference (defensive
282
+ // for any path this refactor missed — empty until proven otherwise).
283
+ const isReasoning = l._kind === "thinking" || (l._kind === undefined && l.color === C.reason)
284
+ // Foldable classes: thinking (ALWAYS — threshold 0) and dim auxiliaries.
285
+ // "text" (main output / user messages) NEVER folds.
286
+ const foldable = isReasoning || (l._kind === "tool" || (l._kind === undefined && l.color === C.dim) || (l._kind === undefined && l.color !== C.text && l.color !== C.reason))
287
+ const threshold = isReasoning ? 0 : LONG_FOLD_LINES
288
+ const folded = foldable && state.foldEnabled !== false && !state.expandedBlocks?.has(longKey)
128
289
  const block = []
129
290
  // Lightweight markdown display (IK5VW3): render BEFORE measuring — the
130
291
  // table column math (formatTables) and wrapping must see the RENDERED
@@ -138,69 +299,162 @@ function buildConvLines(state, cols) {
138
299
  block.push({ text: wrapped, color: l.color, _foldId: l._foldId, _src: i })
139
300
  }
140
301
  }
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) {
302
+ if (folded && block.length > threshold) {
303
+ // FOLDED unified form (fold-block.mjs renderFoldedHead, 2026-08-30 user
304
+ // ruling): named identity header + last 3 lines. Replaces the legacy
305
+ // [first 4, anonymous at the ellipsis, last] whose orphaned-looking
306
+ // "… N more lines" segment confused the scrollback.
307
+ const kind = l.color === C.reason ? "thinking" : l.color === C.dim ? "tool output" : "message"
308
+ convLines.push(...renderFoldedHead({
309
+ header: foldHintLine(`▶ ${kind} · ${block.length} lines — click to expand`, longKey, i),
310
+ body: block, cols,
311
+ }))
312
+ } else if (foldable && block.length > threshold) {
150
313
  if (state.foldEnabled === false) {
151
314
  // Folding fully off — content already fully visible; a "click to
152
315
  // collapse" hint would be misleading (toggling has no effect).
153
316
  convLines.push(...block)
154
317
  } 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).
318
+ // EXPANDED thinking/dim long block via the shared component: blank + ▼
319
+ // control at the HEAD, content, 60% cap with a bottom collapse control.
320
+ // DIM blocks must not re-trigger the consecutive-dim folding below
321
+ // (folding stacked on folding — reported regression).
158
322
  if (l.color === C.dim) {
159
323
  for (const line of block) line._skipDimFold = true
160
324
  }
161
- convLines.push(blankLine())
162
- convLines.push(foldHintLine(`▼ … ${block.length} lines — click to collapse`, longKey, i))
163
- convLines.push(...block)
325
+ convLines.push(...renderExpandedBlock({ body: block, foldKey: longKey, state, maxRows, cols, label: `${block.length} lines` }))
164
326
  }
165
327
  } else {
166
328
  convLines.push(...block)
167
329
  }
330
+ // Trailing blank after a main-output segment (user request 2026-08-30) —
331
+ // landed after the segment's rendered content.
332
+ if (blankAfter) {
333
+ pushBlank()
334
+ blankAfter = false
335
+ }
336
+ }
337
+ // ── Subagent activity blocks (§7.2 D4) — RUNNING blocks only ──────────────
338
+ // Rendered BEFORE the advisor blocks section. A child's block lives here only
339
+ // while it runs: on completion onToolResult freezes the block into state.lines
340
+ // (subagent-blocks.mjs freezeSubTaskLines) so it scrolls away with the conversation
341
+ // instead of staying pinned above the input box ("ghost" report 2026-08-30).
342
+ // Default folded = header summary line (▶ role#id · model · elapsed · turn
343
+ // n/max | current state) + tail 3 block lines; expanded = shared component
344
+ // (full timeline, 60% screen cap).
345
+ const runningSubs = Object.values(state.subTasks ?? {}).filter((s) => !s.done)
346
+ if (runningSubs.length > 0) {
347
+ // Divider between the conversation body and the running-subagent band
348
+ // (user request 2026-08-30, mirroring the task-panel divider in
349
+ // render-frame renderTodo). Only when at least one block actually renders —
350
+ // done children are frozen into state.lines above, so a divider for an
351
+ // empty band would hang over the section boundary.
352
+ // Unconditional divider (task-panel style): the preceding main-output
353
+ // trailing blank is breathing room, the divider is the section boundary —
354
+ // both belong. Only dedupe against another divider (idempotent re-render).
355
+ if (convLines.at(-1)?.color !== C.dim || !convLines.at(-1)?.text?.startsWith("─")) {
356
+ convLines.push({ text: "─".repeat(Math.max(1, cols - 1)), color: C.dim, _skipDimFold: true })
357
+ }
358
+ for (const sub of runningSubs) {
359
+ // Done children never reach this loop (filtered above): they are frozen
360
+ // into state.lines by subagent-blocks.mjs freezeSubTaskLines and removed
361
+ // from subTasks — a restored leftover would otherwise pin at the tail.
362
+ const foldKey = `sub-${sub.key}`
363
+ // Header summary: `[▶ coder#1 · glm-5.3 · 45s · turn 12/100] bash — npm test`
364
+ const icon = sub.approval ? "⏸" : "▶"
365
+ const elapsed = Math.floor(((sub.done ? sub.doneAt : Date.now()) - sub.started) / 1000)
366
+ const modelPart = sub.model ? ` · ${sub.model}` : ""
367
+ const turnPart = sub.maxTurns > 0 ? ` · turn ${sub.turn}/${sub.maxTurns}` : ""
368
+ let statePart
369
+ if (sub.approval) statePart = `等待审批: ${sub.approval}`
370
+ else if (sub.done) {
371
+ statePart = `done ${elapsed}s${sub.lastError ? ` — ${sub.lastError}` : ""}`
372
+ } else if (sub.currentTool) statePart = sub.currentTool
373
+ else statePart = "thinking..."
374
+ const argSummary = sub.currentTool && sub.toolArgs?.command
375
+ ? ` — ${String(sub.toolArgs.command).replace(/\s+/g, " ").trim().slice(0, 60)}`
376
+ : ""
377
+ convLines.push({
378
+ text: `[${icon} ${sub.key}${modelPart} · ${elapsed}s${turnPart}] ${sliceByWidth(statePart + argSummary, Math.max(20, cols - 30))}`,
379
+ color: sub.done ? C.dim : C.tool,
380
+ _foldToggle: foldKey,
381
+ })
382
+ if (isExpanded(state, foldKey)) {
383
+ // Full activity timeline via the shared component (per-kind colors,
384
+ // 60% screen cap — the header control may sit above the viewport once
385
+ // expanded, the capped bottom control stays reachable).
386
+ const body = renderBlockTimeline(sub.blocks, cols)
387
+ convLines.push(...renderExpandedBlock({ body, foldKey, state, maxRows, cols, label: "subagent activity" }))
388
+ } else {
389
+ // Folded: tail 3 non-empty block lines (most recent activity), dim.
390
+ for (const line of foldTailLines(sub.blocks)) {
391
+ convLines.push({ text: `│ ${sliceByWidth(line, cols - 4)}`, color: C.dim })
392
+ }
393
+ }
394
+ }
168
395
  }
169
396
  if (state.reasoning) {
397
+ // Live thinking streams INSIDE the unified box (user ruling 2026-08-30:
398
+ // "思考过程中为什么不是直接进这个框" — the flat tail render was a
399
+ // pre-fold-era leftover). Same folded form as flushed blocks: named header
400
+ // + tail 3, click expands to the 60%-capped live view. The buffer grows
401
+ // per token; convCacheKey already includes state.reasoning.length so the
402
+ // box updates live. Single instance key — one live stream at a time; on
403
+ // flush the block re-keys to `long-{idx}` with the identical form (seamless).
404
+ const liveKey = "thinking-live"
405
+ const body = []
170
406
  for (const wrapped of wrapText(sanitizeDisplay(state.reasoning), cols - 1)) {
171
- convLines.push({ text: wrapped, color: C.reason })
407
+ body.push({ text: wrapped, color: C.reason, _skipDimFold: true })
408
+ }
409
+ const expanded = isExpanded(state, liveKey)
410
+ if (expanded) {
411
+ convLines.push(...renderExpandedBlock({ body, foldKey: liveKey, state, maxRows, cols, label: "thinking (streaming)" }))
412
+ } else {
413
+ convLines.push(...renderFoldedHead({
414
+ header: foldHintLine(`▶ thinking · ${body.length} lines — click to expand`, liveKey),
415
+ body, cols,
416
+ }))
172
417
  }
173
418
  }
174
419
  const advisorBlocks = state._advisorBlocks ?? []
175
420
  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
- }
421
+ // ── Advisor review block (2026-08-30): collapsible in-conversation box ──
422
+ // The live stream used to render flat into the conversation and flooded it
423
+ // (user report). Same interaction as subagent blocks: default FOLDED =
424
+ // header (running/done + total line count) + tail 3 block lines; expanded =
425
+ // shared component (▼ control + ordered per-kind timeline — think/tool/text
426
+ // colors kept, T-F regression; placeholder markers stripped in every view
427
+ // unified with the folded tail which always stripped them). Toggle key
428
+ // `advisor-blocks` (single instance one advisor runs at a time).
429
+ const advKey = "advisor-blocks"
430
+ // Total rendered line count of the timeline (cheap: rough split, no wrap math)
431
+ const advLineCount = advisorBlocks.reduce((n, b) => n + sanitizeDisplay(b.text).split("\n").filter((l) => l.trim()).length, 0)
432
+ const advHeader = `[advisor · review] ${advLineCount} lines`
433
+ if (isExpanded(state, advKey)) {
434
+ const body = renderBlockTimeline(advisorBlocks, cols, { strip: [ADVISOR_THINKING_PLACEHOLDER] })
435
+ convLines.push(...renderExpandedBlock({ body, foldKey: advKey, state, maxRows, cols, label: advHeader }))
436
+ } else {
437
+ // Folded: header control line + tail 3 non-empty lines from the tail
438
+ // blocks (most recent activity), dim — mirrors the subagent block fold.
439
+ convLines.push({
440
+ text: `▶ ${advHeader} click to expand`,
441
+ color: C.fold,
442
+ _foldToggle: advKey,
443
+ })
444
+ for (const line of foldTailLines(advisorBlocks, 3, { strip: [ADVISOR_THINKING_PLACEHOLDER] })) {
445
+ convLines.push({ text: `│ ${sliceByWidth(line, cols - 4)}`, color: C.dim, _skipDimFold: true })
199
446
  }
200
447
  }
201
448
  }
202
449
  if (state.streaming) {
203
- // Rendered BEFORE formatTables see the advisor-block comment above.
450
+ // Main-output breathing room (2026-08-30): the streaming path renders OUTSIDE
451
+ // the line loop (the reply is still in its buffer, not yet in state.lines),
452
+ // so the loop's leading blank never applied — a streamed reply showed no
453
+ // blank until flush landed it in lines and a later frame re-rendered. Apply
454
+ // the same leading blank here (the trailing blank belongs to the line path
455
+ // once flushed — mid-stream there is still content to come).
456
+ if (convLines.at(-1)?.text !== "") convLines.push({ text: "", color: C.text })
457
+ // Rendered BEFORE formatTables — see fold-block.mjs for the width contract.
204
458
  const rendered = renderMathAndMarkdown(sanitizeDisplay(state.streaming))
205
459
  for (const line of formatTables(rendered, cols - 1)) {
206
460
  for (const wrapped of wrapText(line, cols - 1)) {
@@ -224,21 +478,22 @@ function buildConvLines(state, cols) {
224
478
  if (blockLen > FOLD_LINES && !hasExpandedLong) {
225
479
  const foldKey = `fold-${foldCounter++}`
226
480
  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])
481
+ // FOLDED unified named-header + last-3 form (same ruling as the
482
+ // long-message fold above).
483
+ folded.push(...renderFoldedHead({
484
+ header: foldHintLine(`▶ tool output · ${blockLen} lines — click to expand`, foldKey),
485
+ body: convLines.slice(i, j), cols,
486
+ }))
231
487
  i = j
232
488
  continue
233
489
  }
234
- // EXPANDED consecutive-dim block: blank + ▼ at the HEAD, then every line.
490
+ // EXPANDED consecutive-dim block via the shared component: blank + ▼ at
491
+ // the HEAD, then every line, 60% cap with a bottom collapse control.
235
492
  // foldEnabled=false → raw block, no hint (toggling would be a no-op).
236
493
  if (state.foldEnabled === false) {
237
494
  for (let k = i; k < j; k++) folded.push(convLines[k])
238
495
  } 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])
496
+ folded.push(...renderExpandedBlock({ body: convLines.slice(i, j), foldKey, state, maxRows, cols, label: `${blockLen} lines` }))
242
497
  }
243
498
  i = j
244
499
  continue
@@ -252,14 +507,14 @@ function buildConvLines(state, cols) {
252
507
  return folded
253
508
  }
254
509
 
255
- export function countConvLines(state, cols) {
256
- return buildConvLines(state, cols).length
510
+ export function countConvLines(state, cols, maxRows) {
511
+ return buildConvLines(state, cols, maxRows).length
257
512
  }
258
513
 
259
514
  export { buildConvLines }
260
515
 
261
- export function renderConversation(state, cols, visibleH, scroll) {
262
- const convLines = buildConvLines(state, cols)
516
+ export function renderConversation(state, cols, visibleH, scroll, maxRows) {
517
+ const convLines = buildConvLines(state, cols, maxRows)
263
518
  const maxScroll = Math.max(0, convLines.length - visibleH)
264
519
  const clamped = Math.min(scroll, maxScroll)
265
520
  const end = convLines.length - clamped