@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/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
- /** Fold each thinking block down to its (live) last line. Default: true */
15
- thinkingFold: boolean;
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
- thinkingFold: true,
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 Partial<SmartFoldConfig>;
59
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>;
33
60
  return {
34
- toolsFold: typeof parsed.toolsFold === "boolean" ? parsed.toolsFold : defaultConfig.toolsFold,
35
- thinkingFold:
36
- typeof parsed.thinkingFold === "boolean" ? parsed.thinkingFold : defaultConfig.thinkingFold,
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 non-empty line, strip block markers, and tail-truncate
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
- const line = stripBlockMarkers(lastNonEmptyLine(markdown));
158
+ if (lastNonEmptyLine(markdown) === "") return markdown; // nothing visible
159
+ const line = lastMeaningfulLine(markdown);
126
160
  if (!line) return markdown;
127
- // Sanitize the width: renderers should always pass a positive number, but a
128
- // missing/NaN value must degrade to a sane default instead of leaking NaN.
129
- const width = Number.isFinite(availableWidth) && availableWidth >= 8
130
- ? Math.floor(availableWidth)
131
- : 80;
132
- return tailFit(line, width);
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
+
@@ -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.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
- "test": "node test/fold.test.mjs"
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
  }