@clapecho233/pi-smart-fold 0.1.0 → 0.1.1

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,224 @@
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.
11
+ * finalized: seeded hidden `Thought for …` label →(click)→ full+footer
12
+ * →(click)→ label …
13
+ *
14
+ * Requires ./node_modules/@earendil-works symlinks to the installed pi
15
+ * packages (see README dev section). Not part of `npm test`.
16
+ */
17
+ import assert from "node:assert/strict";
18
+ import { AssistantMessageComponent, initTheme } from "@earendil-works/pi-coding-agent";
19
+ import smartFold from "../index.ts";
20
+
21
+ initTheme("default", false); // headless: the component's render path needs a theme
22
+
23
+ // ---- mock pi ExtensionAPI --------------------------------------------------
24
+ let transformer;
25
+ const handlers = {};
26
+ const mockPi = {
27
+ registerMarkdownTransformer: (fn) => {
28
+ transformer = fn;
29
+ },
30
+ on: (name, fn) => {
31
+ (handlers[name] ??= []).push(fn);
32
+ },
33
+ registerTool: () => {},
34
+ registerCommand: () => {},
35
+ appendEntry: () => {},
36
+ };
37
+ smartFold(mockPi);
38
+ assert.equal(typeof transformer, "function", "transformer registered");
39
+
40
+ const fire = (name, event) => {
41
+ let done = Promise.resolve();
42
+ for (const fn of handlers[name] ?? []) done = done.then(() => fn(event));
43
+ return done;
44
+ };
45
+
46
+ /** Invoke the transformer the way pi's Markdown component would. */
47
+ const render = (markdown, isStreaming) =>
48
+ transformer(markdown, {
49
+ messageType: "assistant-thinking",
50
+ isStreaming,
51
+ availableWidth: 100,
52
+ });
53
+
54
+ /** pi's MouseRegion click handler, verbatim semantics. */
55
+ const click = (comp, runIndex) => {
56
+ const hidden = comp.thinkingVisibilityOverrides.get(runIndex) ?? comp.hideThinkingBlock;
57
+ comp.thinkingVisibilityOverrides.set(runIndex, !hidden);
58
+ comp.updateContent(comp.lastMessage);
59
+ };
60
+
61
+ const msg = (thinking) => ({
62
+ content: [{ type: "thinking", thinking }],
63
+ stopReason: undefined,
64
+ });
65
+ const update = (partial, delta) =>
66
+ fire("message_update", {
67
+ message: { role: "assistant" },
68
+ assistantMessageEvent: { type: "thinking_delta", contentIndex: 0, delta, partial },
69
+ });
70
+
71
+ // ---- streaming: tail ↔ full with a single click -----------------------------
72
+ await fire("message_update", {
73
+ message: { role: "assistant" },
74
+ assistantMessageEvent: { type: "thinking_start", contentIndex: 0, partial: msg("") },
75
+ });
76
+ await update(msg("alpha thoughts"), "alpha thoughts");
77
+
78
+ const comp = new AssistantMessageComponent();
79
+ comp.updateContent(msg("alpha thoughts"), true);
80
+ assert.equal(comp.thinkingVisibilityOverrides.size, 0, "no overrides before any click");
81
+
82
+ // default: scrolling tail — label line on TOP, only the last text line
83
+ assert.match(
84
+ render("alpha thoughts", true),
85
+ /^\*\*Thinking… \(\d+[smh][0-9]*\)\*\*\n\nalpha thoughts$/,
86
+ "default streaming view is the live tail",
87
+ );
88
+
89
+ // click 1 → full text so far, timing line pinned at the BOTTOM
90
+ click(comp, 0);
91
+ assert.equal(comp.thinkingVisibilityOverrides.get(0), false, "click redirected to visible");
92
+ assert.match(
93
+ render("alpha thoughts", true),
94
+ /^alpha thoughts\n\n\*\*Thinking… \(\d+[smh][0-9]*\)\*\*$/,
95
+ "first click expands to full view with the timing line last",
96
+ );
97
+
98
+ // text keeps growing while open → full view follows, timing stays at the bottom
99
+ await update(msg("alpha thoughts\nbeta continues"), "beta continues");
100
+ comp.updateContent(msg("alpha thoughts\nbeta continues"), true);
101
+ assert.match(
102
+ render("alpha thoughts\nbeta continues", true),
103
+ /^alpha thoughts\nbeta continues\n\n\*\*Thinking… \(\d+[smh][0-9]*\)\*\*$/,
104
+ "full view follows growth",
105
+ );
106
+
107
+ // click 2 → back to the scrolling tail (no hidden-label middle state)
108
+ click(comp, 0);
109
+ assert.equal(comp.thinkingVisibilityOverrides.get(0), false, "still visible after collapse");
110
+ assert.match(
111
+ render("alpha thoughts\nbeta continues", true),
112
+ /^\*\*Thinking… \(\d+[smh][0-9]*\)\*\*\n\nbeta continues$/,
113
+ "second click returns to the live tail",
114
+ );
115
+
116
+ // click 3 → full again — a clean two-state toggle
117
+ click(comp, 0);
118
+ assert.equal(comp.thinkingVisibilityOverrides.get(0), false);
119
+ assert.match(
120
+ render("alpha thoughts\nbeta continues", true),
121
+ /^alpha thoughts\nbeta continues\n\n\*\*Thinking… \(\d+[smh][0-9]*\)\*\*$/,
122
+ "third click expands again",
123
+ );
124
+
125
+ // ---- message end: open run keeps full text, now with the final footer -------
126
+ await fire("message_end", {
127
+ message: { role: "assistant", content: msg("alpha thoughts\nbeta continues").content, stopReason: "stop" },
128
+ });
129
+ comp.updateContent(msg("alpha thoughts\nbeta continues"), false); // finalized render
130
+ assert.match(
131
+ render("alpha thoughts\nbeta continues", false),
132
+ /^alpha thoughts\nbeta continues\n\n\*\*Thought for [\d.]+s\*\*$/,
133
+ "run left open at message end shows full text + Thought-for footer",
134
+ );
135
+
136
+ // ---- fresh message: seeded hidden label, single click expands ---------------
137
+ const comp2 = new AssistantMessageComponent();
138
+ comp2.updateContent(msg("alpha thoughts\nbeta continues"), false);
139
+ assert.equal(comp2.thinkingVisibilityOverrides.get(0), true, "finalized runs seeded hidden");
140
+ assert.match(comp2.hiddenThinkingLabel, /Thought for /, "hidden label carries the duration");
141
+
142
+ click(comp2, 0);
143
+ assert.equal(comp2.thinkingVisibilityOverrides.get(0), false, "one click expands");
144
+ assert.match(
145
+ render("alpha thoughts\nbeta continues", false),
146
+ /\n\n\*\*Thought for [\d.]+s\*\*$/,
147
+ "expanded finalized run shows the footer",
148
+ );
149
+ click(comp2, 0);
150
+ assert.equal(comp2.thinkingVisibilityOverrides.get(0), true, "one more click collapses");
151
+
152
+ console.log("✓ scenario A: tail ↔ full while streaming; seeded toggle after");
153
+
154
+ // ---- scenario B: finished run + live run in one streaming message ----------
155
+ // Clicks on the finished run must not disturb the live run's open state.
156
+ const fireB = fire; // same handlers
157
+ const mk = (a, b) => ({
158
+ content: [
159
+ { type: "thinking", thinking: a },
160
+ { type: "toolCall", id: "t1", name: "bash", arguments: {} },
161
+ { type: "thinking", thinking: b },
162
+ ],
163
+ stopReason: undefined,
164
+ });
165
+ /** Partial as it exists at run A's thinking_end: just the thinking block. */
166
+ const endPartial = (a) => ({ content: [{ type: "thinking", thinking: a }], stopReason: undefined });
167
+ /** Partial at run B's thinking_start: run A's block followed by the tool call. */
168
+ const toolPartial = (a) => ({
169
+ content: [
170
+ { type: "thinking", thinking: a },
171
+ { type: "toolCall", id: "t1", name: "bash", arguments: {} },
172
+ ],
173
+ stopReason: undefined,
174
+ });
175
+ await fireB("message_update", {
176
+ message: { role: "assistant" },
177
+ assistantMessageEvent: { type: "thinking_start", contentIndex: 0, partial: mk("", "") },
178
+ });
179
+ await fireB("message_update", {
180
+ message: { role: "assistant" },
181
+ assistantMessageEvent: { type: "thinking_end", contentIndex: 0, partial: endPartial("first run") },
182
+ });
183
+ await fireB("message_update", {
184
+ message: { role: "assistant" },
185
+ assistantMessageEvent: { type: "toolcall_start", contentIndex: 1, partial: toolPartial("first run") },
186
+ });
187
+ await fireB("message_update", {
188
+ message: { role: "assistant" },
189
+ assistantMessageEvent: { type: "thinking_start", contentIndex: 2, partial: toolPartial("first run") },
190
+ });
191
+ await fireB("message_update", {
192
+ message: { role: "assistant" },
193
+ assistantMessageEvent: { type: "thinking_delta", contentIndex: 2, delta: "second run", partial: mk("first run", "second run") },
194
+ });
195
+
196
+ const compB = new AssistantMessageComponent();
197
+ compB.updateContent(mk("first run", "second run"), true);
198
+ // run 0 finished → folded duration line; run 1 live → tail
199
+ assert.match(render("first run", true), /^\*\*Thought for /, "finished run folds while message streams");
200
+ assert.match(render("second run", true), /^\*\*Thinking… /, "live run shows the tail");
201
+
202
+ // open the live run (run 1) to full view
203
+ compB.thinkingVisibilityOverrides.set(1, true); // pi click handler for run 1
204
+ compB.updateContent(compB.lastMessage);
205
+ assert.equal(compB.thinkingVisibilityOverrides.get(1), false, "live-run click redirected");
206
+ assert.match(render("second run", true), /^second run\n\n\*\*Thinking… /, "live run expanded");
207
+
208
+ // clicking the finished run (hidden toggle) must not collapse the live run
209
+ compB.thinkingVisibilityOverrides.set(0, true);
210
+ compB.updateContent(compB.lastMessage);
211
+ assert.equal(compB.thinkingVisibilityOverrides.get(0), true, "finished run toggles natively");
212
+ assert.match(
213
+ render("second run", true),
214
+ /^second run\n\n\*\*Thinking… /,
215
+ "finished-run click does not clobber the live run's open state",
216
+ );
217
+
218
+ // clicking it back open reveals the finished run, live run still untouched
219
+ compB.thinkingVisibilityOverrides.set(0, false);
220
+ compB.updateContent(compB.lastMessage);
221
+ assert.match(render("first run", true), /^first run\n\n\*\*Thought for /, "finished run expands with footer");
222
+ assert.match(render("second run", true), /^second run\n\n\*\*Thinking… /, "live run still expanded");
223
+
224
+ console.log("✓ scenario B: finished-run clicks leave the live run's toggle intact");
@@ -14,8 +14,17 @@ import {
14
14
  lastNonEmptyLine,
15
15
  stripBlockMarkers,
16
16
  collapseThinking,
17
+ formatDuration,
18
+ hashText,
19
+ countLineDiff,
20
+ countEditsLineDiff,
21
+ expandedThinkingSuffix,
22
+ foldedThinkingLine,
23
+ liveExpandedSuffix,
24
+ liveThinkingLine,
17
25
  } from "../lib/fold.ts";
