@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.
@@ -12,10 +12,21 @@ import {
12
12
  displayWidth,
13
13
  tailFit,
14
14
  lastNonEmptyLine,
15
+ lastMeaningfulLine,
15
16
  stripBlockMarkers,
16
17
  collapseThinking,
18
+ closeOpenFences,
19
+ formatDuration,
20
+ hashText,
21
+ countLineDiff,
22
+ countEditsLineDiff,
23
+ expandedThinkingSuffix,
24
+ foldedThinkingLine,
25
+ liveExpandedSuffix,
26
+ liveThinkingLine,
17
27
  } from "../lib/fold.ts";
18
28
  import { defaultConfig, loadConfig, saveConfig } from "../lib/config.ts";
29
+ import { ThinkingTracker, trailingThinkingText } from "../lib/thinking.ts";
19
30
 
20
31
  let passed = 0;
21
32
  const check = (name, fn) => {
@@ -40,6 +51,10 @@ check("codePointWidth: CJK is 2", () => {
40
51
  check("codePointWidth: emoji is 2", () => {
41
52
  assert.equal(codePointWidth("😀".codePointAt(0)), 2);
42
53
  });
54
+ check("codePointWidth: stopwatch emoji is 2", () => {
55
+ assert.equal(codePointWidth("⏱".codePointAt(0)), 2);
56
+ assert.equal(displayWidth("⏱ 8s"), 5); // 2 + 1 + 2
57
+ });
43
58
  check("codePointWidth: combining mark is 0", () => {
44
59
  assert.equal(codePointWidth(0x0301), 0);
45
60
  });
@@ -87,6 +102,22 @@ check("lastNonEmptyLine: all empty → empty string", () => {
87
102
  assert.equal(lastNonEmptyLine("\n \n"), "");
88
103
  });
89
104
 
105
+ // ---------------------------------------------------- lastMeaningfulLine ----
106
+ check("lastMeaningfulLine: picks last strippable non-empty line", () => {
107
+ assert.equal(lastMeaningfulLine("first\nsecond\n\n"), "second");
108
+ assert.equal(lastMeaningfulLine("plan:\n- [ ] do it", ), "[ ] do it");
109
+ });
110
+ check("lastMeaningfulLine: skips bare code fences (no fold flicker)", () => {
111
+ assert.equal(lastMeaningfulLine("thinking text\n```"), "thinking text");
112
+ assert.equal(lastMeaningfulLine("```js\ncode()\n```"), "code()");
113
+ assert.equal(lastMeaningfulLine("~~~\nquoted\n~~~"), "quoted");
114
+ });
115
+ check("lastMeaningfulLine: only structural lines → empty", () => {
116
+ assert.equal(lastMeaningfulLine("```"), "");
117
+ assert.equal(lastMeaningfulLine("```js"), "");
118
+ assert.equal(lastMeaningfulLine("\n \n"), "");
119
+ });
120
+
90
121
  // ---------------------------------------------------- stripBlockMarkers ----
91
122
  check("stripBlockMarkers: heading", () => {
92
123
  assert.equal(stripBlockMarkers("## Heading"), "Heading");
@@ -141,7 +172,264 @@ check("collapseThinking: non-finite width degrades to default 80", () => {
141
172
  assert.equal(collapseThinking("a\nb", undefined), "b");
142
173
  });
143
174
 
144
- // -------------------------------------------------------------- config ----
175
+ // --------------------------------------------------------- formatDuration ----
176
+ check("formatDuration: live style uses whole seconds", () => {
177
+ assert.equal(formatDuration(0, "live"), "0s");
178
+ assert.equal(formatDuration(8_400, "live"), "8s");
179
+ assert.equal(formatDuration(91_000, "live"), "1m31s");
180
+ assert.equal(formatDuration(3_723_000, "live"), "1h02m");
181
+ });
182
+ check("formatDuration: final style uses one decimal under a minute", () => {
183
+ assert.equal(formatDuration(12_350), "12.3s"); // toFixed(1) rounds
184
+ assert.equal(formatDuration(940), "0.9s");
185
+ assert.equal(formatDuration(59_999), "60.0s"); // just under the boundary
186
+ });
187
+ check("formatDuration: final style switches to m/h", () => {
188
+ assert.equal(formatDuration(61_500), "1m01s");
189
+ assert.equal(formatDuration(3_723_000), "1h02m");
190
+ });
191
+ check("formatDuration: tolerates garbage", () => {
192
+ assert.equal(formatDuration(Number.NaN), "0.0s");
193
+ assert.equal(formatDuration(-5, "live"), "0s");
194
+ });
195
+
196
+ // -------------------------------------------------------------- hashText ----
197
+ check("hashText: deterministic and distinguishing", () => {
198
+ assert.equal(hashText("hello"), hashText("hello"));
199
+ assert.notEqual(hashText("hello"), hashText("hello!"));
200
+ assert.notEqual(hashText("ab"), hashText("ba")); // same length, different hash
201
+ });
202
+ check("hashText: encodes length", () => {
203
+ assert.equal(hashText(""), "0:811c9dc5"); // FNV offset basis, length 0
204
+ });
205
+
206
+ // ---------------------------------------------------------- countLineDiff ----
207
+ check("countLineDiff: new file → all added", () => {
208
+ assert.deepEqual(countLineDiff(undefined, "a\nb\nc"), { added: 3, removed: 0 });
209
+ });
210
+ check("countLineDiff: identical → zeros (trailing newline tolerant)", () => {
211
+ assert.deepEqual(countLineDiff("a\nb\n", "a\nb\n"), { added: 0, removed: 0 });
212
+ assert.deepEqual(countLineDiff("a\nb", "a\nb\n"), { added: 0, removed: 0 });
213
+ });
214
+ check("countLineDiff: pure append", () => {
215
+ assert.deepEqual(countLineDiff("a\nb", "a\nb\nc\nd"), { added: 2, removed: 0 });
216
+ });
217
+ check("countLineDiff: pure removal", () => {
218
+ assert.deepEqual(countLineDiff("a\nb\nc", "a"), { added: 0, removed: 2 });
219
+ });
220
+ check("countLineDiff: modify one middle line", () => {
221
+ assert.deepEqual(countLineDiff("a\nb\nc", "a\nX\nc"), { added: 1, removed: 1 });
222
+ });
223
+ check("countLineDiff: full rewrite", () => {
224
+ assert.deepEqual(countLineDiff("a\nb\nc", "x\ny\nz"), { added: 3, removed: 3 });
225
+ });
226
+ check("countLineDiff: LCS detects moved/matching middle lines", () => {
227
+ // "b" is kept as a common subsequence
228
+ assert.deepEqual(countLineDiff("a\nb\nc", "x\nb\nz"), { added: 2, removed: 2 });
229
+ });
230
+ check("countLineDiff: oversized middle falls back to replacement", () => {
231
+ const a = Array.from({ length: 100 }, (_, i) => `old-${i}`);
232
+ const b = Array.from({ length: 100 }, (_, i) => `new-${i}`);
233
+ // maxCells=1 forces the fallback path instead of LCS
234
+ assert.deepEqual(countLineDiff(a.join("\n"), b.join("\n"), 1), {
235
+ added: 100,
236
+ removed: 100,
237
+ });
238
+ });
239
+ check("countLineDiff: prefix/suffix trim keeps LCS small", () => {
240
+ const head = Array.from({ length: 500 }, (_, i) => `h${i}`);
241
+ const tail = Array.from({ length: 500 }, (_, i) => `t${i}`);
242
+ const oldText = [...head, "MID", ...tail].join("\n");
243
+ const newText = [...head, "NEW", ...tail].join("\n");
244
+ assert.deepEqual(countLineDiff(oldText, newText), { added: 1, removed: 1 });
245
+ });
246
+
247
+ // -------------------------------------------------- countEditsLineDiff ----
248
+ check("countEditsLineDiff: sums over the edits array", () => {
249
+ const stat = countEditsLineDiff({
250
+ edits: [
251
+ { oldText: "a\nb\nc", newText: "a\nX\nc" }, // +1 -1
252
+ { oldText: "tail", newText: "tail\nextra" }, // +1 -0
253
+ ],
254
+ });
255
+ assert.deepEqual(stat, { added: 2, removed: 1 });
256
+ });
257
+ check("countEditsLineDiff: legacy single oldText/newText shape", () => {
258
+ assert.deepEqual(countEditsLineDiff({ oldText: "x", newText: "y\nz" }), { added: 2, removed: 1 });
259
+ });
260
+ check("countEditsLineDiff: garbage input yields undefined", () => {
261
+ assert.equal(countEditsLineDiff(undefined), undefined);
262
+ assert.equal(countEditsLineDiff({}), undefined);
263
+ assert.equal(countEditsLineDiff({ edits: "nope" }), undefined);
264
+ assert.equal(countEditsLineDiff({ edits: [{ oldText: 5 }] }), undefined);
265
+ });
266
+ check("countEditsLineDiff: new-content-only edit counts as additions", () => {
267
+ assert.deepEqual(countEditsLineDiff({ edits: [{ newText: "a\nb" }] }), { added: 2, removed: 0 });
268
+ });
269
+
270
+ // -------------------------------------------------- thinking line renderers ----
271
+ check("liveThinkingLine: bold label line above the tail", () => {
272
+ assert.equal(
273
+ liveThinkingLine("first\nlast line", 8_000, 80),
274
+ "**Thinking… (8s)**\n\nlast line",
275
+ );
276
+ });
277
+ check("liveThinkingLine: unknown duration → bare tail", () => {
278
+ assert.equal(liveThinkingLine("first\nlast line", undefined, 80), "last line");
279
+ });
280
+ check("liveThinkingLine: truncates tail to width", () => {
281
+ const out = liveThinkingLine("short\n" + "x".repeat(100), 5_000, 20);
282
+ const [label, , tail] = out.split("\n");
283
+ assert.equal(label, "**Thinking… (5s)**");
284
+ assert.equal(displayWidth(tail) <= 20, true);
285
+ assert.equal(tail.startsWith("…"), true);
286
+ });
287
+ check("liveThinkingLine: empty content passes through", () => {
288
+ assert.equal(liveThinkingLine("\n \n", 1_000, 80), "\n \n");
289
+ });
290
+ check("liveThinkingLine: bare fence last → label only, never the full text", () => {
291
+ // While a code block streams, the last non-empty line is often the bare
292
+ // fence — the fold must not flash open (the old fallback returned the full
293
+ // markdown, causing the flicker).
294
+ assert.equal(
295
+ liveThinkingLine("thought about code\n```", 8_000, 80),
296
+ "**Thinking… (8s)**\n\nthought about code",
297
+ );
298
+ assert.equal(liveThinkingLine("```", 8_000, 80), "**Thinking… (8s)**");
299
+ assert.equal(liveThinkingLine("```", undefined, 80), "");
300
+ });
301
+ check("liveThinkingLine: fence with info string is structural", () => {
302
+ assert.equal(
303
+ liveThinkingLine("before block\n```python", 3_000, 80),
304
+ "**Thinking… (3s)**\n\nbefore block",
305
+ );
306
+ });
307
+ check("foldedThinkingLine: smart shows bold Thought-for line", () => {
308
+ assert.equal(
309
+ foldedThinkingLine("anything at all", 12_340, 80, "smart"),
310
+ "**Thought for 12.3s**",
311
+ );
312
+ });
313
+ check("foldedThinkingLine: smart without duration", () => {
314
+ assert.equal(foldedThinkingLine("anything", undefined, 80, "smart"), "**Thought…**");
315
+ });
316
+ check("foldedThinkingLine: tail keeps the tail + bold duration", () => {
317
+ assert.equal(
318
+ foldedThinkingLine("first\nlast line", 12_340, 80, "tail"),
319
+ "**12.3s** · last line",
320
+ );
321
+ });
322
+ check("foldedThinkingLine: tail ending at a fence folds to the code line", () => {
323
+ // Thinking that ends with a code block ends with the closing fence —
324
+ // the fold must show the last code line, not unfold the whole block.
325
+ assert.equal(
326
+ foldedThinkingLine("intro\n```js\ncode()\n```", 12_340, 80, "tail"),
327
+ "**12.3s** · code()",
328
+ );
329
+ assert.equal(foldedThinkingLine("```", 12_340, 80, "tail"), "**Thought for 12.3s**");
330
+ assert.equal(foldedThinkingLine("```", undefined, 80, "tail"), "**Thought…**");
331
+ });
332
+ check("expandedThinkingSuffix: bold footer or empty", () => {
333
+ assert.equal(expandedThinkingSuffix(61_500), "\n\n**Thought for 1m01s**");
334
+ assert.equal(expandedThinkingSuffix(undefined), "");
335
+ });
336
+ check("liveExpandedSuffix: bold ticking footer at the bottom", () => {
337
+ assert.equal(liveExpandedSuffix(8_000), "\n\n**Thinking… (8s)**");
338
+ assert.equal(liveExpandedSuffix(91_000), "\n\n**Thinking… (1m31s)**");
339
+ assert.equal(liveExpandedSuffix(undefined), "");
340
+ // appended after the full text, the timing line ends up as the last line
341
+ const view = "first thought\nsecond thought" + liveExpandedSuffix(8_000);
342
+ assert.equal(view.split("\n").at(-1), "**Thinking… (8s)**");
343
+ });
344
+
345
+ // ---------------------------------------------------------- closeOpenFences ----
346
+ check("closeOpenFences: balanced fences pass through unchanged", () => {
347
+ assert.equal(closeOpenFences("text\n```js\ncode()\n```"), "text\n```js\ncode()\n```");
348
+ assert.equal(closeOpenFences("plain thinking"), "plain thinking");
349
+ assert.equal(closeOpenFences(""), "");
350
+ });
351
+ check("closeOpenFences: unclosed fence gets a matching close", () => {
352
+ assert.equal(closeOpenFences("text\n```js\ncode("), "text\n```js\ncode(\n```");
353
+ // longer opening fence needs an equally long close
354
+ assert.equal(closeOpenFences("````\ncode"), "````\ncode\n````");
355
+ });
356
+ check("closeOpenFences: tilde fences tracked separately", () => {
357
+ assert.equal(closeOpenFences("~~~\nquoted"), "~~~\nquoted\n~~~");
358
+ // a ``` line does not close a ~~~ fence (it is content), and vice versa
359
+ assert.equal(closeOpenFences("~~~\n```\nq"), "~~~\n```\nq\n~~~");
360
+ });
361
+ check("closeOpenFences: closing fence with info string does not close", () => {
362
+ assert.equal(closeOpenFences("```\ncode\n```js"), "```\ncode\n```js\n```");
363
+ });
364
+ check("closeOpenFences: footer appended after the close renders as bold", () => {
365
+ const view = closeOpenFences("thinking\n```js\ncode(") + "\n\n**Thinking… (8s)**";
366
+ assert.equal(view.split("\n").at(-1), "**Thinking… (8s)**");
367
+ assert.match(view, /```js\ncode\(\n```\n\n\*\*Thinking/);
368
+ });
369
+
370
+ // ------------------------------------------------------------ config ----
371
+ check("config: defaults when file missing", () => {
372
+ const dir = mkdtempSync(join(tmpdir(), "smart-fold-"));
373
+ try {
374
+ assert.deepEqual(loadConfig(dir), defaultConfig);
375
+ } finally {
376
+ rmSync(dir, { recursive: true, force: true });
377
+ }
378
+ });
379
+ check("config: save/load roundtrip with new fields", () => {
380
+ const dir = mkdtempSync(join(tmpdir(), "smart-fold-"));
381
+ try {
382
+ const next = {
383
+ toolsFold: false,
384
+ thinking: "tail",
385
+ writeStat: false,
386
+ writeCollapsed: "preview",
387
+ };
388
+ assert.equal(saveConfig(dir, next), true);
389
+ assert.deepEqual(loadConfig(dir), next);
390
+ } finally {
391
+ rmSync(dir, { recursive: true, force: true });
392
+ }
393
+ });
394
+ check("config: migrates legacy thinkingFold boolean", () => {
395
+ const dir = mkdtempSync(join(tmpdir(), "smart-fold-"));
396
+ try {
397
+ writeFileSync(join(dir, "smart-fold.config.json"), '{"thinkingFold": false}', "utf8");
398
+ assert.deepEqual(loadConfig(dir).thinking, "off");
399
+ writeFileSync(join(dir, "smart-fold.config.json"), '{"thinkingFold": true}', "utf8");
400
+ assert.deepEqual(loadConfig(dir).thinking, "smart");
401
+ } finally {
402
+ rmSync(dir, { recursive: true, force: true });
403
+ }
404
+ });
405
+ check("config: invalid values fall back to defaults", () => {
406
+ const dir = mkdtempSync(join(tmpdir(), "smart-fold-"));
407
+ try {
408
+ writeFileSync(
409
+ join(dir, "smart-fold.config.json"),
410
+ '{"thinking": "bogus", "writeCollapsed": 42, "writeStat": "yes", "toolsFold": 1}',
411
+ "utf8",
412
+ );
413
+ const loaded = loadConfig(dir);
414
+ assert.deepEqual(loaded.thinking, "smart");
415
+ assert.deepEqual(loaded.writeCollapsed, "header");
416
+ assert.deepEqual(loaded.writeStat, true);
417
+ assert.deepEqual(loaded.toolsFold, true);
418
+ } finally {
419
+ rmSync(dir, { recursive: true, force: true });
420
+ }
421
+ });
422
+ check("config: broken JSON falls back to defaults", () => {
423
+ const dir = mkdtempSync(join(tmpdir(), "smart-fold-"));
424
+ try {
425
+ writeFileSync(join(dir, "smart-fold.config.json"), "{oops", "utf8");
426
+ assert.deepEqual(loadConfig(dir), defaultConfig);
427
+ } finally {
428
+ rmSync(dir, { recursive: true, force: true });
429
+ }
430
+ });
431
+
432
+ // ------------------------------------------------------------ config ----
145
433
  check("config: defaults when file missing", () => {
146
434
  const dir = mkdtempSync(join(tmpdir(), "smart-fold-"));
147
435
  try {
@@ -150,14 +438,45 @@ check("config: defaults when file missing", () => {
150
438
  rmSync(dir, { recursive: true, force: true });
151
439
  }
152
440
  });
153
- check("config: save/load roundtrip and partial merge", () => {
441
+ check("config: save/load roundtrip with new fields", () => {
442
+ const dir = mkdtempSync(join(tmpdir(), "smart-fold-"));
443
+ try {
444
+ const next = {
445
+ toolsFold: false,
446
+ thinking: "tail",
447
+ writeStat: false,
448
+ writeCollapsed: "preview",
449
+ };
450
+ assert.equal(saveConfig(dir, next), true);
451
+ assert.deepEqual(loadConfig(dir), next);
452
+ } finally {
453
+ rmSync(dir, { recursive: true, force: true });
454
+ }
455
+ });
456
+ check("config: migrates legacy thinkingFold boolean", () => {
154
457
  const dir = mkdtempSync(join(tmpdir(), "smart-fold-"));
155
458
  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
459
  writeFileSync(join(dir, "smart-fold.config.json"), '{"thinkingFold": false}', "utf8");
160
- assert.deepEqual(loadConfig(dir), { toolsFold: true, thinkingFold: false });
460
+ assert.deepEqual(loadConfig(dir).thinking, "off");
461
+ writeFileSync(join(dir, "smart-fold.config.json"), '{"thinkingFold": true}', "utf8");
462
+ assert.deepEqual(loadConfig(dir).thinking, "smart");
463
+ } finally {
464
+ rmSync(dir, { recursive: true, force: true });
465
+ }
466
+ });
467
+ check("config: invalid values fall back to defaults", () => {
468
+ const dir = mkdtempSync(join(tmpdir(), "smart-fold-"));
469
+ try {
470
+ writeFileSync(
471
+ join(dir, "smart-fold.config.json"),
472
+ '{"thinking": "bogus", "writeCollapsed": 42, "writeStat": "yes", "toolsFold": 1}',
473
+ "utf8",
474
+ );
475
+ const loaded = loadConfig(dir);
476
+ assert.deepEqual(loaded.thinking, "smart");
477
+ assert.deepEqual(loaded.writeCollapsed, "header");
478
+ assert.deepEqual(loaded.writeStat, true);
479
+ assert.deepEqual(loaded.toolsFold, true);
161
480
  } finally {
162
481
  rmSync(dir, { recursive: true, force: true });
163
482
  }
@@ -172,4 +491,162 @@ check("config: broken JSON falls back to defaults", () => {
172
491
  }
173
492
  });
174
493
 
494
+ // ------------------------------------------------------ ThinkingTracker ----
495
+ const msg = (content) => ({ content });
496
+
497
+ check("ThinkingTracker: single run lifecycle", () => {
498
+ let t = 1_000;
499
+ const tracker = new ThinkingTracker(() => t);
500
+ tracker.handleUpdate({ type: "thinking_start", contentIndex: 0, partial: msg([]) });
501
+ t = 3_000;
502
+ tracker.handleUpdate({ type: "thinking_delta", contentIndex: 0, delta: "hmm ", partial: msg([{ type: "thinking", thinking: "hmm " }]) });
503
+ t = 5_200;
504
+ tracker.handleUpdate({
505
+ type: "thinking_end",
506
+ contentIndex: 0,
507
+ partial: msg([{ type: "thinking", thinking: "hmm let me think" }]),
508
+ });
509
+ // live elapsed while open
510
+ t = 6_000;
511
+ assert.equal(tracker.liveElapsedMs(), 5_000);
512
+ // text followed → closes the run
513
+ t = 7_000;
514
+ tracker.handleUpdate({
515
+ type: "text_start",
516
+ contentIndex: 1,
517
+ partial: msg([{ type: "thinking", thinking: "hmm let me think" }, { type: "text", text: "" }]),
518
+ });
519
+ assert.equal(tracker.liveElapsedMs(), undefined);
520
+ const hash = hashText("hmm let me think");
521
+ assert.equal(tracker.finalizedMs(hash), 4_200); // 5200 - 1000
522
+ const runs = tracker.drainPending();
523
+ assert.deepEqual(runs, [{ hash, ms: 4_200 }]);
524
+ assert.deepEqual(tracker.drainPending(), []);
525
+ });
526
+
527
+ check("ThinkingTracker: consecutive thinking blocks form one group", () => {
528
+ let t = 100;
529
+ const tracker = new ThinkingTracker(() => t);
530
+ tracker.handleUpdate({ type: "thinking_start", contentIndex: 0, partial: msg([]) });
531
+ t = 200;
532
+ tracker.handleUpdate({ type: "thinking_delta", contentIndex: 0, delta: "part one", partial: msg([{ type: "thinking", thinking: "part one" }]) });
533
+ t = 300;
534
+ tracker.handleUpdate({
535
+ type: "thinking_end",
536
+ contentIndex: 0,
537
+ partial: msg([{ type: "thinking", thinking: "part one" }]),
538
+ });
539
+ // second thinking block immediately after → same group, no close
540
+ tracker.handleUpdate({
541
+ type: "thinking_start",
542
+ contentIndex: 1,
543
+ partial: msg([{ type: "thinking", thinking: "part one" }]),
544
+ });
545
+ t = 400;
546
+ tracker.handleUpdate({
547
+ type: "thinking_end",
548
+ contentIndex: 1,
549
+ partial: msg([{ type: "thinking", thinking: "part one" }, { type: "thinking", thinking: "part two" }]),
550
+ });
551
+ const runs = tracker.handleMessageEnd(
552
+ msg([{ type: "thinking", thinking: "part one" }, { type: "thinking", thinking: "part two" }]),
553
+ );
554
+ assert.equal(runs.length, 1);
555
+ assert.deepEqual(runs[0], { hash: hashText("part one\n\npart two"), ms: 300 }); // 400 - 100
556
+ });
557
+
558
+ check("ThinkingTracker: tool call between thinking blocks splits groups", () => {
559
+ let t = 0;
560
+ const tracker = new ThinkingTracker(() => t);
561
+ t = 100;
562
+ tracker.handleUpdate({ type: "thinking_start", contentIndex: 0, partial: msg([]) });
563
+ t = 200;
564
+ tracker.handleUpdate({
565
+ type: "thinking_end",
566
+ contentIndex: 0,
567
+ partial: msg([{ type: "thinking", thinking: "before tool" }]),
568
+ });
569
+ t = 900;
570
+ tracker.handleUpdate({
571
+ type: "toolcall_start",
572
+ contentIndex: 1,
573
+ partial: msg([{ type: "thinking", thinking: "before tool" }, { type: "toolCall" }]),
574
+ });
575
+ // group 1's thinking ended at its last activity (t=200) → 100ms of thinking
576
+ assert.equal(tracker.finalizedMs(hashText("before tool")), 100);
577
+ // second group after the tool
578
+ tracker.handleUpdate({
579
+ type: "thinking_start",
580
+ contentIndex: 2,
581
+ partial: msg([{ type: "toolCall" }]),
582
+ });
583
+ t = 1_500;
584
+ const runs = tracker.handleMessageEnd(msg([{ type: "toolCall" }, { type: "thinking", thinking: "after tool" }]));
585
+ // both runs of the message are drained together
586
+ assert.equal(runs.length, 2);
587
+ assert.deepEqual(runs[0], { hash: hashText("before tool"), ms: 100 });
588
+ assert.deepEqual(runs[1], { hash: hashText("after tool"), ms: 600 }); // 1500 - 900
589
+ assert.equal(tracker.finalizedMs(hashText("after tool")), 600);
590
+ });
591
+
592
+ check("ThinkingTracker: finalizeIfMatches closes on exact text", () => {
593
+ let t = 10_000;
594
+ const tracker = new ThinkingTracker(() => t);
595
+ tracker.handleUpdate({ type: "thinking_start", contentIndex: 0, partial: msg([]) });
596
+ t = 12_500;
597
+ tracker.handleUpdate({
598
+ type: "thinking_end",
599
+ contentIndex: 0,
600
+ partial: msg([{ type: "thinking", thinking: "exact text" }]),
601
+ });
602
+ assert.equal(tracker.finalizeIfMatches("exact text"), 2_500);
603
+ assert.equal(tracker.liveElapsedMs(), undefined);
604
+ assert.equal(tracker.finalizeIfMatches("exact text"), undefined); // already closed
605
+ assert.equal(tracker.finalizedMs(hashText("exact text")), 2_500);
606
+ });
607
+
608
+ check("ThinkingTracker: restore merges persisted durations", () => {
609
+ const tracker = new ThinkingTracker(() => 0);
610
+ const hash = hashText("restored thought");
611
+ tracker.restore([{ hash, ms: 3_000 }]);
612
+ assert.equal(tracker.finalizedMs(hash), 3_000);
613
+ // invalid entries are ignored
614
+ tracker.restore([{ hash: 42, ms: "x" }, { hash: "ok", ms: 5 }]);
615
+ assert.equal(tracker.finalizedMs("ok"), 5);
616
+ });
617
+
618
+ check("ThinkingTracker: delta-only fallback when no thinking_end seen", () => {
619
+ let t = 0;
620
+ const tracker = new ThinkingTracker(() => t);
621
+ t = 100;
622
+ tracker.handleUpdate({ type: "thinking_start", contentIndex: 0, partial: msg([]) });
623
+ t = 250;
624
+ tracker.handleUpdate({ type: "thinking_delta", contentIndex: 0, delta: "partial", partial: msg([]) });
625
+ const runs = tracker.handleMessageEnd(undefined);
626
+ assert.equal(runs.length, 1);
627
+ assert.deepEqual(runs[0], { hash: hashText("partial"), ms: 150 });
628
+ });
629
+
630
+ // ------------------------------------------------------ trailingThinkingText ----
631
+ check("trailingThinkingText: joins and trims trailing thinking blocks", () => {
632
+ assert.equal(
633
+ trailingThinkingText(
634
+ msg([{ type: "text", text: "hi" }, { type: "thinking", thinking: " one " }, { type: "thinking", thinking: "two" }]),
635
+ ),
636
+ "one\n\ntwo",
637
+ );
638
+ });
639
+ check("trailingThinkingText: stops at non-thinking block", () => {
640
+ assert.equal(
641
+ trailingThinkingText(msg([{ type: "thinking", thinking: "x" }, { type: "text", text: "hi" }])),
642
+ null,
643
+ );
644
+ });
645
+ check("trailingThinkingText: skips empty thinking blocks at the end", () => {
646
+ assert.equal(
647
+ trailingThinkingText(msg([{ type: "thinking", thinking: "x" }, { type: "thinking", thinking: " " }])),
648
+ "x",
649
+ );
650
+ });
651
+
175
652
  console.log(`✓ ${passed} test groups passed`);
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "nodenext",
5
+ "moduleResolution": "nodenext",
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "allowImportingTsExtensions": true,
9
+ "skipLibCheck": true,
10
+ "noUncheckedIndexedAccess": false,
11
+ "types": ["node"]
12
+ },
13
+ "include": ["index.ts", "lib/**/*.ts"]
14
+ }