@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.
- package/README.md +104 -27
- package/index.ts +1185 -61
- package/lib/config.ts +55 -7
- package/lib/fold.ts +291 -9
- package/lib/thinking.ts +194 -0
- package/package.json +11 -2
- package/scripts/link-pi.mjs +75 -0
- package/scripts/load-check.mjs +44 -0
- package/test/click-sim.mjs +414 -0
- package/test/fold.test.mjs +483 -6
- package/tsconfig.json +14 -0
package/test/fold.test.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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),
|
|
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
|
+
}
|