18
26
  import { defaultConfig, loadConfig, saveConfig } from "../lib/config.ts";
27
+ import { ThinkingTracker, trailingThinkingText } from "../lib/thinking.ts";
19
28
 
20
29
  let passed = 0;
21
30
  const check = (name, fn) => {
@@ -40,6 +49,10 @@ check("codePointWidth: CJK is 2", () => {
40
49
  check("codePointWidth: emoji is 2", () => {
41
50
  assert.equal(codePointWidth("😀".codePointAt(0)), 2);
42
51
  });
52
+ check("codePointWidth: stopwatch emoji is 2", () => {
53
+ assert.equal(codePointWidth("⏱".codePointAt(0)), 2);
54
+ assert.equal(displayWidth("⏱ 8s"), 5); // 2 + 1 + 2
55
+ });
43
56
  check("codePointWidth: combining mark is 0", () => {
44
57
  assert.equal(codePointWidth(0x0301), 0);
45
58
  });
@@ -141,7 +154,212 @@ check("collapseThinking: non-finite width degrades to default 80", () => {
141
154
  assert.equal(collapseThinking("a\nb", undefined), "b");
142
155
  });
143
156
 
144
- // -------------------------------------------------------------- config ----
157
+ // --------------------------------------------------------- formatDuration ----
158
+ check("formatDuration: live style uses whole seconds", () => {
159
+ assert.equal(formatDuration(0, "live"), "0s");
160
+ assert.equal(formatDuration(8_400, "live"), "8s");
161
+ assert.equal(formatDuration(91_000, "live"), "1m31s");
162
+ assert.equal(formatDuration(3_723_000, "live"), "1h02m");
163
+ });
164
+ check("formatDuration: final style uses one decimal under a minute", () => {
165
+ assert.equal(formatDuration(12_350), "12.3s"); // toFixed(1) rounds
166
+ assert.equal(formatDuration(940), "0.9s");
167
+ assert.equal(formatDuration(59_999), "60.0s"); // just under the boundary
168
+ });
169
+ check("formatDuration: final style switches to m/h", () => {
170
+ assert.equal(formatDuration(61_500), "1m01s");
171
+ assert.equal(formatDuration(3_723_000), "1h02m");
172
+ });
173
+ check("formatDuration: tolerates garbage", () => {
174
+ assert.equal(formatDuration(Number.NaN), "0.0s");
175
+ assert.equal(formatDuration(-5, "live"), "0s");
176
+ });
177
+
178
+ // -------------------------------------------------------------- hashText ----
179
+ check("hashText: deterministic and distinguishing", () => {
180
+ assert.equal(hashText("hello"), hashText("hello"));
181
+ assert.notEqual(hashText("hello"), hashText("hello!"));
182
+ assert.notEqual(hashText("ab"), hashText("ba")); // same length, different hash
183
+ });
184
+ check("hashText: encodes length", () => {
185
+ assert.equal(hashText(""), "0:811c9dc5"); // FNV offset basis, length 0
186
+ });
187
+
188
+ // ---------------------------------------------------------- countLineDiff ----
189
+ check("countLineDiff: new file → all added", () => {
190
+ assert.deepEqual(countLineDiff(undefined, "a\nb\nc"), { added: 3, removed: 0 });
191
+ });
192
+ check("countLineDiff: identical → zeros (trailing newline tolerant)", () => {
193
+ assert.deepEqual(countLineDiff("a\nb\n", "a\nb\n"), { added: 0, removed: 0 });
194
+ assert.deepEqual(countLineDiff("a\nb", "a\nb\n"), { added: 0, removed: 0 });
195
+ });
196
+ check("countLineDiff: pure append", () => {
197
+ assert.deepEqual(countLineDiff("a\nb", "a\nb\nc\nd"), { added: 2, removed: 0 });
198
+ });
199
+ check("countLineDiff: pure removal", () => {
200
+ assert.deepEqual(countLineDiff("a\nb\nc", "a"), { added: 0, removed: 2 });
201
+ });
202
+ check("countLineDiff: modify one middle line", () => {
203
+ assert.deepEqual(countLineDiff("a\nb\nc", "a\nX\nc"), { added: 1, removed: 1 });
204
+ });
205
+ check("countLineDiff: full rewrite", () => {
206
+ assert.deepEqual(countLineDiff("a\nb\nc", "x\ny\nz"), { added: 3, removed: 3 });
207
+ });
208
+ check("countLineDiff: LCS detects moved/matching middle lines", () => {
209
+ // "b" is kept as a common subsequence
210
+ assert.deepEqual(countLineDiff("a\nb\nc", "x\nb\nz"), { added: 2, removed: 2 });
211
+ });
212
+ check("countLineDiff: oversized middle falls back to replacement", () => {
213
+ const a = Array.from({ length: 100 }, (_, i) => `old-${i}`);
214
+ const b = Array.from({ length: 100 }, (_, i) => `new-${i}`);
215
+ // maxCells=1 forces the fallback path instead of LCS
216
+ assert.deepEqual(countLineDiff(a.join("\n"), b.join("\n"), 1), {
217
+ added: 100,
218
+ removed: 100,
219
+ });
220
+ });
221
+ check("countLineDiff: prefix/suffix trim keeps LCS small", () => {
222
+ const head = Array.from({ length: 500 }, (_, i) => `h${i}`);
223
+ const tail = Array.from({ length: 500 }, (_, i) => `t${i}`);
224
+ const oldText = [...head, "MID", ...tail].join("\n");
225
+ const newText = [...head, "NEW", ...tail].join("\n");
226
+ assert.deepEqual(countLineDiff(oldText, newText), { added: 1, removed: 1 });
227
+ });
228
+
229
+ // -------------------------------------------------- countEditsLineDiff ----
230
+ check("countEditsLineDiff: sums over the edits array", () => {
231
+ const stat = countEditsLineDiff({
232
+ edits: [
233
+ { oldText: "a\nb\nc", newText: "a\nX\nc" }, // +1 -1
234
+ { oldText: "tail", newText: "tail\nextra" }, // +1 -0
235
+ ],
236
+ });
237
+ assert.deepEqual(stat, { added: 2, removed: 1 });
238
+ });
239
+ check("countEditsLineDiff: legacy single oldText/newText shape", () => {
240
+ assert.deepEqual(countEditsLineDiff({ oldText: "x", newText: "y\nz" }), { added: 2, removed: 1 });
241
+ });
242
+ check("countEditsLineDiff: garbage input yields undefined", () => {
243
+ assert.equal(countEditsLineDiff(undefined), undefined);
244
+ assert.equal(countEditsLineDiff({}), undefined);
245
+ assert.equal(countEditsLineDiff({ edits: "nope" }), undefined);
246
+ assert.equal(countEditsLineDiff({ edits: [{ oldText: 5 }] }), undefined);
247
+ });
248
+ check("countEditsLineDiff: new-content-only edit counts as additions", () => {
249
+ assert.deepEqual(countEditsLineDiff({ edits: [{ newText: "a\nb" }] }), { added: 2, removed: 0 });
250
+ });
251
+
252
+ // -------------------------------------------------- thinking line renderers ----
253
+ check("liveThinkingLine: bold label line above the tail", () => {
254
+ assert.equal(
255
+ liveThinkingLine("first\nlast line", 8_000, 80),
256
+ "**Thinking… (8s)**\n\nlast line",
257
+ );
258
+ });
259
+ check("liveThinkingLine: unknown duration → bare tail", () => {
260
+ assert.equal(liveThinkingLine("first\nlast line", undefined, 80), "last line");
261
+ });
262
+ check("liveThinkingLine: truncates tail to width", () => {
263
+ const out = liveThinkingLine("short\n" + "x".repeat(100), 5_000, 20);
264
+ const [label, , tail] = out.split("\n");
265
+ assert.equal(label, "**Thinking… (5s)**");
266
+ assert.equal(displayWidth(tail) <= 20, true);
267
+ assert.equal(tail.startsWith("…"), true);
268
+ });
269
+ check("liveThinkingLine: empty content passes through", () => {
270
+ assert.equal(liveThinkingLine("\n \n", 1_000, 80), "\n \n");
271
+ });
272
+ check("foldedThinkingLine: smart shows bold Thought-for line", () => {
273
+ assert.equal(
274
+ foldedThinkingLine("anything at all", 12_340, 80, "smart"),
275
+ "**Thought for 12.3s**",
276
+ );
277
+ });
278
+ check("foldedThinkingLine: smart without duration", () => {
279
+ assert.equal(foldedThinkingLine("anything", undefined, 80, "smart"), "**Thought…**");
280
+ });
281
+ check("foldedThinkingLine: tail keeps the tail + bold duration", () => {
282
+ assert.equal(
283
+ foldedThinkingLine("first\nlast line", 12_340, 80, "tail"),
284
+ "**12.3s** · last line",
285
+ );
286
+ });
287
+ check("expandedThinkingSuffix: bold footer or empty", () => {
288
+ assert.equal(expandedThinkingSuffix(61_500), "\n\n**Thought for 1m01s**");
289
+ assert.equal(expandedThinkingSuffix(undefined), "");
290
+ });
291
+ check("liveExpandedSuffix: bold ticking footer at the bottom", () => {
292
+ assert.equal(liveExpandedSuffix(8_000), "\n\n**Thinking… (8s)**");
293
+ assert.equal(liveExpandedSuffix(91_000), "\n\n**Thinking… (1m31s)**");
294
+ assert.equal(liveExpandedSuffix(undefined), "");
295
+ // appended after the full text, the timing line ends up as the last line
296
+ const view = "first thought\nsecond thought" + liveExpandedSuffix(8_000);
297
+ assert.equal(view.split("\n").at(-1), "**Thinking… (8s)**");
298
+ });
299
+
300
+ // ------------------------------------------------------------ config ----
301
+ check("config: defaults when file missing", () => {
302
+ const dir = mkdtempSync(join(tmpdir(), "smart-fold-"));
303
+ try {
304
+ assert.deepEqual(loadConfig(dir), defaultConfig);
305
+ } finally {
306
+ rmSync(dir, { recursive: true, force: true });
307
+ }
308
+ });
309
+ check("config: save/load roundtrip with new fields", () => {
310
+ const dir = mkdtempSync(join(tmpdir(), "smart-fold-"));
311
+ try {
312
+ const next = {
313
+ toolsFold: false,
314
+ thinking: "tail",
315
+ writeStat: false,
316
+ writeCollapsed: "preview",
317
+ };
318
+ assert.equal(saveConfig(dir, next), true);
319
+ assert.deepEqual(loadConfig(dir), next);
320
+ } finally {
321
+ rmSync(dir, { recursive: true, force: true });
322
+ }
323
+ });
324
+ check("config: migrates legacy thinkingFold boolean", () => {
325
+ const dir = mkdtempSync(join(tmpdir(), "smart-fold-"));
326
+ try {
327
+ writeFileSync(join(dir, "smart-fold.config.json"), '{"thinkingFold": false}', "utf8");
328
+ assert.deepEqual(loadConfig(dir).thinking, "off");
329
+ writeFileSync(join(dir, "smart-fold.config.json"), '{"thinkingFold": true}', "utf8");
330
+ assert.deepEqual(loadConfig(dir).thinking, "smart");
331
+ } finally {
332
+ rmSync(dir, { recursive: true, force: true });
333
+ }
334
+ });
335
+ check("config: invalid values fall back to defaults", () => {
336
+ const dir = mkdtempSync(join(tmpdir(), "smart-fold-"));
337
+ try {
338
+ writeFileSync(
339
+ join(dir, "smart-fold.config.json"),
340
+ '{"thinking": "bogus", "writeCollapsed": 42, "writeStat": "yes", "toolsFold": 1}',
341
+ "utf8",
342
+ );
343
+ const loaded = loadConfig(dir);
344
+ assert.deepEqual(loaded.thinking, "smart");
345
+ assert.deepEqual(loaded.writeCollapsed, "header");
346
+ assert.deepEqual(loaded.writeStat, true);
347
+ assert.deepEqual(loaded.toolsFold, true);
348
+ } finally {
349
+ rmSync(dir, { recursive: true, force: true });
350
+ }
351
+ });
352
+ check("config: broken JSON falls back to defaults", () => {
353
+ const dir = mkdtempSync(join(tmpdir(), "smart-fold-"));
354
+ try {
355
+ writeFileSync(join(dir, "smart-fold.config.json"), "{oops", "utf8");
356
+ assert.deepEqual(loadConfig(dir), defaultConfig);
357
+ } finally {
358
+ rmSync(dir, { recursive: true, force: true });
359
+ }
360
+ });
361
+
362
+ // ------------------------------------------------------------ config ----
145
363
  check("config: defaults when file missing", () => {
146
364
  const dir = mkdtempSync(join(tmpdir(), "smart-fold-"));
147
365
  try {
@@ -150,14 +368,45 @@ check("config: defaults when file missing", () => {
150
368
  rmSync(dir, { recursive: true, force: true });
151
369
  }
152
370
  });
153
- check("config: save/load roundtrip and partial merge", () => {
371
+ check("config: save/load roundtrip with new fields", () => {
372
+ const dir = mkdtempSync(join(tmpdir(), "smart-fold-"));
373
+ try {
374
+ const next = {
375
+ toolsFold: false,
376
+ thinking: "tail",
377
+ writeStat: false,
378
+ writeCollapsed: "preview",
379
+ };
380
+ assert.equal(saveConfig(dir, next), true);
381
+ assert.deepEqual(loadConfig(dir), next);
382
+ } finally {
383
+ rmSync(dir, { recursive: true, force: true });
384
+ }
385
+ });
386
+ check("config: migrates legacy thinkingFold boolean", () => {
154
387
  const dir = mkdtempSync(join(tmpdir(), "smart-fold-"));
155
388
  try {
156
- assert.equal(saveConfig(dir, { toolsFold: false, thinkingFold: true }), true);
157
- assert.deepEqual(loadConfig(dir), { toolsFold: false, thinkingFold: true });
158
- // partial file merges over defaults
159
389
  writeFileSync(join(dir, "smart-fold.config.json"), '{"thinkingFold": false}', "utf8");
160
- assert.deepEqual(loadConfig(dir), { toolsFold: true, thinkingFold: false });
390
+ assert.deepEqual(loadConfig(dir).thinking, "off");
391
+ writeFileSync(join(dir, "smart-fold.config.json"), '{"thinkingFold": true}', "utf8");
392
+ assert.deepEqual(loadConfig(dir).thinking, "smart");
393
+ } finally {
394
+ rmSync(dir, { recursive: true, force: true });
395
+ }
396
+ });
397
+ check("config: invalid values fall back to defaults", () => {
398
+ const dir = mkdtempSync(join(tmpdir(), "smart-fold-"));
399
+ try {
400
+ writeFileSync(
401
+ join(dir, "smart-fold.config.json"),
402
+ '{"thinking": "bogus", "writeCollapsed": 42, "writeStat": "yes", "toolsFold": 1}',
403
+ "utf8",
404
+ );
405
+ const loaded = loadConfig(dir);
406
+ assert.deepEqual(loaded.thinking, "smart");
407
+ assert.deepEqual(loaded.writeCollapsed, "header");
408
+ assert.deepEqual(loaded.writeStat, true);
409
+ assert.deepEqual(loaded.toolsFold, true);
161
410
  } finally {
162
411
  rmSync(dir, { recursive: true, force: true });
163
412
  }
@@ -172,4 +421,162 @@ check("config: broken JSON falls back to defaults", () => {
172
421
  }
173
422
  });
174
423
 
424
+ // ------------------------------------------------------ ThinkingTracker ----
425
+ const msg = (content) => ({ content });
426
+
427
+ check("ThinkingTracker: single run lifecycle", () => {
428
+ let t = 1_000;
429
+ const tracker = new ThinkingTracker(() => t);
430
+ tracker.handleUpdate({ type: "thinking_start", contentIndex: 0, partial: msg([]) });
431
+ t = 3_000;
432
+ tracker.handleUpdate({ type: "thinking_delta", contentIndex: 0, delta: "hmm ", partial: msg([{ type: "thinking", thinking: "hmm " }]) });
433
+ t = 5_200;
434
+ tracker.handleUpdate({
435
+ type: "thinking_end",
436
+ contentIndex: 0,
437
+ partial: msg([{ type: "thinking", thinking: "hmm let me think" }]),
438
+ });
439
+ // live elapsed while open
440
+ t = 6_000;
441
+ assert.equal(tracker.liveElapsedMs(), 5_000);
442
+ // text followed → closes the run
443
+ t = 7_000;
444
+ tracker.handleUpdate({
445
+ type: "text_start",
446
+ contentIndex: 1,
447
+ partial: msg([{ type: "thinking", thinking: "hmm let me think" }, { type: "text", text: "" }]),
448
+ });
449
+ assert.equal(tracker.liveElapsedMs(), undefined);
450
+ const hash = hashText("hmm let me think");
451
+ assert.equal(tracker.finalizedMs(hash), 4_200); // 5200 - 1000
452
+ const runs = tracker.drainPending();
453
+ assert.deepEqual(runs, [{ hash, ms: 4_200 }]);
454
+ assert.deepEqual(tracker.drainPending(), []);
455
+ });
456
+
457
+ check("ThinkingTracker: consecutive thinking blocks form one group", () => {
458
+ let t = 100;
459
+ const tracker = new ThinkingTracker(() => t);
460
+ tracker.handleUpdate({ type: "thinking_start", contentIndex: 0, partial: msg([]) });
461
+ t = 200;
462
+ tracker.handleUpdate({ type: "thinking_delta", contentIndex: 0, delta: "part one", partial: msg([{ type: "thinking", thinking: "part one" }]) });
463
+ t = 300;
464
+ tracker.handleUpdate({
465
+ type: "thinking_end",
466
+ contentIndex: 0,
467
+ partial: msg([{ type: "thinking", thinking: "part one" }]),
468
+ });
469
+ // second thinking block immediately after → same group, no close
470
+ tracker.handleUpdate({
471
+ type: "thinking_start",
472
+ contentIndex: 1,
473
+ partial: msg([{ type: "thinking", thinking: "part one" }]),
474
+ });
475
+ t = 400;
476
+ tracker.handleUpdate({
477
+ type: "thinking_end",
478
+ contentIndex: 1,
479
+ partial: msg([{ type: "thinking", thinking: "part one" }, { type: "thinking", thinking: "part two" }]),
480
+ });
481
+ const runs = tracker.handleMessageEnd(
482
+ msg([{ type: "thinking", thinking: "part one" }, { type: "thinking", thinking: "part two" }]),
483
+ );
484
+ assert.equal(runs.length, 1);
485
+ assert.deepEqual(runs[0], { hash: hashText("part one\n\npart two"), ms: 300 }); // 400 - 100
486
+ });
487
+
488
+ check("ThinkingTracker: tool call between thinking blocks splits groups", () => {
489
+ let t = 0;
490
+ const tracker = new ThinkingTracker(() => t);
491
+ t = 100;
492
+ tracker.handleUpdate({ type: "thinking_start", contentIndex: 0, partial: msg([]) });
493
+ t = 200;
494
+ tracker.handleUpdate({
495
+ type: "thinking_end",
496
+ contentIndex: 0,
497
+ partial: msg([{ type: "thinking", thinking: "before tool" }]),
498
+ });
499
+ t = 900;
500
+ tracker.handleUpdate({
501
+ type: "toolcall_start",
502
+ contentIndex: 1,
503
+ partial: msg([{ type: "thinking", thinking: "before tool" }, { type: "toolCall" }]),
504
+ });
505
+ // group 1's thinking ended at its last activity (t=200) → 100ms of thinking
506
+ assert.equal(tracker.finalizedMs(hashText("before tool")), 100);
507
+ // second group after the tool
508
+ tracker.handleUpdate({
509
+ type: "thinking_start",
510
+ contentIndex: 2,
511
+ partial: msg([{ type: "toolCall" }]),
512
+ });
513
+ t = 1_500;
514
+ const runs = tracker.handleMessageEnd(msg([{ type: "toolCall" }, { type: "thinking", thinking: "after tool" }]));
515
+ // both runs of the message are drained together
516
+ assert.equal(runs.length, 2);
517
+ assert.deepEqual(runs[0], { hash: hashText("before tool"), ms: 100 });
518
+ assert.deepEqual(runs[1], { hash: hashText("after tool"), ms: 600 }); // 1500 - 900
519
+ assert.equal(tracker.finalizedMs(hashText("after tool")), 600);
520
+ });
521
+
522
+ check("ThinkingTracker: finalizeIfMatches closes on exact text", () => {
523
+ let t = 10_000;
524
+ const tracker = new ThinkingTracker(() => t);
525
+ tracker.handleUpdate({ type: "thinking_start", contentIndex: 0, partial: msg([]) });
526
+ t = 12_500;
527
+ tracker.handleUpdate({
528
+ type: "thinking_end",
529
+ contentIndex: 0,
530
+ partial: msg([{ type: "thinking", thinking: "exact text" }]),
531
+ });
532
+ assert.equal(tracker.finalizeIfMatches("exact text"), 2_500);
533
+ assert.equal(tracker.liveElapsedMs(), undefined);
534
+ assert.equal(tracker.finalizeIfMatches("exact text"), undefined); // already closed
535
+ assert.equal(tracker.finalizedMs(hashText("exact text")), 2_500);
536
+ });
537
+
538
+ check("ThinkingTracker: restore merges persisted durations", () => {
539
+ const tracker = new ThinkingTracker(() => 0);
540
+ const hash = hashText("restored thought");
541
+ tracker.restore([{ hash, ms: 3_000 }]);
542
+ assert.equal(tracker.finalizedMs(hash), 3_000);
543
+ // invalid entries are ignored
544
+ tracker.restore([{ hash: 42, ms: "x" }, { hash: "ok", ms: 5 }]);
545
+ assert.equal(tracker.finalizedMs("ok"), 5);
546
+ });
547
+
548
+ check("ThinkingTracker: delta-only fallback when no thinking_end seen", () => {
549
+ let t = 0;
550
+ const tracker = new ThinkingTracker(() => t);
551
+ t = 100;
552
+ tracker.handleUpdate({ type: "thinking_start", contentIndex: 0, partial: msg([]) });
553
+ t = 250;
554
+ tracker.handleUpdate({ type: "thinking_delta", contentIndex: 0, delta: "partial", partial: msg([]) });
555
+ const runs = tracker.handleMessageEnd(undefined);
556
+ assert.equal(runs.length, 1);
557
+ assert.deepEqual(runs[0], { hash: hashText("partial"), ms: 150 });
558
+ });
559
+
560
+ // ------------------------------------------------------ trailingThinkingText ----
561
+ check("trailingThinkingText: joins and trims trailing thinking blocks", () => {
562
+ assert.equal(
563
+ trailingThinkingText(
564
+ msg([{ type: "text", text: "hi" }, { type: "thinking", thinking: " one " }, { type: "thinking", thinking: "two" }]),
565
+ ),
566
+ "one\n\ntwo",
567
+ );
568
+ });
569
+ check("trailingThinkingText: stops at non-thinking block", () => {
570
+ assert.equal(
571
+ trailingThinkingText(msg([{ type: "thinking", thinking: "x" }, { type: "text", text: "hi" }])),
572
+ null,
573
+ );
574
+ });
575
+ check("trailingThinkingText: skips empty thinking blocks at the end", () => {
576
+ assert.equal(
577
+ trailingThinkingText(msg([{ type: "thinking", thinking: "x" }, { type: "thinking", thinking: " " }])),
578
+ "x",
579
+ );
580
+ });
581
+
175
582
  console.log(`✓ ${passed} test groups passed`);