@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.
- package/README.md +69 -16
- package/index.ts +1109 -61
- package/lib/config.ts +55 -7
- package/lib/fold.ts +223 -7
- package/lib/thinking.ts +194 -0
- package/package.json +3 -2
- package/test/click-sim.mjs +224 -0
- package/test/fold.test.mjs +413 -6
|
@@ -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");
|
package/test/fold.test.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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),
|
|
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`);
|