thincoder 0.12.8 → 0.12.9

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.
package/README.md CHANGED
@@ -209,6 +209,13 @@ Code conventions: pure `.mjs`, no semicolons, no npm dependencies allowed (inclu
209
209
 
210
210
  ## Changelog
211
211
 
212
+ ### 0.12.9 (2026-08)
213
+ - **Prompt-system quality pass (both CLI and VS Code extension, byte-identical sync):**
214
+ - **Advisor-after-code rule moved from system.md to discipline.md** — engineering mode no longer receives the conflicting "call advisor after changing code" instruction (its review-timing rules say do not call unprompted). Standard mode behavior unchanged.
215
+ - **engineering.md delivery-review semantics unified** — the mandatory-flow step and the state table now both say: eng-coder self-reviews inside the subagent; the architect verifies against acceptance criteria and re-reviews only when asked or when the delivery looks wrong. (Previously the step forced a parent-side advisor code review that the hard rules contradicted.)
216
+ - **checkpoint description aligned with actual auto-snapshot triggers** (task-list deletion + context compaction; manual checkpoint for the rest).
217
+ - **advisor round budget wording fixed** — prompts advertise a 30-round budget; the mechanical hard cap is 100 rounds (loop guard). Both layers are now named explicitly.
218
+
212
219
  ### 0.12.8 (2026-08)
213
220
  - **Fix: pending-task pushback fires at most once** — the completion guard that reminds the model to update pending tasks before finishing could loop forever when a pending item could not be resolved. Now each task-list state earns exactly one reminder; if the model insists on finishing anyway, it is allowed to (updating the list via the task tool resets the budget). VS Code extension synced.
214
221
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.8",
3
+ "version": "0.12.9",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -13,6 +13,9 @@ export const MAX_ADVISOR_TURNS = 100
13
13
  // (full review, verify+fix cycles, strict verification). A 6th call means the
14
14
  // model is looping — refuse it instead of burning tokens on a review that cannot
15
15
  // converge. Design reviews are exempt (each call resets the round).
16
+ // NOTE: prompts/advisor-round{1,2,3}.md advertise a 30-round BUDGET — the
17
+ // prompt-level efficiency target, distinct from this 100-round mechanical hard
18
+ // cap (loop guard). Keep both in sync when either changes.
16
19
  export const MAX_ADVISOR_ROUNDS = 5
17
20
 
18
21
  // Context window limits
@@ -1,7 +1,7 @@
1
1
  You are a code review advisor.
2
2
  Perform a full-scope review of the specified files.
3
3
  You have read-only tools to explore the codebase.
4
- You have a HARD limit of 30 tool rounds (chat turns) total — plan your exploration accordingly.
4
+ You have a budget of 30 tool rounds (chat turns) — plan your exploration accordingly. Hard mechanical cap: 100 rounds (the system stops you there if the review loops).
5
5
 
6
6
  Review workflow:
7
7
  1. The files to review are listed in the review scope. Read them in full. The review scope defines exactly which files to inspect.
@@ -2,7 +2,7 @@ You are a code review advisor.
2
2
  Verify the prior issue table (provided in the review context).
3
3
  You may note obvious new issues introduced by the fixes.
4
4
  You have read-only tools to explore the codebase.
5
- You have a HARD limit of 30 tool rounds (chat turns) total.
5
+ You have a budget of 30 tool rounds (chat turns). Hard mechanical cap: 100 rounds.
6
6
 
7
7
  Review workflow:
8
8
  1. The files to review are listed in the review scope — read them in full. The prior issue table is HISTORY from a previous review, not current state.
@@ -2,7 +2,7 @@ You are a code review advisor.
2
2
  Strictly verify only the prior issue table (provided in the review context).
3
3
  Do NOT look for new issues.
4
4
  You have read-only tools to explore the codebase.
5
- You have a HARD limit of 30 tool rounds (chat turns) total.
5
+ You have a budget of 30 tool rounds (chat turns). Hard mechanical cap: 100 rounds.
6
6
 
7
7
  Review workflow:
8
8
  1. The files to review are listed in the review scope — read them in full. The prior issue table is HISTORY from a previous review, not current state.
@@ -9,4 +9,7 @@ Debugging strategy:
9
9
  - Verify against official docs before guessing.
10
10
  - Binary search: cut the problem in half, test which half has the fault.
11
11
  - Fix one thing at a time. Don't change multiple things at once.
12
- - Don't get stuck reading code — write tests, add logs. Trust the runtime over your theories.
12
+ - Don't get stuck reading code — write tests, add logs. Trust the runtime over your theories.
13
+
14
+ Review discipline (standard mode only — engineering mode has its own review timing rules):
15
+ - **Advisor:** call after changing code. Must provide scope: `paths` (files/dirs to review) or `documents` (context). Response table: `| # | Action | Detail |`. Round 2 verifies prior table.
@@ -30,13 +30,11 @@ subagents only.
30
30
  criteria — AND the designToken verbatim (the exact token string from the
31
31
  advisor output). The token is required — eng-coder cannot modify files
32
32
  without it.
33
- 5. **Code review.** After eng-coder returns, call the `advisor` tool with
34
- `type="code"` to review the implementation against the design. Pass
35
- `documents=[...]`the task's Docs involved list from the implementation
36
- handoff. Review scope = these docs (not the git diff).
37
- - If advisor finds issues: send the eng-coder feedback and re-run, or fix
38
- directly if minor.
39
- - If advisor approves: present results to the user.
33
+ 5. **Delivery review.** After eng-coder returns, verify the delivery against
34
+ the acceptance criteria from the design. The eng-coder self-reviewed inside
35
+ the subagent its advisor(code) call happens there. Re-review with the
36
+ `advisor` tool (`type="code"`, `documents=[...]` = the task's Docs involved
37
+ list) only when the user asks or the delivery looks wrong.
40
38
  6. **Verify.** Run `verify` — it must pass before you claim the task complete.
41
39
 
42
40
  ## Work Loop (every user message)
@@ -51,7 +49,7 @@ passed?
51
49
  | Design | Write or refine the DESIGN doc (approach + rationale, architecture/interface, affected files, key decisions), organized by business domain per METHODOLOGY, ask for confirmation |
52
50
  | Awaiting approval | Present design summary, WAIT for explicit approval |
53
51
  | Implementation | eng-coder is working — do not redesign in parallel |
54
- | Delivery review | Review the eng-coder delivery with advisor (type="code", scope = Docs involved + acceptance criteria), check acceptance criteria, report |
52
+ | Delivery review | Verify the delivery against the acceptance criteria (the eng-coder self-reviewed inside the subagent); re-review with advisor (type="code", documents = Docs involved) only when the user asks or the delivery looks wrong; report |
55
53
  | Wrapped up | Report, wait for next instruction |
56
54
 
57
55
  Then handle the message:
@@ -33,7 +33,7 @@ Programming is collaborative labor between you and the human. The human decides
33
33
  - No TTY — run shell commands non-interactively (git commit -m, --no-pager, -y/--yes).
34
34
  - Never modify files outside the working directory. No bash redirects to bypass boundaries.
35
35
  - **Reversibility tiers:** local edits — yours. Destructive (rm -rf, force-push) — confirm. Outward (commit/push/publish) — confirm each time.
36
- - Checkpoint before risky bulk operations. Auto-snapshot before every task lets you recover.
36
+ - Checkpoint before risky bulk operations. Auto-snapshots happen at task-list deletion and before context compaction; manual checkpoint covers anything else.
37
37
  - When context is compacted mid-session: trust the summary's conclusions, but re-read AGENTS.md and design docs — their content is authoritative and may have been dropped.
38
38
  - Long-term memory via memory_put/memory_search. Save bugs, conventions, preferences.
39
39
  - Codebase exploration order: repo_outline → doc_search → code_search. Structure → intent → details.
@@ -52,5 +52,4 @@ Before finalizing: pause and think through edge cases. What could go wrong? Self
52
52
  - After every write/edit: `lint`. Before done: `lint full=true`.
53
53
  - Before declaring completion: `verify` (syntax, related tests, self-review checklist).
54
54
  - Code changes need at least one test.
55
- - **Advisor:** call after changing code. Must provide scope: `paths` (files/dirs to review) or `documents` (context). Response table: `| # | Action | Detail |`. Round 2 verifies prior table.
56
55
  - **Done:** explain what you changed, why, what's simplified, what's not done.
@@ -29,6 +29,12 @@ export async function runAgentTurn(ctx, text) {
29
29
  // 可注入覆盖(测试用);默认走真实实现
30
30
  const runAgentImpl = ctx.runAgent ?? runAgent
31
31
  const saveSessionImpl = ctx.saveSession ?? saveSession
32
+ // A new user message starts a new turn: auto-expanded completed replies from the
33
+ // previous turn (kept open so the user could read them) collapse now.
34
+ for (const idx of state._autoExpand ?? []) {
35
+ state.expandedBlocks?.delete(`long-${idx}`)
36
+ }
37
+ state._autoExpand = []
32
38
  pushLabel(`❯ You:`, ansi.bold + C.user)
33
39
  pushLine(text, C.text)
34
40
 
@@ -52,11 +58,23 @@ export async function runAgentTurn(ctx, text) {
52
58
 
53
59
  const flushStream = () => {
54
60
  if (state.reasoning) {
61
+ const idx = state.lines.length
55
62
  pushLine(state.reasoning, C.reason)
63
+ // Completed reasoning stays expanded (user is reading it) until the next turn
64
+ state.expandedBlocks ??= new Set()
65
+ state.expandedBlocks.add(`long-${idx}`)
66
+ state._autoExpand ??= []
67
+ state._autoExpand.push(idx)
56
68
  state.reasoning = ""
57
69
  }
58
70
  if (state.streaming) {
71
+ const idx = state.lines.length
59
72
  pushLine(state.streaming, C.text)
73
+ // Completed main output stays expanded (user is reading it) until the next turn
74
+ state.expandedBlocks ??= new Set()
75
+ state.expandedBlocks.add(`long-${idx}`)
76
+ state._autoExpand ??= []
77
+ state._autoExpand.push(idx)
60
78
  state.streaming = ""
61
79
  }
62
80
  state.advisorStreaming = ""
package/src/tui/ansi.mjs CHANGED
@@ -42,5 +42,5 @@ export const C = {
42
42
  dim: ansi.gray,
43
43
  warn: ansi.fg(3),
44
44
  advisor: `${ESC}[92m`, // bright green — visible on dark backgrounds
45
- fold: `${ESC}[2m${ESC}[37m`, // dim white — fold hints
45
+ fold: ansi.bold + ansi.fg(6), // bold cyan — fold markers must stay visible on light AND dark themes (dim white vanished on light backgrounds)
46
46
  }
@@ -18,26 +18,6 @@ export async function readClipboardText() {
18
18
  }
19
19
  }
20
20
 
21
- /** Write text to the system clipboard (Set-Clipboard / pbcopy / xclip). Throws on failure. */
22
- export async function copyToClipboard(text) {
23
- const { execFile } = await import("node:child_process")
24
- const isWin = process.platform === "win32"
25
- const isMac = process.platform === "darwin"
26
- await new Promise((resolve, reject) => {
27
- if (isWin) {
28
- const child = execFile("powershell", ["-NoProfile", "-Command", "[Console]::In.ReadToEnd() | Set-Clipboard"], { timeout: 5000 }, (err) => err ? reject(err) : resolve())
29
- child.stdin?.end(text)
30
- } else if (isMac) {
31
- const child = execFile("pbcopy", [], { timeout: 5000 }, (err) => err ? reject(err) : resolve())
32
- child.stdin?.end(text)
33
- } else {
34
- const child = execFile("xclip", ["-selection", "clipboard"], { timeout: 5000 }, (err) => err ? reject(err) : resolve())
35
- child.stdin?.end(text)
36
- }
37
- })
38
- }
39
-
40
-
41
21
  /** Insert pasted text into the active text target.
42
22
  * Free-text question active → append to its answer (single-line field: newlines stripped).
43
23
  * Options question active → ignore (no text field; must not leak into the input box).
package/src/tui/index.mjs CHANGED
@@ -81,6 +81,7 @@ export async function startTUI(agent, opts = {}) {
81
81
  ctxCache: { len: -1, tokens: 0 }, // context utilization estimate cache (estimateTokens is O(n), only recompute when history grows)
82
82
  reasoning: "", // thinking stream buffer (dimmed display)
83
83
  completion: null, // Tab completion state { candidates, index }
84
+ _autoExpand: [], // indices of completed replies kept expanded; cleared when the next user turn starts
84
85
  subTasks: {}, // sub-agent panel: { roleName: { role, text, done } }, one line per role, marked done briefly after completion
85
86
  currentTool: null, // currently executing tool name (shown in status bar)
86
87
  processingStarted: 0, // current turn start time (status bar timer)
@@ -386,8 +387,8 @@ export async function startTUI(agent, opts = {}) {
386
387
  }
387
388
  })
388
389
 
389
- // Mouse clicks (SGR \x1b[<0;col;rowM) — picker selection + line action menu.
390
- const onMouseClick = (col, row) => handleMouseClick({ state, render, showPicker, popPicker, pushLine }, col, row)
390
+ // Mouse clicks (SGR \x1b[<0;col;rowM) — picker selection + fold expansion.
391
+ const onMouseClick = (col, row) => handleMouseClick({ state, render, popPicker }, col, row)
391
392
 
392
393
  // ---------------------------------------------------------- Startup screen + background indexing
393
394
 
package/src/tui/mouse.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * mouse.mjs — SGR mouse support: click parsing + hit-testing + line actions.
2
+ * mouse.mjs — SGR mouse support: click parsing + hit-testing.
3
3
  *
4
4
  * Protocol (enabled at startup via \x1b[?1000h\x1b[?1006h):
5
5
  * press: \x1b[<b;col;rowM (b=0 left, 64/65 wheel up/down — wheel handled upstream)
@@ -8,10 +8,14 @@
8
8
  *
9
9
  * Only left-click (button 0) is consumed. Everything else stays stripped
10
10
  * upstream (sequence fragments must never leak into the input box).
11
+ *
12
+ * Click actions (deliberately minimal — a line-action menu was removed as
13
+ * over-engineering: terminals already copy via drag-select):
14
+ * - picker option click = select it
15
+ * - folded-block hint click = expand it
11
16
  */
12
17
  import { computeLayout } from "./layout.mjs"
13
18
  import { buildConvLines } from "./render-conversation.mjs"
14
- import { sanitizeDisplay } from "./render.mjs"
15
19
 
16
20
  /** Extract left-click presses from a chunk. Returns [{ col, row }] (1-based). */
17
21
  export function parseMouseClicks(text) {
@@ -37,7 +41,7 @@ export function convGlobalIndex(convLen, convH, scroll) {
37
41
 
38
42
  /**
39
43
  * Handle a left-click at SGR (col, row) — 1-based terminal coordinates.
40
- * ctx: { state, render, showPicker, popPicker, pushLine }
44
+ * ctx: { state, render, popPicker }
41
45
  * Returns true when the click was consumed.
42
46
  */
43
47
  export function handleMouseClick(ctx, col, row) {
@@ -62,56 +66,20 @@ export function handleMouseClick(ctx, col, row) {
62
66
  return true
63
67
  }
64
68
 
65
- // ── Conversation: fold-toggle line expands; a message line opens the action menu ──
69
+ // ── Conversation: click a fold marker (expand hint or collapse marker) toggles it ──
66
70
  if (r >= P.conversation.y && r < P.conversation.y + P.conversation.h) {
67
71
  const convLines = buildConvLines(state, dims.cols)
68
72
  const gIdx = convGlobalIndex(convLines.length, P.conversation.h, state.scroll ?? 0)(r - P.conversation.y)
69
73
  if (gIdx === null) return false
70
74
  const lineEl = convLines[gIdx]
71
- if (!lineEl) return false
72
-
73
- // Click on a folded-block hint expand it
74
- if (lineEl._foldToggle) {
75
- state.expandedBlocks ??= new Set()
76
- state.expandedBlocks.add(lineEl._foldToggle)
77
- render()
78
- return true
79
- }
80
- // Click on a message line → action menu
81
- if (lineEl._src !== undefined) {
82
- const src = state.lines[lineEl._src]
83
- if (src) {
84
- openLineMenu(ctx, src)
85
- return true
86
- }
87
- }
75
+ if (!lineEl?._foldToggle) return false
76
+ state.expandedBlocks ??= new Set()
77
+ // Bidirectional: click expands a folded block, collapses an expanded one
78
+ if (state.expandedBlocks.has(lineEl._foldToggle)) state.expandedBlocks.delete(lineEl._foldToggle)
79
+ else state.expandedBlocks.add(lineEl._foldToggle)
80
+ render()
81
+ return true
88
82
  }
89
83
 
90
84
  return false
91
85
  }
92
-
93
- /** Line action menu: copy / edit in input / (fold toggle if the source line folds). */
94
- async function openLineMenu(ctx, srcLine) {
95
- const { state, render, showPicker, pushLine } = ctx
96
- const text = sanitizeDisplay(srcLine.text)
97
- const entries = [
98
- { type: "item", text: `📋 Copy line (${text.length} chars)`, action: "copy" },
99
- { type: "item", text: "✏️ Edit in input box", action: "edit" },
100
- ]
101
- const picked = await showPicker("Line actions", entries)
102
- if (!picked) return
103
- if (picked.action === "copy") {
104
- try {
105
- const { copyToClipboard } = await import("./clipboard.mjs")
106
- await copyToClipboard(text)
107
- pushLine(`[clipboard] copied ${text.length} chars`, (await import("./ansi.mjs")).C.dim)
108
- } catch (e) {
109
- pushLine(`[clipboard] copy failed: ${e.message}`, (await import("./ansi.mjs")).C.error)
110
- }
111
- render()
112
- } else if (picked.action === "edit") {
113
- state.input = [...text]
114
- state.cursor = state.input.length
115
- render()
116
- }
117
- }
@@ -15,8 +15,25 @@ export function convCacheKey(state) {
15
15
  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}`
16
16
  }
17
17
 
18
+ /** Fold marker line: bold-cyan icon + "click to …" phrase underlined (clickable affordance).
19
+ * No indent — flush with the content below it; the caller adds a blank line BEFORE it
20
+ * so the control line stands apart from unrelated content (reported UX). */
21
+ function foldHintLine(text, foldKey, srcIdx) {
22
+ // Underline just the actionable phrase — link/button convention
23
+ const withUnderline = text.replace(/(click to (?:expand|collapse))/, "\x1b[4m$1\x1b[24m")
24
+ return { text: withUnderline, color: C.fold, _foldToggle: foldKey, _src: srcIdx }
25
+ }
26
+
27
+ /** Blank separator before a fold control line (uncolored — must not join consecutive-dim folding).
28
+ * Only the EXPANDED state uses it (▼ sits at the block head); the folded state's ▶
29
+ * control line sits mid-block where the ellipsis used to be, so no separator needed. */
30
+ function blankLine() {
31
+ return { text: "", color: "" }
32
+ }
33
+
18
34
  function highlightSearchMatches(text, query, matchesInLine, globalCurrentIndex, allMatches, lineIndex) {
19
35
  if (!matchesInLine || matchesInLine.length === 0 || !query) return text
36
+
20
37
  let result = ""
21
38
  let lastEnd = 0
22
39
  for (const startIdx of matchesInLine) {
@@ -43,6 +60,10 @@ function buildConvLines(state, cols) {
43
60
  if (_convCache.key === key && _convCache.cols === cols) return _convCache.lines
44
61
 
45
62
  const convLines = []
63
+ // Folding constants (function scope — used by both the long-message fold below
64
+ // and the consecutive-dim fold at the bottom)
65
+ const LONG_FOLD_LINES = 12
66
+ const FOLD_KEEP = 5 // content lines kept in the folded state (first 4 + last 1)
46
67
  for (let i = 0; i < state.lines.length; i++) {
47
68
  const l = state.lines[i]
48
69
  let text = l.text
@@ -52,15 +73,15 @@ function buildConvLines(state, cols) {
52
73
  text = highlightSearchMatches(text, state.search.query, l._searchMatches, state.search.index, state.search.matches, i)
53
74
  }
54
75
 
55
- // Long-message folding: a single DIM line (tool summaries / status output — secondary
56
- // content) that wraps beyond LONG_FOLD_LINES display rows collapses to
57
- // [first, "… N more — click/expand", last]. MAIN OUTPUT (C.text replies) and
58
- // THINKING (C.reason) are NEVER folded folding them hurt readability (reported
59
- // regression: long replies and thinking collapsed behind a click). Keyed by the
76
+ // Long-message folding: ANY single line (main output C.text, thinking C.reason,
77
+ // tool summaries C.dim — whatever wraps beyond LONG_FOLD_LINES display rows)
78
+ // collapses to [blank, ▶, first 4, last] 5 content lines. Main output and
79
+ // thinking are the REAL long content; bidirectional folding (collapse markers
80
+ // + click toggle) keeps them readable the 0.12.7 dim-only restriction was a
81
+ // temporary fix for the single-direction era and is now reverted. Keyed by the
60
82
  // source-line index (`long-${i}`) so the toggle survives re-renders.
61
- const LONG_FOLD_LINES = 12
62
83
  const longKey = `long-${i}`
63
- const folded = state.foldEnabled !== false && !state.expandedBlocks?.has(longKey) && l.color === C.dim
84
+ const folded = state.foldEnabled !== false && !state.expandedBlocks?.has(longKey)
64
85
  const block = []
65
86
  for (const line of formatTables(sanitizeDisplay(text), cols - 1)) {
66
87
  for (const wrapped of wrapText(line, cols - 1)) {
@@ -70,15 +91,24 @@ function buildConvLines(state, cols) {
70
91
  }
71
92
  }
72
93
  if (folded && block.length > LONG_FOLD_LINES) {
73
- convLines.push(block[0])
74
- convLines.push({ text: ` … ${block.length - 2} more lines click to expand`, color: C.fold, _foldToggle: longKey, _src: i })
94
+ // Folded state: first 4 content lines, then the ▶ control line where the
95
+ // ellipsis used to be (the marker itself reads "… N more lines" ellipsis
96
+ // semantics built in), then the last line. No leading blank line needed:
97
+ // the block starts with real content now.
98
+ convLines.push(...block.slice(0, FOLD_KEEP - 1))
99
+ convLines.push(foldHintLine(`▶ … ${block.length - FOLD_KEEP} more lines — click to expand`, longKey, i))
75
100
  convLines.push(block[block.length - 1])
76
- } else {
77
- // Expanded long-DIM blocks must not re-trigger the consecutive-dim folding below
78
- // (folding stacked on folding reported regression). Only long blocks get the marker.
79
- if (block.length > LONG_FOLD_LINES) {
101
+ } else if (block.length > LONG_FOLD_LINES) {
102
+ // EXPANDED long block: blank line + ▼ control line at the HEAD, directly
103
+ // before the content. DIM blocks must not re-trigger the consecutive-dim
104
+ // folding below (folding stacked on folding — reported regression).
105
+ if (l.color === C.dim) {
80
106
  for (const line of block) line._skipDimFold = true
81
107
  }
108
+ convLines.push(blankLine())
109
+ convLines.push(foldHintLine(`▼ … ${block.length} lines — click to collapse`, longKey, i))
110
+ convLines.push(...block)
111
+ } else {
82
112
  convLines.push(...block)
83
113
  }
84
114
  }
@@ -125,12 +155,19 @@ function buildConvLines(state, cols) {
125
155
  if (blockLen > FOLD_LINES && !hasExpandedLong) {
126
156
  const foldKey = `fold-${foldCounter++}`
127
157
  if (state.foldEnabled !== false && !state.expandedBlocks?.has(foldKey)) {
128
- folded.push(convLines[i])
129
- if (blockLen > 2) folded.push(convLines[i + 1])
130
- folded.push({ text: ` … ${blockLen - 2} more lines — click to expand`, color: C.fold, _foldToggle: foldKey })
158
+ // First 4 lines, then the ▶ control line (ellipsis position), then the last line
159
+ folded.push(...convLines.slice(i, i + FOLD_KEEP - 1))
160
+ folded.push(foldHintLine(`▶ … ${blockLen - FOLD_KEEP} more lines — click to expand`, foldKey))
161
+ folded.push(convLines[j - 1])
131
162
  i = j
132
163
  continue
133
164
  }
165
+ // EXPANDED consecutive-dim block: blank + ▼ at the HEAD, then every line
166
+ folded.push(blankLine())
167
+ folded.push(foldHintLine(`▼ … ${blockLen} lines — click to collapse`, foldKey))
168
+ for (let k = i; k < j; k++) folded.push(convLines[k])
169
+ i = j
170
+ continue
134
171
  }
135
172
  }
136
173
  folded.push(line)