@clapecho233/pi-smart-fold 0.1.0 → 0.2.0

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.
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Link the locally installed pi runtime into this project's node_modules so
4
+ * the extension typechecks and imports against the exact pi version that is
5
+ * installed on this machine (the same runtime that loads the extension).
6
+ *
7
+ * Creates:
8
+ * node_modules/@earendil-works/pi-coding-agent -> <pi>/libexec/lib/node_modules/@earendil-works/pi-coding-agent
9
+ * node_modules/@earendil-works/pi-tui -> <pi>/.../pi-coding-agent/node_modules/@earendil-works/pi-tui
10
+ *
11
+ * Re-run after every pi upgrade (also wired up as the npm `postinstall` and
12
+ * `link:pi` scripts; pass --required to fail hard when pi is not found).
13
+ */
14
+ import { execFileSync } from "node:child_process";
15
+ import { mkdirSync, readlinkSync, rmSync, symlinkSync } from "node:fs";
16
+ import { dirname, resolve } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
20
+
21
+ /** Resolve the pi installation root from the `pi` launcher on PATH. */
22
+ function piRoot() {
23
+ for (const finder of [
24
+ () => process.env.PI_BIN,
25
+ () => execFileSync("which", ["pi"], { encoding: "utf8" }).trim(),
26
+ ]) {
27
+ try {
28
+ const bin = finder();
29
+ if (!bin) continue;
30
+ // /opt/homebrew/bin/pi -> ../Cellar/pi-coding-agent/<ver>/bin/pi
31
+ const real = execFileSync("realpath", [bin], { encoding: "utf8" }).trim();
32
+ return dirname(dirname(real)); // .../pi-coding-agent/<ver>
33
+ } catch {
34
+ // try next strategy
35
+ }
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ const root = piRoot();
41
+ if (!root) {
42
+ const message = "link-pi: could not locate the pi installation (is `pi` on PATH?)";
43
+ if (process.argv.includes("--required")) {
44
+ console.error(message);
45
+ process.exit(1);
46
+ }
47
+ console.warn(`${message} — skipping (dev typecheck will not work until linked)`);
48
+ process.exit(0);
49
+ }
50
+
51
+ const agentPkg = resolve(root, "libexec/lib/node_modules/@earendil-works/pi-coding-agent");
52
+ const tuiPkg = resolve(agentPkg, "node_modules/@earendil-works/pi-tui");
53
+
54
+ const links = [
55
+ ["@earendil-works/pi-coding-agent", agentPkg],
56
+ ["@earendil-works/pi-tui", tuiPkg],
57
+ ];
58
+
59
+ for (const [name, target] of links) {
60
+ const linkPath = resolve(projectRoot, "node_modules", name);
61
+ try {
62
+ const existing = readlinkSync(linkPath);
63
+ if (existing === target) {
64
+ console.log(`link-pi: ${name} already -> ${target}`);
65
+ continue;
66
+ }
67
+ rmSync(linkPath, { force: true, recursive: true });
68
+ } catch {
69
+ // no existing link (or a real directory — leave real dirs alone unless
70
+ // they are dangling symlinks, handled by rmSync above)
71
+ }
72
+ mkdirSync(dirname(linkPath), { recursive: true });
73
+ symlinkSync(target, linkPath);
74
+ console.log(`link-pi: linked ${name} -> ${target}`);
75
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Load-test the extension against the installed pi runtime (jiti), the same
3
+ * loader pi uses for filesystem extensions. Exercises the default export
4
+ * with a stub ExtensionAPI and reports what got registered.
5
+ */
6
+ import { createRequire } from "node:module";
7
+ import { resolve } from "node:path";
8
+
9
+ const root = resolve(import.meta.dirname, "..");
10
+ // Resolve jiti from the linked pi package (extensions run inside pi's module
11
+ // root, so this mirrors the real resolution environment).
12
+ const require2 = createRequire(
13
+ resolve(root, "node_modules/@earendil-works/pi-coding-agent/package.json"),
14
+ );
15
+ const jitiPath = require2.resolve("jiti");
16
+ const { createJiti } = await import(
17
+ jitiPath.startsWith("/") ? `file://${jitiPath}` : jitiPath,
18
+ );
19
+ const jiti = createJiti(import.meta.url);
20
+ const mod = await jiti.import(resolve(root, "index.ts"), { default: true });
21
+
22
+ console.log("default export:", typeof mod);
23
+ if (typeof mod !== "function") process.exit(1);
24
+
25
+ const registered = { transformers: 0, tools: [], commands: [], handlers: [] };
26
+ const stub = {
27
+ registerMarkdownTransformer: () => registered.transformers++,
28
+ registerTool: (t) => registered.tools.push(t?.name),
29
+ registerCommand: (n) => registered.commands.push(n),
30
+ on: (e) => registered.handlers.push(e),
31
+ appendEntry: () => {},
32
+ };
33
+ mod(stub);
34
+ console.log(
35
+ "transformers:", registered.transformers,
36
+ "| tools:", registered.tools.join(","),
37
+ "| commands:", registered.commands.join(","),
38
+ "| handlers:", registered.handlers.join(","),
39
+ );
40
+ if (registered.transformers !== 1 || registered.tools.length !== 7 || registered.commands[0] !== "fold") {
41
+ console.error("registration shape unexpected");
42
+ process.exit(1);
43
+ }
44
+ console.log("OK");
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Click-cycle simulation against the REAL pi AssistantMessageComponent.
3
+ *
4
+ * Drives the patched updateContent + pi's exact MouseRegion click semantics
5
+ (copied from pi's assistant-message.js) and asserts the user-facing toggle:
6
+ *
7
+ * streaming: tail →(click)→ full →(click)→ tail →(click)→ full …
8
+ * full view keeps the ticking `Thinking… (Ns)` line at the
9
+ * BOTTOM of the block; the hidden-label middle state never
10
+ * appears. Same one-click toggle for a run that already
11
+ * finished while the message keeps streaming (folded duration
12
+ * line ↔ full text with footer).
13
+ * finalized: seeded hidden `Thought for …` label →(click)→ full+footer
14
+ * →(click)→ label …
15
+ *
16
+ * Requires ./node_modules/@earendil-works symlinks to the installed pi
17
+ * packages (see README dev section). Not part of `npm test`.
18
+ */
19
+ import assert from "node:assert/strict";
20
+ import { AssistantMessageComponent, initTheme } from "@earendil-works/pi-coding-agent";
21
+ import smartFold from "../index.ts";
22
+
23
+ initTheme("default", false); // headless: the component's render path needs a theme
24
+
25
+ // ---- mock pi ExtensionAPI --------------------------------------------------
26
+ let transformer;
27
+ const handlers = {};
28
+ const mockPi = {
29
+ registerMarkdownTransformer: (fn) => {
30
+ transformer = fn;
31
+ },
32
+ on: (name, fn) => {
33
+ (handlers[name] ??= []).push(fn);
34
+ },
35
+ registerTool: () => {},
36
+ registerCommand: () => {},
37
+ appendEntry: () => {},
38
+ };
39
+ smartFold(mockPi);
40
+ assert.equal(typeof transformer, "function", "transformer registered");
41
+
42
+ const fire = (name, event) => {
43
+ let done = Promise.resolve();
44
+ for (const fn of handlers[name] ?? []) done = done.then(() => fn(event));
45
+ return done;
46
+ };
47
+
48
+ /** Invoke the transformer the way pi's Markdown component would. */
49
+ const render = (markdown, isStreaming) =>
50
+ transformer(markdown, {
51
+ messageType: "assistant-thinking",
52
+ isStreaming,
53
+ availableWidth: 100,
54
+ });
55
+
56
+ /** pi's MouseRegion click handler, verbatim semantics. */
57
+ const click = (comp, runIndex) => {
58
+ const hidden = comp.thinkingVisibilityOverrides.get(runIndex) ?? comp.hideThinkingBlock;
59
+ comp.thinkingVisibilityOverrides.set(runIndex, !hidden);
60
+ comp.updateContent(comp.lastMessage);
61
+ };
62
+
63
+ const msg = (thinking) => ({
64
+ content: [{ type: "thinking", thinking }],
65
+ stopReason: undefined,
66
+ });
67
+ const update = (partial, delta) =>
68
+ fire("message_update", {
69
+ message: { role: "assistant" },
70
+ assistantMessageEvent: { type: "thinking_delta", contentIndex: 0, delta, partial },
71
+ });
72
+
73
+ // ---- streaming: tail ↔ full with a single click -----------------------------
74
+ await fire("message_update", {
75
+ message: { role: "assistant" },
76
+ assistantMessageEvent: { type: "thinking_start", contentIndex: 0, partial: msg("") },
77
+ });
78
+ await update(msg("alpha thoughts"), "alpha thoughts");
79
+
80
+ const comp = new AssistantMessageComponent();
81
+ comp.updateContent(msg("alpha thoughts"), true);
82
+ assert.equal(comp.thinkingVisibilityOverrides.size, 0, "no overrides before any click");
83
+
84
+ // default: scrolling tail — label line on TOP, only the last text line
85
+ assert.match(
86
+ render("alpha thoughts", true),
87
+ /^\*\*Thinking… \(\d+[smh][0-9]*\)\*\*\n\nalpha thoughts$/,
88
+ "default streaming view is the live tail",
89
+ );
90
+
91
+ // click 1 → full text so far, timing line pinned at the BOTTOM
92
+ click(comp, 0);
93
+ assert.equal(comp.thinkingVisibilityOverrides.get(0), false, "click redirected to visible");
94
+ assert.match(
95
+ render("alpha thoughts", true),
96
+ /^alpha thoughts\n\n\*\*Thinking… \(\d+[smh][0-9]*\)\*\*$/,
97
+ "first click expands to full view with the timing line last",
98
+ );
99
+
100
+ // text keeps growing while open → full view follows, timing stays at the bottom
101
+ await update(msg("alpha thoughts\nbeta continues"), "beta continues");
102
+ comp.updateContent(msg("alpha thoughts\nbeta continues"), true);
103
+ assert.match(
104
+ render("alpha thoughts\nbeta continues", true),
105
+ /^alpha thoughts\nbeta continues\n\n\*\*Thinking… \(\d+[smh][0-9]*\)\*\*$/,
106
+ "full view follows growth",
107
+ );
108
+
109
+ // click 2 → back to the scrolling tail (no hidden-label middle state)
110
+ click(comp, 0);
111
+ assert.equal(comp.thinkingVisibilityOverrides.get(0), false, "still visible after collapse");
112
+ assert.match(
113
+ render("alpha thoughts\nbeta continues", true),
114
+ /^\*\*Thinking… \(\d+[smh][0-9]*\)\*\*\n\nbeta continues$/,
115
+ "second click returns to the live tail",
116
+ );
117
+
118
+ // click 3 → full again — a clean two-state toggle
119
+ click(comp, 0);
120
+ assert.equal(comp.thinkingVisibilityOverrides.get(0), false);
121
+ assert.match(
122
+ render("alpha thoughts\nbeta continues", true),
123
+ /^alpha thoughts\nbeta continues\n\n\*\*Thinking… \(\d+[smh][0-9]*\)\*\*$/,
124
+ "third click expands again",
125
+ );
126
+
127
+ // ---- message end: open run keeps full text, now with the final footer -------
128
+ await fire("message_end", {
129
+ message: { role: "assistant", content: msg("alpha thoughts\nbeta continues").content, stopReason: "stop" },
130
+ });
131
+ comp.updateContent(msg("alpha thoughts\nbeta continues"), false); // finalized render
132
+ assert.match(
133
+ render("alpha thoughts\nbeta continues", false),
134
+ /^alpha thoughts\nbeta continues\n\n\*\*Thought for [\d.]+s\*\*$/,
135
+ "run left open at message end shows full text + Thought-for footer",
136
+ );
137
+
138
+ // ---- fresh message: seeded hidden label, single click expands ---------------
139
+ const comp2 = new AssistantMessageComponent();
140
+ comp2.updateContent(msg("alpha thoughts\nbeta continues"), false);
141
+ assert.equal(comp2.thinkingVisibilityOverrides.get(0), true, "finalized runs seeded hidden");
142
+ assert.match(comp2.hiddenThinkingLabel, /Thought for /, "hidden label carries the duration");
143
+
144
+ click(comp2, 0);
145
+ assert.equal(comp2.thinkingVisibilityOverrides.get(0), false, "one click expands");
146
+ assert.match(
147
+ render("alpha thoughts\nbeta continues", false),
148
+ /\n\n\*\*Thought for [\d.]+s\*\*$/,
149
+ "expanded finalized run shows the footer",
150
+ );
151
+ click(comp2, 0);
152
+ assert.equal(comp2.thinkingVisibilityOverrides.get(0), true, "one more click collapses");
153
+
154
+ console.log("✓ scenario A: tail ↔ full while streaming; seeded toggle after");
155
+
156
+ // ---- scenario B: finished run + live run in one streaming message ----------
157
+ // Clicks on the finished run must not disturb the live run's open state.
158
+ const fireB = fire; // same handlers
159
+ const mk = (a, b) => ({
160
+ content: [
161
+ { type: "thinking", thinking: a },
162
+ { type: "toolCall", id: "t1", name: "bash", arguments: {} },
163
+ { type: "thinking", thinking: b },
164
+ ],
165
+ stopReason: undefined,
166
+ });
167
+ /** Partial as it exists at run A's thinking_end: just the thinking block. */
168
+ const endPartial = (a) => ({ content: [{ type: "thinking", thinking: a }], stopReason: undefined });
169
+ /** Partial at run B's thinking_start: run A's block followed by the tool call. */
170
+ const toolPartial = (a) => ({
171
+ content: [
172
+ { type: "thinking", thinking: a },
173
+ { type: "toolCall", id: "t1", name: "bash", arguments: {} },
174
+ ],
175
+ stopReason: undefined,
176
+ });
177
+ await fireB("message_update", {
178
+ message: { role: "assistant" },
179
+ assistantMessageEvent: { type: "thinking_start", contentIndex: 0, partial: mk("", "") },
180
+ });
181
+ await fireB("message_update", {
182
+ message: { role: "assistant" },
183
+ assistantMessageEvent: { type: "thinking_end", contentIndex: 0, partial: endPartial("first run") },
184
+ });
185
+ await fireB("message_update", {
186
+ message: { role: "assistant" },
187
+ assistantMessageEvent: { type: "toolcall_start", contentIndex: 1, partial: toolPartial("first run") },
188
+ });
189
+ await fireB("message_update", {
190
+ message: { role: "assistant" },
191
+ assistantMessageEvent: { type: "thinking_start", contentIndex: 2, partial: toolPartial("first run") },
192
+ });
193
+ await fireB("message_update", {
194
+ message: { role: "assistant" },
195
+ assistantMessageEvent: { type: "thinking_delta", contentIndex: 2, delta: "second run", partial: mk("first run", "second run") },
196
+ });
197
+
198
+ const compB = new AssistantMessageComponent();
199
+ compB.updateContent(mk("first run", "second run"), true);
200
+ // run 0 finished → folded duration line; run 1 live → tail
201
+ assert.match(render("first run", true), /^\*\*Thought for /, "finished run folds while message streams");
202
+ assert.match(render("second run", true), /^\*\*Thinking… /, "live run shows the tail");
203
+
204
+ // open the live run (run 1) to full view
205
+ compB.thinkingVisibilityOverrides.set(1, true); // pi click handler for run 1
206
+ compB.updateContent(compB.lastMessage);
207
+ assert.equal(compB.thinkingVisibilityOverrides.get(1), false, "live-run click redirected");
208
+ assert.match(render("second run", true), /^second run\n\n\*\*Thinking… /, "live run expanded");
209
+
210
+ // clicking the finished run while the message still streams: ONE click
211
+ // expands it directly — the global `Thought…` hidden-label middle state
212
+ // must never appear (this was the reported bug)
213
+ compB.thinkingVisibilityOverrides.set(0, true); // pi click handler for run 0
214
+ compB.updateContent(compB.lastMessage);
215
+ assert.equal(compB.thinkingVisibilityOverrides.get(0), false, "finished-run click redirected to visible");
216
+ assert.match(
217
+ render("first run", true),
218
+ /^first run\n\n\*\*Thought for /,
219
+ "one click expands the finished run with the footer",
220
+ );
221
+ assert.match(
222
+ render("second run", true),
223
+ /^second run\n\n\*\*Thinking… /,
224
+ "finished-run click does not clobber the live run's open state",
225
+ );
226
+
227
+ // clicking it again folds the finished run back to the duration line
228
+ compB.thinkingVisibilityOverrides.set(0, true);
229
+ compB.updateContent(compB.lastMessage);
230
+ assert.equal(compB.thinkingVisibilityOverrides.get(0), false, "still visible after collapse");
231
+ assert.match(
232
+ render("first run", true),
233
+ /^\*\*Thought for /,
234
+ "second click re-folds the finished run (no label middle state)",
235
+ );
236
+ assert.match(
237
+ render("second run", true),
238
+ /^second run\n\n\*\*Thinking… /,
239
+ "live run still expanded",
240
+ );
241
+
242
+ // third click expands again — a clean two-state toggle
243
+ compB.thinkingVisibilityOverrides.set(0, true);
244
+ compB.updateContent(compB.lastMessage);
245
+ assert.match(
246
+ render("first run", true),
247
+ /^first run\n\n\*\*Thought for /,
248
+ "third click expands again",
249
+ );
250
+
251
+ console.log("✓ scenario B: finished-run clicks leave the live run's toggle intact");
252
+
253
+ // ---- scenario C: the reported regression — single run finished, answer
254
+ // text still streaming. `Thought for …` → click → full text, click → folded
255
+ // again. Never the global `Thought…` label. --------------------------------
256
+ const fireC = fire; // same handlers
257
+ const msgC = (thinking, text) => ({
258
+ content: [
259
+ { type: "thinking", thinking },
260
+ { type: "text", text },
261
+ ],
262
+ stopReason: undefined,
263
+ });
264
+ const thinkOnly = (t) => ({ content: [{ type: "thinking", thinking: t }], stopReason: undefined });
265
+ await fireC("message_update", {
266
+ message: { role: "assistant" },
267
+ assistantMessageEvent: { type: "thinking_start", contentIndex: 0, partial: thinkOnly("") },
268
+ });
269
+ await fireC("message_update", {
270
+ message: { role: "assistant" },
271
+ assistantMessageEvent: {
272
+ type: "thinking_delta",
273
+ contentIndex: 0,
274
+ delta: "plan the answer",
275
+ partial: thinkOnly("plan the answer"),
276
+ },
277
+ });
278
+ await fireC("message_update", {
279
+ message: { role: "assistant" },
280
+ assistantMessageEvent: { type: "thinking_end", contentIndex: 0, partial: thinkOnly("plan the answer") },
281
+ });
282
+ // the following text block closes the thinking run: it now has a finalized
283
+ // duration while the message itself is still streaming
284
+ await fireC("message_update", {
285
+ message: { role: "assistant" },
286
+ assistantMessageEvent: {
287
+ type: "text_start",
288
+ contentIndex: 1,
289
+ partial: msgC("plan the answer", ""),
290
+ },
291
+ });
292
+
293
+ const compC = new AssistantMessageComponent();
294
+ compC.updateContent(msgC("plan the answer", "answering…"), true);
295
+ assert.match(
296
+ render("plan the answer", true),
297
+ /^\*\*Thought for /,
298
+ "finished run folds while the answer still streams",
299
+ );
300
+
301
+ // click 1 → full text + footer (NOT the global `Thought…` label)
302
+ compC.thinkingVisibilityOverrides.set(0, true);
303
+ compC.updateContent(compC.lastMessage);
304
+ assert.equal(compC.thinkingVisibilityOverrides.get(0), false, "click redirected to visible");
305
+ assert.match(
306
+ render("plan the answer", true),
307
+ /^plan the answer\n\n\*\*Thought for /,
308
+ "one click expands to full text with the footer",
309
+ );
310
+
311
+ // click 2 → folded duration line again
312
+ compC.thinkingVisibilityOverrides.set(0, true);
313
+ compC.updateContent(compC.lastMessage);
314
+ assert.equal(compC.thinkingVisibilityOverrides.get(0), false, "still visible after collapse");
315
+ assert.match(
316
+ render("plan the answer", true),
317
+ /^\*\*Thought for /,
318
+ "second click re-folds to the duration line",
319
+ );
320
+
321
+ // message ends: the run left folded normalizes into the seeded hidden state
322
+ // (label with duration) so the next click expands directly
323
+ await fireC("message_end", {
324
+ message: { role: "assistant", ...msgC("plan the answer", "answering…"), stopReason: "stop" },
325
+ });
326
+ compC.updateContent(compC.lastMessage, false);
327
+ assert.equal(compC.thinkingVisibilityOverrides.get(0), true, "collapsed run normalized to seeded hidden");
328
+ assert.match(compC.hiddenThinkingLabel, /Thought for /, "label carries the duration");
329
+ compC.thinkingVisibilityOverrides.set(0, false);
330
+ compC.updateContent(compC.lastMessage);
331
+ assert.match(
332
+ render("plan the answer", false),
333
+ /^plan the answer\n\n\*\*Thought for /,
334
+ "click after finalize expands in one step",
335
+ );
336
+
337
+ console.log("✓ scenario C: finished run in a streaming message toggles with one click");
338
+
339
+ // ---- scenario D: code blocks inside thinking -------------------------------
340
+ // Collapsed: when the last non-empty line is a bare code fence, the fold must
341
+ // NOT fall back to the full markdown (that flash-open was the flicker).
342
+ // Expanded: the footer must render OUTSIDE an unclosed fence — bold text, not
343
+ // literal `**` asterisks (verified through pi's real Markdown renderer).
344
+ import { Markdown } from "@earendil-works/pi-tui";
345
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
346
+
347
+ const fireD = fire; // same handlers
348
+ const md = (t) => ({ content: [{ type: "thinking", thinking: t }], stopReason: undefined });
349
+ const streamD = (delta, partial) =>
350
+ fireD("message_update", {
351
+ message: { role: "assistant" },
352
+ assistantMessageEvent: { type: "thinking_delta", contentIndex: 0, delta, partial: md(partial) },
353
+ });
354
+
355
+ // stream up to the opening fence: last non-empty line is the bare-ish fence
356
+ await fireD("message_update", {
357
+ message: { role: "assistant" },
358
+ assistantMessageEvent: { type: "thinking_start", contentIndex: 0, partial: md("") },
359
+ });
360
+ await streamD("consider the snippet\n```ts", "consider the snippet\n```ts");
361
+ const compD = new AssistantMessageComponent();
362
+ compD.updateContent(md("consider the snippet\n```ts"), true);
363
+ assert.match(
364
+ render("consider the snippet\n```ts", true),
365
+ /^\*\*Thinking… \(\d+s\)\*\*\n\nconsider the snippet$/,
366
+ "bare fence as last line does not unfold the tail view",
367
+ );
368
+
369
+ // code content streams in: it becomes the tail line
370
+ await streamD("\nconst x = 1;", "consider the snippet\n```ts\nconst x = 1;");
371
+ compD.updateContent(md("consider the snippet\n```ts\nconst x = 1;"), true);
372
+ assert.match(
373
+ render("consider the snippet\n```ts\nconst x = 1;", true),
374
+ /^\*\*Thinking… \(\d+s\)\*\*\n\nconst x = 1;$/,
375
+ "code content line shows as the tail",
376
+ );
377
+
378
+ // expand while the fence is still unclosed → fence closed, footer outside it
379
+ click(compD, 0);
380
+ const expandedD = render("consider the snippet\n```ts\nconst x = 1;", true);
381
+ assert.match(
382
+ expandedD,
383
+ /```ts\nconst x = 1;\n```\n\n\*\*Thinking… \(\d+s\)\*\*$/,
384
+ "unclosed fence gets closed and the footer lands below it",
385
+ );
386
+
387
+ // render through pi's REAL Markdown component: the footer line must be bold
388
+ // "Thinking… (Ns)" — no literal asterisks from being swallowed by the fence
389
+ const stripAnsi = (s) => s.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, "");
390
+ const renderedD = new Markdown(expandedD, 1, 0, getMarkdownTheme()).render(80).map(stripAnsi);
391
+ const lastLineD = [...renderedD].reverse().find((l) => l.trim() !== "");
392
+ assert.match(lastLineD.trim(), /^Thinking… \(\d+s\)$/, "footer renders bold without literal asterisks");
393
+ assert.equal(
394
+ renderedD.some((l) => l.includes("**Thinking")),
395
+ false,
396
+ "no raw ** markers leak into the render",
397
+ );
398
+ assert.equal(
399
+ renderedD.some((l) => l.includes("const x = 1;")),
400
+ true,
401
+ "code content still rendered",
402
+ );
403
+
404
+ // and the pre-fix behavior really did swallow the footer: sanity-check that
405
+ // an unclosed fence without our close would show literal asterisks
406
+ const unclosedView = "consider the snippet\n```ts\nconst x = 1;\n\n**Thinking… (8s)**";
407
+ const renderedU = new Markdown(unclosedView, 1, 0, getMarkdownTheme()).render(80).map(stripAnsi);
408
+ assert.equal(
409
+ renderedU.some((l) => l.includes("**Thinking…")),
410
+ true,
411
+ "sanity: unclosed fence would have swallowed the footer (regression guard)",
412
+ );
413
+
414
+ console.log("✓ scenario D: code blocks — no fold flicker, footer renders outside the fence");