@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
package/lib/config.ts
CHANGED
|
@@ -8,18 +8,45 @@
|
|
|
8
8
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
|
|
11
|
+
export type ThinkingMode = "smart" | "tail" | "full" | "off";
|
|
12
|
+
export type WriteCollapsedStyle = "header" | "preview";
|
|
13
|
+
|
|
11
14
|
export interface SmartFoldConfig {
|
|
12
15
|
/** Collapse tool output at every session start. Default: true */
|
|
13
16
|
toolsFold: boolean;
|
|
14
|
-
/**
|
|
15
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Thinking display strategy:
|
|
19
|
+
* - "smart": while the model thinks, show one live line with the elapsed
|
|
20
|
+
* time and the scrolling tail of the thinking text; once the message is
|
|
21
|
+
* finished, collapse to a single `Thinking… (Xs)` line. Clicking the line
|
|
22
|
+
* twice (through pi's native hidden state) reveals the full text.
|
|
23
|
+
* - "tail": always fold thinking to a single tail line (plus duration).
|
|
24
|
+
* - "full": leave finished thinking unfolded (full markdown).
|
|
25
|
+
* - "off": no thinking transformation at all.
|
|
26
|
+
* Default: "smart"
|
|
27
|
+
*/
|
|
28
|
+
thinking: ThinkingMode;
|
|
29
|
+
/** Append `+N -M` line-diff stats to the write tool header. Default: true */
|
|
30
|
+
writeStat: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Collapsed write tool rows:
|
|
33
|
+
* - "header": only the `write <path> +N -M` line (fully collapsed).
|
|
34
|
+
* - "preview": keep pi's default content preview (10 lines).
|
|
35
|
+
* Default: "header"
|
|
36
|
+
*/
|
|
37
|
+
writeCollapsed: WriteCollapsedStyle;
|
|
16
38
|
}
|
|
17
39
|
|
|
18
40
|
export const defaultConfig: SmartFoldConfig = {
|
|
19
41
|
toolsFold: true,
|
|
20
|
-
|
|
42
|
+
thinking: "smart",
|
|
43
|
+
writeStat: true,
|
|
44
|
+
writeCollapsed: "header",
|
|
21
45
|
};
|
|
22
46
|
|
|
47
|
+
const THINKING_MODES: readonly ThinkingMode[] = ["smart", "tail", "full", "off"];
|
|
48
|
+
const WRITE_STYLES: readonly WriteCollapsedStyle[] = ["header", "preview"];
|
|
49
|
+
|
|
23
50
|
export function configFilePath(extensionDir: string): string {
|
|
24
51
|
return join(extensionDir, "smart-fold.config.json");
|
|
25
52
|
}
|
|
@@ -29,17 +56,38 @@ export function loadConfig(extensionDir: string): SmartFoldConfig {
|
|
|
29
56
|
try {
|
|
30
57
|
const file = configFilePath(extensionDir);
|
|
31
58
|
if (!existsSync(file)) return { ...defaultConfig };
|
|
32
|
-
const parsed = JSON.parse(readFileSync(file, "utf8")) as
|
|
59
|
+
const parsed = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>;
|
|
33
60
|
return {
|
|
34
|
-
toolsFold:
|
|
35
|
-
|
|
36
|
-
|
|
61
|
+
toolsFold:
|
|
62
|
+
typeof parsed.toolsFold === "boolean" ? parsed.toolsFold : defaultConfig.toolsFold,
|
|
63
|
+
thinking: migrateThinking(parsed),
|
|
64
|
+
writeStat:
|
|
65
|
+
typeof parsed.writeStat === "boolean" ? parsed.writeStat : defaultConfig.writeStat,
|
|
66
|
+
writeCollapsed:
|
|
67
|
+
typeof parsed.writeCollapsed === "string" &&
|
|
68
|
+
WRITE_STYLES.includes(parsed.writeCollapsed as WriteCollapsedStyle)
|
|
69
|
+
? (parsed.writeCollapsed as WriteCollapsedStyle)
|
|
70
|
+
: defaultConfig.writeCollapsed,
|
|
37
71
|
};
|
|
38
72
|
} catch {
|
|
39
73
|
return { ...defaultConfig };
|
|
40
74
|
}
|
|
41
75
|
}
|
|
42
76
|
|
|
77
|
+
/** Accepts the new string form plus the legacy `thinkingFold: boolean`. */
|
|
78
|
+
function migrateThinking(parsed: Record<string, unknown>): ThinkingMode {
|
|
79
|
+
if (
|
|
80
|
+
typeof parsed.thinking === "string" &&
|
|
81
|
+
THINKING_MODES.includes(parsed.thinking as ThinkingMode)
|
|
82
|
+
) {
|
|
83
|
+
return parsed.thinking as ThinkingMode;
|
|
84
|
+
}
|
|
85
|
+
if (typeof parsed.thinkingFold === "boolean") {
|
|
86
|
+
return parsed.thinkingFold ? "smart" : "off";
|
|
87
|
+
}
|
|
88
|
+
return defaultConfig.thinking;
|
|
89
|
+
}
|
|
90
|
+
|
|
43
91
|
/** Persist config best-effort. Returns true on success. */
|
|
44
92
|
export function saveConfig(extensionDir: string, config: SmartFoldConfig): boolean {
|
|
45
93
|
try {
|
package/lib/fold.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi-smart-fold — pure folding helpers.
|
|
2
|
+
* pi-smart-fold — pure folding / formatting helpers.
|
|
3
3
|
*
|
|
4
4
|
* No pi imports here: this module stays dependency-free and unit-testable
|
|
5
5
|
* with plain `node` (Node >= 23 type stripping; no build step needed).
|
|
@@ -26,6 +26,10 @@ export function codePointWidth(cp: number): number {
|
|
|
26
26
|
) {
|
|
27
27
|
return 0;
|
|
28
28
|
}
|
|
29
|
+
// Wide: keycap-base misc-technical emoji (⏱ ⏳ ⏰ …) with emoji presentation
|
|
30
|
+
if (cp >= 0x23e9 && cp <= 0x23f3) {
|
|
31
|
+
return 2;
|
|
32
|
+
}
|
|
29
33
|
// Wide: Hangul Jamo, CJK radicals/symbols, kana, Yi, Hangul syllables,
|
|
30
34
|
// CJK ideographs (incl. ext A/B+), compat ideographs/forms, fullwidth forms,
|
|
31
35
|
// common emoji planes
|
|
@@ -85,6 +89,13 @@ export function tailFit(input: string, maxWidth: number): string {
|
|
|
85
89
|
return "…" + chars.slice(start).join("");
|
|
86
90
|
}
|
|
87
91
|
|
|
92
|
+
/** Clamp a renderer-provided width to something sane for one-line folding. */
|
|
93
|
+
export function sanitizeWidth(availableWidth: number, fallback = 80): number {
|
|
94
|
+
return Number.isFinite(availableWidth) && availableWidth >= 8
|
|
95
|
+
? Math.floor(availableWidth)
|
|
96
|
+
: fallback;
|
|
97
|
+
}
|
|
98
|
+
|
|
88
99
|
/** Last line of a markdown string whose trimmed content is non-empty. */
|
|
89
100
|
export function lastNonEmptyLine(markdown: string): string {
|
|
90
101
|
const lines = markdown.split(/\r?\n/);
|
|
@@ -124,10 +135,215 @@ export function stripBlockMarkers(raw: string): string {
|
|
|
124
135
|
export function collapseThinking(markdown: string, availableWidth: number): string {
|
|
125
136
|
const line = stripBlockMarkers(lastNonEmptyLine(markdown));
|
|
126
137
|
if (!line) return markdown;
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
138
|
+
return tailFit(line, sanitizeWidth(availableWidth));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Durations
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
const pad2 = (n: number): string => String(n).padStart(2, "0");
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Human-readable duration.
|
|
149
|
+
* - "live": whole seconds, for the ticking badge while the model thinks.
|
|
150
|
+
* - "final": one decimal below a minute, then `XmYYs` / `XhYYm`.
|
|
151
|
+
*/
|
|
152
|
+
export function formatDuration(ms: number, style: "live" | "final" = "final"): string {
|
|
153
|
+
if (!Number.isFinite(ms) || ms < 0) ms = 0;
|
|
154
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
155
|
+
if (style === "live") {
|
|
156
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
157
|
+
if (totalSeconds < 3600) return `${Math.floor(totalSeconds / 60)}m${pad2(totalSeconds % 60)}s`;
|
|
158
|
+
return `${Math.floor(totalSeconds / 3600)}h${pad2(Math.floor((totalSeconds % 3600) / 60))}m`;
|
|
159
|
+
}
|
|
160
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
161
|
+
if (totalSeconds < 3600) return `${Math.floor(totalSeconds / 60)}m${pad2(totalSeconds % 60)}s`;
|
|
162
|
+
return `${Math.floor(totalSeconds / 3600)}h${pad2(Math.floor((totalSeconds % 3600) / 60))}m`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Content hashing (stable identity for a thinking block across renders)
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
/** 32-bit FNV-1a of a string (UTF-16 code units). */
|
|
170
|
+
function fnv1a(text: string): number {
|
|
171
|
+
let hash = 0x811c9dc5;
|
|
172
|
+
for (let i = 0; i < text.length; i++) {
|
|
173
|
+
hash ^= text.charCodeAt(i);
|
|
174
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
175
|
+
}
|
|
176
|
+
return hash >>> 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Cheap, stable content hash: `<length>:<fnv1a-hex>`.
|
|
181
|
+
* Used to match a rendered thinking block back to its recorded duration.
|
|
182
|
+
*/
|
|
183
|
+
export function hashText(text: string): string {
|
|
184
|
+
return `${text.length.toString(36)}:${fnv1a(text).toString(16)}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Line diff (for the write tool's `+N -M` stat)
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
export interface LineDiffStat {
|
|
192
|
+
added: number;
|
|
193
|
+
removed: number;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function splitLines(text: string): string[] {
|
|
197
|
+
return text.replace(/\n$/, "").split("\n");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Line-level added/removed counts between two file contents, git-diff style.
|
|
202
|
+
*
|
|
203
|
+
* Common prefix/suffix lines are trimmed first (cheap), then the middle is
|
|
204
|
+
* measured with an LCS table. When the middle is too large for the LCS
|
|
205
|
+
* budget (`maxCells`), it is treated as a full replacement.
|
|
206
|
+
* `oldText === undefined` means the file did not exist yet (everything added).
|
|
207
|
+
*/
|
|
208
|
+
export function countLineDiff(
|
|
209
|
+
oldText: string | undefined,
|
|
210
|
+
newText: string,
|
|
211
|
+
maxCells = 1_500_000,
|
|
212
|
+
): LineDiffStat {
|
|
213
|
+
const next = splitLines(newText);
|
|
214
|
+
if (oldText === undefined) return { added: next.length, removed: 0 };
|
|
215
|
+
const prev = splitLines(oldText);
|
|
216
|
+
if (prev.join("\n") === next.join("\n")) return { added: 0, removed: 0 };
|
|
217
|
+
|
|
218
|
+
// Trim common prefix/suffix (without letting them overlap).
|
|
219
|
+
let prefix = 0;
|
|
220
|
+
while (prefix < prev.length && prefix < next.length && prev[prefix] === next[prefix]) prefix++;
|
|
221
|
+
let suffix = 0;
|
|
222
|
+
while (
|
|
223
|
+
suffix < prev.length - prefix &&
|
|
224
|
+
suffix < next.length - prefix &&
|
|
225
|
+
prev[prev.length - 1 - suffix] === next[next.length - 1 - suffix]
|
|
226
|
+
) {
|
|
227
|
+
suffix++;
|
|
228
|
+
}
|
|
229
|
+
const a = prev.slice(prefix, prev.length - suffix);
|
|
230
|
+
const b = next.slice(prefix, next.length - suffix);
|
|
231
|
+
if (a.length === 0) return { added: b.length, removed: 0 };
|
|
232
|
+
if (b.length === 0) return { added: 0, removed: a.length };
|
|
233
|
+
if (a.length * b.length > maxCells) return { added: b.length, removed: a.length };
|
|
234
|
+
|
|
235
|
+
// LCS length over the middle sections (classic DP, row-major Uint32 table).
|
|
236
|
+
const n = a.length;
|
|
237
|
+
const m = b.length;
|
|
238
|
+
const stride = m + 1;
|
|
239
|
+
const dp = new Uint32Array((n + 1) * stride);
|
|
240
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
241
|
+
const row = i * stride;
|
|
242
|
+
const nextRow = (i + 1) * stride;
|
|
243
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
244
|
+
dp[row + j] =
|
|
245
|
+
a[i] === b[j]
|
|
246
|
+
? dp[nextRow + j + 1] + 1
|
|
247
|
+
: Math.max(dp[nextRow + j], dp[row + j + 1]);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const common = dp[0];
|
|
251
|
+
return { added: b.length - common, removed: a.length - common };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Line-level added/removed counts summed over an edit tool's edits array
|
|
256
|
+
* (supports the legacy single oldText/newText shape). Returns undefined when
|
|
257
|
+
* there is nothing renderable.
|
|
258
|
+
*/
|
|
259
|
+
export function countEditsLineDiff(
|
|
260
|
+
input: { edits?: unknown; oldText?: unknown; newText?: unknown } | undefined,
|
|
261
|
+
): LineDiffStat | undefined {
|
|
262
|
+
if (!input || typeof input !== "object") return undefined;
|
|
263
|
+
const edits = Array.isArray(input.edits)
|
|
264
|
+
? input.edits
|
|
265
|
+
: typeof input.oldText === "string" && typeof input.newText === "string"
|
|
266
|
+
? [{ oldText: input.oldText, newText: input.newText }]
|
|
267
|
+
: [];
|
|
268
|
+
let added = 0;
|
|
269
|
+
let removed = 0;
|
|
270
|
+
let seen = false;
|
|
271
|
+
for (const edit of edits as Array<{ oldText?: unknown; newText?: unknown }>) {
|
|
272
|
+
if (!edit || typeof edit !== "object") continue;
|
|
273
|
+
const oldText = typeof edit.oldText === "string" ? edit.oldText : undefined;
|
|
274
|
+
const newText = typeof edit.newText === "string" ? edit.newText : undefined;
|
|
275
|
+
if (oldText === undefined && newText === undefined) continue;
|
|
276
|
+
const stat = countLineDiff(oldText, newText ?? "");
|
|
277
|
+
added += stat.added;
|
|
278
|
+
removed += stat.removed;
|
|
279
|
+
seen = true;
|
|
280
|
+
}
|
|
281
|
+
return seen ? { added, removed } : undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// Thinking line renderers (display-only, used by the markdown transformer)
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The live two-line view shown while the model is thinking:
|
|
290
|
+
* ```
|
|
291
|
+
* Thinking… (8s) ← bold label line
|
|
292
|
+
* <scrolling tail> ← newest thinking text, tail-truncated
|
|
293
|
+
* ```
|
|
294
|
+
* Without a known elapsed time only the tail line is shown.
|
|
295
|
+
*/
|
|
296
|
+
export function liveThinkingLine(
|
|
297
|
+
markdown: string,
|
|
298
|
+
elapsedMs: number | undefined,
|
|
299
|
+
width: number,
|
|
300
|
+
): string {
|
|
301
|
+
const tail = stripBlockMarkers(lastNonEmptyLine(markdown));
|
|
302
|
+
if (!tail) return markdown;
|
|
303
|
+
const fitted = tailFit(tail, sanitizeWidth(width));
|
|
304
|
+
if (elapsedMs === undefined) return fitted;
|
|
305
|
+
return `**Thinking… (${formatDuration(elapsedMs, "live")})**\n\n${fitted}`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The single collapsed line shown once a thinking run has finished.
|
|
310
|
+
* - "smart": `Thought for 12.4s` (bold) — pi's native hidden-label look plus time.
|
|
311
|
+
* - "tail": bold duration prefix + the last line (legacy one-line tail fold).
|
|
312
|
+
*/
|
|
313
|
+
export function foldedThinkingLine(
|
|
314
|
+
markdown: string,
|
|
315
|
+
ms: number | undefined,
|
|
316
|
+
width: number,
|
|
317
|
+
style: "smart" | "tail",
|
|
318
|
+
): string {
|
|
319
|
+
const w = sanitizeWidth(width);
|
|
320
|
+
if (style === "tail") {
|
|
321
|
+
const tail = stripBlockMarkers(lastNonEmptyLine(markdown));
|
|
322
|
+
if (!tail) return markdown;
|
|
323
|
+
const prefix = ms === undefined ? "" : `**${formatDuration(ms, "final")}** · `;
|
|
324
|
+
return tailFit(prefix + tail, w);
|
|
325
|
+
}
|
|
326
|
+
return tailFit(
|
|
327
|
+
ms === undefined ? "**Thought…**" : `**Thought for ${formatDuration(ms, "final")}**`,
|
|
328
|
+
w,
|
|
329
|
+
);
|
|
133
330
|
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Bold live-timing footer appended below fully-expanded thinking text while
|
|
334
|
+
* the run is still streaming (`\n\n**Thinking… (8s)**`), or an empty string
|
|
335
|
+
* when the elapsed time is unknown. Keeps updating at the bottom of the
|
|
336
|
+
* block while the full text grows.
|
|
337
|
+
*/
|
|
338
|
+
export function liveExpandedSuffix(ms: number | undefined): string {
|
|
339
|
+
return ms === undefined ? "" : `\n\n**Thinking… (${formatDuration(ms, "live")})**`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Bold duration footer appended below fully-expanded thinking text
|
|
344
|
+
* (`\n\n**Thought for 12.4s**`), or an empty string when unknown.
|
|
345
|
+
*/
|
|
346
|
+
export function expandedThinkingSuffix(ms: number | undefined): string {
|
|
347
|
+
return ms === undefined ? "" : `\n\n**Thought for ${formatDuration(ms, "final")}**`;
|
|
348
|
+
}
|
|
349
|
+
|
package/lib/thinking.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-smart-fold — thinking-run timing.
|
|
3
|
+
*
|
|
4
|
+
* Tracks when each "thinking run" (a maximal group of consecutive thinking
|
|
5
|
+
* blocks inside one assistant message) starts and ends, so the UI can show a
|
|
6
|
+
* live elapsed time while the model thinks and a total duration afterwards.
|
|
7
|
+
* Durations are exposed by content hash and can be persisted/restored by the
|
|
8
|
+
* extension via session entries.
|
|
9
|
+
*
|
|
10
|
+
* Pure logic, no pi imports: unit-testable with plain `node`.
|
|
11
|
+
*/
|
|
12
|
+
import { hashText } from "./fold.ts";
|
|
13
|
+
|
|
14
|
+
export interface ClosedThinkingRun {
|
|
15
|
+
hash: string;
|
|
16
|
+
ms: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Minimal structural types so tests can feed plain objects. */
|
|
20
|
+
interface ContentLike {
|
|
21
|
+
type: string;
|
|
22
|
+
thinking?: string;
|
|
23
|
+
}
|
|
24
|
+
interface MessageLike {
|
|
25
|
+
content?: ContentLike[];
|
|
26
|
+
}
|
|
27
|
+
export interface AssistantMessageEventLike {
|
|
28
|
+
type: string;
|
|
29
|
+
contentIndex?: number;
|
|
30
|
+
delta?: string;
|
|
31
|
+
partial?: MessageLike;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface OpenGroup {
|
|
35
|
+
start: number;
|
|
36
|
+
lastDelta: number;
|
|
37
|
+
/** Delta-accumulated fallback text. */
|
|
38
|
+
text: string;
|
|
39
|
+
/** Exact joined text once a thinking_end event has been observed. */
|
|
40
|
+
exact?: string;
|
|
41
|
+
/** True once a thinking_end event was seen for this group. */
|
|
42
|
+
ended?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const MAX_TRACKED = 2000;
|
|
46
|
+
|
|
47
|
+
export class ThinkingTracker {
|
|
48
|
+
private readonly now: () => number;
|
|
49
|
+
private current: OpenGroup | null = null;
|
|
50
|
+
private readonly finalized = new Map<string, number>();
|
|
51
|
+
private pending: ClosedThinkingRun[] = [];
|
|
52
|
+
|
|
53
|
+
constructor(nowFn: () => number = () => Date.now()) {
|
|
54
|
+
this.now = nowFn;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Feed `message_update`'s assistantMessageEvent (assistant messages only). */
|
|
58
|
+
handleUpdate(event: AssistantMessageEventLike): void {
|
|
59
|
+
const now = this.now();
|
|
60
|
+
switch (event.type) {
|
|
61
|
+
case "thinking_start": {
|
|
62
|
+
// The group continues when the content block right before this one is
|
|
63
|
+
// also thinking; anything else starts a fresh group (and closes the
|
|
64
|
+
// previous one).
|
|
65
|
+
const prevType = lastTypeBefore(event.partial, event.contentIndex);
|
|
66
|
+
if (!(this.current && prevType === "thinking")) {
|
|
67
|
+
this.closeCurrent();
|
|
68
|
+
this.current = { start: now, lastDelta: now, text: "" };
|
|
69
|
+
}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
case "thinking_delta": {
|
|
73
|
+
if (!this.current) this.current = { start: now, lastDelta: now, text: "" };
|
|
74
|
+
this.current.lastDelta = now;
|
|
75
|
+
if (typeof event.delta === "string") this.current.text += event.delta;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
case "thinking_end": {
|
|
79
|
+
if (this.current) {
|
|
80
|
+
const exact = trailingThinkingText(event.partial);
|
|
81
|
+
if (exact !== null) this.current.exact = exact;
|
|
82
|
+
this.current.lastDelta = now;
|
|
83
|
+
this.current.ended = true;
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
default: {
|
|
88
|
+
// Any other content block starting (text, tool call, …) ends the run.
|
|
89
|
+
this.closeCurrent();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Close any open group for a finished assistant message and return the runs
|
|
96
|
+
* recorded since the last drain (for session persistence).
|
|
97
|
+
*/
|
|
98
|
+
handleMessageEnd(message: MessageLike | undefined): ClosedThinkingRun[] {
|
|
99
|
+
if (this.current) {
|
|
100
|
+
const exact = trailingThinkingText(message);
|
|
101
|
+
if (exact !== null) this.current.exact = exact;
|
|
102
|
+
// A run that never saw thinking_end was cut off mid-stream or missed
|
|
103
|
+
// events: count up to the message end.
|
|
104
|
+
if (!this.current.ended) this.current.lastDelta = this.now();
|
|
105
|
+
}
|
|
106
|
+
this.closeCurrent();
|
|
107
|
+
return this.drainPending();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Live elapsed ms of the currently streaming thinking group, if any. */
|
|
111
|
+
liveElapsedMs(): number | undefined {
|
|
112
|
+
return this.current ? this.now() - this.current.start : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Duration of a finished group, keyed by its joined markdown hash. */
|
|
116
|
+
finalizedMs(hash: string): number | undefined {
|
|
117
|
+
return this.finalized.get(hash);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Render-time safety net: the TUI may re-render a message as finalized
|
|
122
|
+
* before our message_end handler runs. If the open group's text matches the
|
|
123
|
+
* rendered markdown, close it now and return its duration.
|
|
124
|
+
*/
|
|
125
|
+
finalizeIfMatches(markdown: string): number | undefined {
|
|
126
|
+
if (!this.current) return undefined;
|
|
127
|
+
const hash = hashText(markdown);
|
|
128
|
+
if (hashText(this.current.exact ?? this.current.text) !== hash) return undefined;
|
|
129
|
+
this.closeCurrent();
|
|
130
|
+
return this.finalized.get(hash);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Merge durations restored from session entries. */
|
|
134
|
+
restore(runs: ClosedThinkingRun[]): void {
|
|
135
|
+
for (const run of runs) {
|
|
136
|
+
if (run && typeof run.hash === "string" && Number.isFinite(run.ms)) {
|
|
137
|
+
this.finalized.set(run.hash, Math.max(0, run.ms));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
drainPending(): ClosedThinkingRun[] {
|
|
143
|
+
const out = this.pending;
|
|
144
|
+
this.pending = [];
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private closeCurrent(): void {
|
|
149
|
+
const group = this.current;
|
|
150
|
+
if (!group) return;
|
|
151
|
+
this.current = null;
|
|
152
|
+
const text = group.exact ?? group.text;
|
|
153
|
+
if (!text.trim()) return;
|
|
154
|
+
const ms = Math.max(0, group.lastDelta - group.start);
|
|
155
|
+
const hash = hashText(text);
|
|
156
|
+
this.finalized.delete(hash); // re-insert to refresh insertion order
|
|
157
|
+
this.finalized.set(hash, ms);
|
|
158
|
+
if (this.finalized.size > MAX_TRACKED) {
|
|
159
|
+
let excess = this.finalized.size - MAX_TRACKED;
|
|
160
|
+
for (const key of this.finalized.keys()) {
|
|
161
|
+
if (excess-- <= 0) break;
|
|
162
|
+
this.finalized.delete(key);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
this.pending.push({ hash, ms });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function lastTypeBefore(
|
|
170
|
+
message: MessageLike | undefined,
|
|
171
|
+
contentIndex: number | undefined,
|
|
172
|
+
): string | undefined {
|
|
173
|
+
const content = message?.content;
|
|
174
|
+
if (!Array.isArray(content) || contentIndex === undefined) return undefined;
|
|
175
|
+
return content[Math.max(0, contentIndex - 1)]?.type;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Joined text of the trailing run of consecutive non-empty thinking blocks —
|
|
180
|
+
* exactly what pi's AssistantMessageComponent renders as one Markdown group
|
|
181
|
+
* (blocks are trimmed and joined with a blank line).
|
|
182
|
+
*/
|
|
183
|
+
export function trailingThinkingText(message: MessageLike | undefined): string | null {
|
|
184
|
+
const content = message?.content;
|
|
185
|
+
if (!Array.isArray(content)) return null;
|
|
186
|
+
const parts: string[] = [];
|
|
187
|
+
for (let i = content.length - 1; i >= 0; i--) {
|
|
188
|
+
const block = content[i];
|
|
189
|
+
if (block?.type !== "thinking") break;
|
|
190
|
+
const text = typeof block.thinking === "string" ? block.thinking.trim() : "";
|
|
191
|
+
if (text) parts.unshift(text);
|
|
192
|
+
}
|
|
193
|
+
return parts.length > 0 ? parts.join("\n\n") : null;
|
|
194
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clapecho233/pi-smart-fold",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "pi coding-agent extension: collapse tool output at startup and fold thinking blocks to a live last line",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
]
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
|
-
"test": "node test/fold.test.mjs"
|
|
19
|
+
"test": "node test/fold.test.mjs",
|
|
20
|
+
"test:sim": "node test/click-sim.mjs"
|
|
20
21
|
},
|
|
21
22
|
"engines": {
|
|
22
23
|
"node": ">=22.18"
|