@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/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/);
|
|
@@ -94,6 +105,28 @@ export function lastNonEmptyLine(markdown: string): string {
|
|
|
94
105
|
return "";
|
|
95
106
|
}
|
|
96
107
|
|
|
108
|
+
/** A markdown code-fence line: up to 3 spaces indent, then ``` or ~~~. */
|
|
109
|
+
const FENCE_LINE = /^\s{0,3}(?:`{3,}|~{3,})/;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Last line of a markdown string worth showing as a one-line tail: blank
|
|
113
|
+
* lines and structural code-fence lines are skipped, and lines that strip
|
|
114
|
+
* down to nothing (bare block markers) are skipped too. Returns "" when
|
|
115
|
+
* there is no such line — e.g. while the model is right at a code fence
|
|
116
|
+
* being streamed.
|
|
117
|
+
*/
|
|
118
|
+
export function lastMeaningfulLine(markdown: string): string {
|
|
119
|
+
const lines = markdown.split(/\r?\n/);
|
|
120
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
121
|
+
const raw = lines[i];
|
|
122
|
+
if (raw.trim() === "") continue;
|
|
123
|
+
if (FENCE_LINE.test(raw)) continue; // opening/closing fence — structural
|
|
124
|
+
const stripped = stripBlockMarkers(raw);
|
|
125
|
+
if (stripped !== "") return stripped;
|
|
126
|
+
}
|
|
127
|
+
return "";
|
|
128
|
+
}
|
|
129
|
+
|
|
97
130
|
/**
|
|
98
131
|
* Strip common block-level markdown markers (`#`, `>`, `-`, `*`, `1.`)
|
|
99
132
|
* so the collapsed line reads like prose. Runs a few passes for nesting
|
|
@@ -117,17 +150,266 @@ export function stripBlockMarkers(raw: string): string {
|
|
|
117
150
|
|
|
118
151
|
/**
|
|
119
152
|
* Collapse a thinking markdown block into a single line:
|
|
120
|
-
* take its last
|
|
153
|
+
* take its last meaningful line, strip block markers, and tail-truncate
|
|
121
154
|
* to `availableWidth` display columns. Falls back to the original
|
|
122
155
|
* markdown when there is nothing to show.
|
|
123
156
|
*/
|
|
124
157
|
export function collapseThinking(markdown: string, availableWidth: number): string {
|
|
125
|
-
|
|
158
|
+
if (lastNonEmptyLine(markdown) === "") return markdown; // nothing visible
|
|
159
|
+
const line = lastMeaningfulLine(markdown);
|
|
126
160
|
if (!line) return markdown;
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
161
|
+
return tailFit(line, sanitizeWidth(availableWidth));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Durations
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
const pad2 = (n: number): string => String(n).padStart(2, "0");
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Human-readable duration.
|
|
172
|
+
* - "live": whole seconds, for the ticking badge while the model thinks.
|
|
173
|
+
* - "final": one decimal below a minute, then `XmYYs` / `XhYYm`.
|
|
174
|
+
*/
|
|
175
|
+
export function formatDuration(ms: number, style: "live" | "final" = "final"): string {
|
|
176
|
+
if (!Number.isFinite(ms) || ms < 0) ms = 0;
|
|
177
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
178
|
+
if (style === "live") {
|
|
179
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
180
|
+
if (totalSeconds < 3600) return `${Math.floor(totalSeconds / 60)}m${pad2(totalSeconds % 60)}s`;
|
|
181
|
+
return `${Math.floor(totalSeconds / 3600)}h${pad2(Math.floor((totalSeconds % 3600) / 60))}m`;
|
|
182
|
+
}
|
|
183
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
184
|
+
if (totalSeconds < 3600) return `${Math.floor(totalSeconds / 60)}m${pad2(totalSeconds % 60)}s`;
|
|
185
|
+
return `${Math.floor(totalSeconds / 3600)}h${pad2(Math.floor((totalSeconds % 3600) / 60))}m`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Content hashing (stable identity for a thinking block across renders)
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
/** 32-bit FNV-1a of a string (UTF-16 code units). */
|
|
193
|
+
function fnv1a(text: string): number {
|
|
194
|
+
let hash = 0x811c9dc5;
|
|
195
|
+
for (let i = 0; i < text.length; i++) {
|
|
196
|
+
hash ^= text.charCodeAt(i);
|
|
197
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
198
|
+
}
|
|
199
|
+
return hash >>> 0;
|
|
133
200
|
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Cheap, stable content hash: `<length>:<fnv1a-hex>`.
|
|
204
|
+
* Used to match a rendered thinking block back to its recorded duration.
|
|
205
|
+
*/
|
|
206
|
+
export function hashText(text: string): string {
|
|
207
|
+
return `${text.length.toString(36)}:${fnv1a(text).toString(16)}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// Line diff (for the write tool's `+N -M` stat)
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
export interface LineDiffStat {
|
|
215
|
+
added: number;
|
|
216
|
+
removed: number;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function splitLines(text: string): string[] {
|
|
220
|
+
return text.replace(/\n$/, "").split("\n");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Line-level added/removed counts between two file contents, git-diff style.
|
|
225
|
+
*
|
|
226
|
+
* Common prefix/suffix lines are trimmed first (cheap), then the middle is
|
|
227
|
+
* measured with an LCS table. When the middle is too large for the LCS
|
|
228
|
+
* budget (`maxCells`), it is treated as a full replacement.
|
|
229
|
+
* `oldText === undefined` means the file did not exist yet (everything added).
|
|
230
|
+
*/
|
|
231
|
+
export function countLineDiff(
|
|
232
|
+
oldText: string | undefined,
|
|
233
|
+
newText: string,
|
|
234
|
+
maxCells = 1_500_000,
|
|
235
|
+
): LineDiffStat {
|
|
236
|
+
const next = splitLines(newText);
|
|
237
|
+
if (oldText === undefined) return { added: next.length, removed: 0 };
|
|
238
|
+
const prev = splitLines(oldText);
|
|
239
|
+
if (prev.join("\n") === next.join("\n")) return { added: 0, removed: 0 };
|
|
240
|
+
|
|
241
|
+
// Trim common prefix/suffix (without letting them overlap).
|
|
242
|
+
let prefix = 0;
|
|
243
|
+
while (prefix < prev.length && prefix < next.length && prev[prefix] === next[prefix]) prefix++;
|
|
244
|
+
let suffix = 0;
|
|
245
|
+
while (
|
|
246
|
+
suffix < prev.length - prefix &&
|
|
247
|
+
suffix < next.length - prefix &&
|
|
248
|
+
prev[prev.length - 1 - suffix] === next[next.length - 1 - suffix]
|
|
249
|
+
) {
|
|
250
|
+
suffix++;
|
|
251
|
+
}
|
|
252
|
+
const a = prev.slice(prefix, prev.length - suffix);
|
|
253
|
+
const b = next.slice(prefix, next.length - suffix);
|
|
254
|
+
if (a.length === 0) return { added: b.length, removed: 0 };
|
|
255
|
+
if (b.length === 0) return { added: 0, removed: a.length };
|
|
256
|
+
if (a.length * b.length > maxCells) return { added: b.length, removed: a.length };
|
|
257
|
+
|
|
258
|
+
// LCS length over the middle sections (classic DP, row-major Uint32 table).
|
|
259
|
+
const n = a.length;
|
|
260
|
+
const m = b.length;
|
|
261
|
+
const stride = m + 1;
|
|
262
|
+
const dp = new Uint32Array((n + 1) * stride);
|
|
263
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
264
|
+
const row = i * stride;
|
|
265
|
+
const nextRow = (i + 1) * stride;
|
|
266
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
267
|
+
dp[row + j] =
|
|
268
|
+
a[i] === b[j]
|
|
269
|
+
? dp[nextRow + j + 1] + 1
|
|
270
|
+
: Math.max(dp[nextRow + j], dp[row + j + 1]);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const common = dp[0];
|
|
274
|
+
return { added: b.length - common, removed: a.length - common };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Line-level added/removed counts summed over an edit tool's edits array
|
|
279
|
+
* (supports the legacy single oldText/newText shape). Returns undefined when
|
|
280
|
+
* there is nothing renderable.
|
|
281
|
+
*/
|
|
282
|
+
export function countEditsLineDiff(input: unknown): LineDiffStat | undefined {
|
|
283
|
+
if (!input || typeof input !== "object") return undefined;
|
|
284
|
+
const { edits, oldText, newText } = input as {
|
|
285
|
+
edits?: unknown;
|
|
286
|
+
oldText?: unknown;
|
|
287
|
+
newText?: unknown;
|
|
288
|
+
};
|
|
289
|
+
const editList = Array.isArray(edits)
|
|
290
|
+
? edits
|
|
291
|
+
: typeof oldText === "string" && typeof newText === "string"
|
|
292
|
+
? [{ oldText, newText }]
|
|
293
|
+
: [];
|
|
294
|
+
let added = 0;
|
|
295
|
+
let removed = 0;
|
|
296
|
+
let seen = false;
|
|
297
|
+
for (const edit of editList as Array<{ oldText?: unknown; newText?: unknown }>) {
|
|
298
|
+
if (!edit || typeof edit !== "object") continue;
|
|
299
|
+
const oldText = typeof edit.oldText === "string" ? edit.oldText : undefined;
|
|
300
|
+
const newText = typeof edit.newText === "string" ? edit.newText : undefined;
|
|
301
|
+
if (oldText === undefined && newText === undefined) continue;
|
|
302
|
+
const stat = countLineDiff(oldText, newText ?? "");
|
|
303
|
+
added += stat.added;
|
|
304
|
+
removed += stat.removed;
|
|
305
|
+
seen = true;
|
|
306
|
+
}
|
|
307
|
+
return seen ? { added, removed } : undefined;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// Thinking line renderers (display-only, used by the markdown transformer)
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The live two-line view shown while the model is thinking:
|
|
316
|
+
* ```
|
|
317
|
+
* Thinking… (8s) ← bold label line
|
|
318
|
+
* <scrolling tail> ← newest thinking text, tail-truncated
|
|
319
|
+
* ```
|
|
320
|
+
* Without a known elapsed time only the tail line is shown.
|
|
321
|
+
*/
|
|
322
|
+
export function liveThinkingLine(
|
|
323
|
+
markdown: string,
|
|
324
|
+
elapsedMs: number | undefined,
|
|
325
|
+
width: number,
|
|
326
|
+
): string {
|
|
327
|
+
if (lastNonEmptyLine(markdown) === "") return markdown; // nothing visible
|
|
328
|
+
const tail = lastMeaningfulLine(markdown);
|
|
329
|
+
if (!tail) {
|
|
330
|
+
// Only structural lines so far (e.g. a bare code fence being streamed):
|
|
331
|
+
// show the label alone — falling back to the full text would flash the
|
|
332
|
+
// whole block open for a frame (the fold "flicker").
|
|
333
|
+
return elapsedMs === undefined
|
|
334
|
+
? ""
|
|
335
|
+
: `**Thinking… (${formatDuration(elapsedMs, "live")})**`;
|
|
336
|
+
}
|
|
337
|
+
const fitted = tailFit(tail, sanitizeWidth(width));
|
|
338
|
+
if (elapsedMs === undefined) return fitted;
|
|
339
|
+
return `**Thinking… (${formatDuration(elapsedMs, "live")})**\n\n${fitted}`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* The single collapsed line shown once a thinking run has finished.
|
|
344
|
+
* - "smart": `Thought for 12.4s` (bold) — pi's native hidden-label look plus time.
|
|
345
|
+
* - "tail": bold duration prefix + the last line (legacy one-line tail fold).
|
|
346
|
+
*/
|
|
347
|
+
export function foldedThinkingLine(
|
|
348
|
+
markdown: string,
|
|
349
|
+
ms: number | undefined,
|
|
350
|
+
width: number,
|
|
351
|
+
style: "smart" | "tail",
|
|
352
|
+
): string {
|
|
353
|
+
const w = sanitizeWidth(width);
|
|
354
|
+
if (style === "tail") {
|
|
355
|
+
if (lastNonEmptyLine(markdown) === "") return markdown; // nothing visible
|
|
356
|
+
const tail = lastMeaningfulLine(markdown);
|
|
357
|
+
if (!tail) {
|
|
358
|
+
// Nothing textual (e.g. thinking ends right at a code fence): the
|
|
359
|
+
// duration line alone — never fall back to the full markdown.
|
|
360
|
+
return ms === undefined
|
|
361
|
+
? "**Thought…**"
|
|
362
|
+
: `**Thought for ${formatDuration(ms, "final")}**`;
|
|
363
|
+
}
|
|
364
|
+
const prefix = ms === undefined ? "" : `**${formatDuration(ms, "final")}** · `;
|
|
365
|
+
return tailFit(prefix + tail, w);
|
|
366
|
+
}
|
|
367
|
+
return tailFit(
|
|
368
|
+
ms === undefined ? "**Thought…**" : `**Thought for ${formatDuration(ms, "final")}**`,
|
|
369
|
+
w,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Bold live-timing footer appended below fully-expanded thinking text while
|
|
375
|
+
* the run is still streaming (`\n\n**Thinking… (8s)**`), or an empty string
|
|
376
|
+
* when the elapsed time is unknown. Keeps updating at the bottom of the
|
|
377
|
+
* block while the full text grows.
|
|
378
|
+
*/
|
|
379
|
+
export function liveExpandedSuffix(ms: number | undefined): string {
|
|
380
|
+
return ms === undefined ? "" : `\n\n**Thinking… (${formatDuration(ms, "live")})**`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Bold duration footer appended below fully-expanded thinking text
|
|
385
|
+
* (`\n\n**Thought for 12.4s**`), or an empty string when unknown.
|
|
386
|
+
*/
|
|
387
|
+
export function expandedThinkingSuffix(ms: number | undefined): string {
|
|
388
|
+
return ms === undefined ? "" : `\n\n**Thought for ${formatDuration(ms, "final")}**`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Close a trailing unclosed fenced code block (``` or ~~~) so text appended
|
|
393
|
+
* after `markdown` — e.g. the expanded-view footer — renders BELOW the code
|
|
394
|
+
* block instead of inside it as literal characters (visible `**` asterisks).
|
|
395
|
+
* A closing fence must match the opening fence's character, be at least as
|
|
396
|
+
* long, and carry no info string. Balanced input passes through unchanged.
|
|
397
|
+
*/
|
|
398
|
+
export function closeOpenFences(markdown: string): string {
|
|
399
|
+
let fence: string | null = null;
|
|
400
|
+
for (const line of markdown.split(/\r?\n/)) {
|
|
401
|
+
const match = /^\s{0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
402
|
+
if (!match) continue;
|
|
403
|
+
if (fence === null) {
|
|
404
|
+
fence = match[1];
|
|
405
|
+
} else if (
|
|
406
|
+
match[1][0] === fence[0] &&
|
|
407
|
+
match[1].length >= fence.length &&
|
|
408
|
+
match[2].trim() === ""
|
|
409
|
+
) {
|
|
410
|
+
fence = null;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return fence === null ? markdown : `${markdown}\n${fence}`;
|
|
414
|
+
}
|
|
415
|
+
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
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,9 +16,18 @@
|
|
|
16
16
|
]
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
|
-
"
|
|
19
|
+
"postinstall": "node scripts/link-pi.mjs",
|
|
20
|
+
"link:pi": "node scripts/link-pi.mjs --required",
|
|
21
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
22
|
+
"check": "node scripts/link-pi.mjs && tsc -p tsconfig.json && node scripts/load-check.mjs && node test/fold.test.mjs && node test/click-sim.mjs",
|
|
23
|
+
"test": "node test/fold.test.mjs",
|
|
24
|
+
"test:sim": "node test/click-sim.mjs"
|
|
20
25
|
},
|
|
21
26
|
"engines": {
|
|
22
27
|
"node": ">=22.18"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^24.13.6",
|
|
31
|
+
"typescript": "5.9"
|
|
23
32
|
}
|
|
24
33
|
}
|