@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/index.ts CHANGED
@@ -4,28 +4,95 @@
4
4
  * A pi (https://github.com/earendil-works/pi-mono) coding-agent extension that
5
5
  * keeps the transcript compact:
6
6
  *
7
- * 1. Tool output is collapsed on every session start (`session_start` covers
8
- * startup, /reload, /new, /resume and /fork).
9
- * 2. Every assistant *thinking* block is folded down to a single line that
10
- * shows its LAST line. While the model streams, the folded line keeps
11
- * updating to the newest text — an auto-scrolling "tail -f" effect.
7
+ * 1. Thinking blocks:
8
+ * - While the model thinks, the block shows a bold `Thinking… (8s)`
9
+ * label line, then the scrolling tail of the thinking text below it.
10
+ * - Clicking the live block toggles directly between that scrolling
11
+ * tail and the full text so far — no intermediate label state. In
12
+ * full view the ticking bold `Thinking… (8s)` line stays pinned at
13
+ * the bottom of the block while the text grows above it.
14
+ * - Once the message finishes, the block collapses to a single bold
15
+ * `Thought for 12.4s` line.
16
+ * - Click a collapsed block once — that single block expands to its
17
+ * full text (with the duration footer at the bottom); click once more
18
+ * to collapse it again. Finished thinking starts in pi's hidden state
19
+ * (the prototype patch seeds it and writes the per-message label
20
+ * `Thought for 12.4s`), so each click lands directly on the expanded
21
+ * rendering — no intermediate state.
22
+ * - Click detection is exact, not heuristic: pi's click handler mutates
23
+ * the component's internal `thinkingVisibilityOverrides` map before
24
+ * re-rendering. The extension observes that mutation through a small
25
+ * idempotent prototype patch on the (publicly exported)
26
+ * AssistantMessageComponent, so global re-renders (theme change,
27
+ * resize, ctrl+t) never fake a click.
28
+ * - The extension owns pi's hidden-thinking label text, so the native
29
+ * "Thinking..." never appears — clicks and ctrl+t show our hint.
30
+ * - Durations are measured per thinking run and persisted in the
31
+ * session, so restored sessions keep showing them.
32
+ * 2. Tool output is collapsed on every session start (`session_start`
33
+ * covers startup, /reload, /new, /resume and /fork).
34
+ * 3. The `write` tool gets git-style stats in its header line —
35
+ * `write path +12 -3` (green additions, red deletions) — computed by
36
+ * diffing the file content before the write executes. Collapsed write
37
+ * rows show only that header line (the content preview appears when
38
+ * expanded).
12
39
  *
13
- * The full thinking text is never modified on disk or in the LLM context:
14
- * folding is display-only, applied through pi's markdown-transformer hook.
40
+ * Thinking text is never modified on disk or in the LLM context: folding is
41
+ * display-only, applied through pi's markdown-transformer hook. The write
42
+ * override delegates to pi's own write tool implementation; only the TUI
43
+ * rendering is customized.
15
44
  *
16
45
  * Runtime control:
17
- * /fold show current state
18
- * /fold thinking on|off fold / unfold thinking blocks (persisted)
19
- * /fold tools on|off collapse / expand tool output (persisted, and
20
- * applied to the current session immediately)
46
+ * /fold open the /config-style settings list
47
+ * /fold thinking smart|tail|full|off
48
+ * /fold expand on|off expand-all fallback (per-block clicks are primary)
49
+ * /fold tools on|off
50
+ * /fold writestat on|off
51
+ * /fold writecollapsed header|preview
21
52
  *
22
53
  * Config file: `smart-fold.config.json` next to this entry file.
23
54
  */
24
- import { dirname } from "node:path";
55
+ import { existsSync, statSync } from "node:fs";
56
+ import { readFile } from "node:fs/promises";
57
+ import { dirname, resolve as resolvePath } from "node:path";
25
58
  import { fileURLToPath } from "node:url";
59
+
26
60
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
27
- import { collapseThinking } from "./lib/fold.ts";
28
- import { loadConfig, saveConfig, type SmartFoldConfig } from "./lib/config.ts";
61
+ import {
62
+ AssistantMessageComponent,
63
+ createBashToolDefinition,
64
+ createEditToolDefinition,
65
+ createFindToolDefinition,
66
+ createGrepToolDefinition,
67
+ createLsToolDefinition,
68
+ createReadToolDefinition,
69
+ createWriteToolDefinition,
70
+ getSettingsListTheme,
71
+ } from "@earendil-works/pi-coding-agent";
72
+ import { Container, SettingsList, Text, truncateToWidth } from "@earendil-works/pi-tui";
73
+ import type { Component, SettingItem } from "@earendil-works/pi-tui";
74
+
75
+ import {
76
+ closeOpenFences,
77
+ countEditsLineDiff,
78
+ countLineDiff,
79
+ displayWidth,
80
+ formatDuration,
81
+ expandedThinkingSuffix,
82
+ foldedThinkingLine,
83
+ hashText,
84
+ liveExpandedSuffix,
85
+ liveThinkingLine,
86
+ } from "./lib/fold.ts";
87
+ import {
88
+ defaultConfig,
89
+ loadConfig,
90
+ saveConfig,
91
+ type SmartFoldConfig,
92
+ type ThinkingMode,
93
+ type WriteCollapsedStyle,
94
+ } from "./lib/config.ts";
95
+ import { ThinkingTracker } from "./lib/thinking.ts";
29
96
 
30
97
  /** Best-effort resolve of this extension's directory (for the config file). */
31
98
  function resolveExtensionDir(): string | undefined {
@@ -47,31 +114,667 @@ function resolveExtensionDir(): string | undefined {
47
114
  return undefined;
48
115
  }
49
116
 
117
+ // ---------------------------------------------------------------------------
118
+ // Thinking display state (exact, not heuristic): single-click expand
119
+ // ---------------------------------------------------------------------------
120
+ //
121
+ // pi's thinking click handler does:
122
+ // this.thinkingVisibilityOverrides.set(runIndex, !hidden);
123
+ // this.updateContent(this.lastMessage);
124
+ // and ctrl+t / the settings toggle do:
125
+ // this.thinkingVisibilityOverrides.clear();
126
+ // this.updateContent(...)
127
+ //
128
+ // A block renders "hidden" (pi's native label state) when
129
+ // overrides.get(runIndex) ?? hideThinkingBlock
130
+ // is true. We patch AssistantMessageComponent.prototype.updateContent
131
+ // (idempotently, shared across extension reloads via Symbol.for keys) to:
132
+ //
133
+ // 1. SEED every thinking run of a FINALIZED message with a hidden override
134
+ // when it has none yet (runs a streaming-era redirect left visible but
135
+ // folded are normalized into the seeded state as well). Finished
136
+ // thinking therefore starts collapsed in the hidden state — and since we
137
+ // also set the component's label text per message (`Thought for 12.4s`),
138
+ // the collapsed line keeps its duration. One click then flips the run to
139
+ // expanded (our transformer renders the full text), one click flips it
140
+ // back. Single click.
141
+ // 2. CLEAR the overrides when expand-all is requested (mode "full" or the
142
+ // expand toggle), so every run renders expanded.
143
+ // 3. DIFF the overrides map against a per-component snapshot to observe
144
+ // clicks exactly (seeding performed in the same pass is excluded, and
145
+ // clears are ignored) and record which block (content hash) was clicked
146
+ // plus a monotonic click sequence.
147
+ // 4. REDIRECT clicks on any thinking run of a STILL-STREAMING message.
148
+ // pi's handler just toggled that run to the hidden-label state; we flip
149
+ // the override straight back to visible and toggle our own mode
150
+ // instead. For the run still being written that switches between the
151
+ // scrolling tail and the full text (timing line pinned at the bottom);
152
+ // for a run that already finished while the message keeps streaming it
153
+ // switches between the folded duration line and the full text with its
154
+ // footer. The MouseRegion click closure is rebuilt from the corrected
155
+ // map on every render, so each later click hits the same redirect and
156
+ // the hidden middle state (the global `Thought…` label) never shows.
157
+ //
158
+ // The markdown transformer uses that click signal only to mark a block as
159
+ // user-revealed (add-only), so unflagged re-renders of expanded blocks keep
160
+ // showing the full text and global re-renders never fake a click.
161
+
162
+ const SF_STATE = Symbol.for("pi-smart-fold.state");
163
+ const OVERRIDE_SNAPSHOT = Symbol.for("pi-smart-fold.overrideSnapshot");
164
+
165
+ interface SmartFoldGlobalState {
166
+ /** "smart" | "tail" => seed hidden; "full" => clear; "off" => inert. */
167
+ behavior: "seed" | "clear" | "inert";
168
+ /** Look up (or finalize) the duration of a thinking run's joined text. */
169
+ durationFor(runText: string): number | undefined;
170
+ /**
171
+ * True when a run has no finalized duration yet, i.e. it is the run still
172
+ * being written and currently renders in live mode. Pure — never closes
173
+ * the tracker's open group (unlike `durationFor`).
174
+ */
175
+ isLiveRun(runText: string): boolean;
176
+ /**
177
+ * True when a FINISHED run of a still-streaming message currently renders
178
+ * expanded (revealed by a click, or matched by the streaming open prefix,
179
+ * or expand-all/full mode) — mirrors the transformer's `open` condition.
180
+ */
181
+ isFinishedRunOpen(runText: string): boolean;
182
+ /** Called when the user collapses a live run back to the scrolling tail. */
183
+ onLiveCollapse?(runText: string): void;
184
+ /**
185
+ * Called when the user collapses a finished run of a still-streaming
186
+ * message back to its folded duration line.
187
+ */
188
+ onFinishedCollapse?(runText: string): void;
189
+ }
190
+
191
+ type SmartFoldGlobal = typeof globalThis & { [SF_STATE]?: SmartFoldGlobalState };
192
+
193
+ function smartFoldState(): SmartFoldGlobalState | undefined {
194
+ return (globalThis as SmartFoldGlobal)[SF_STATE];
195
+ }
196
+
197
+ function sameOverrideEntries(
198
+ a: Array<[number, boolean]>,
199
+ b: Array<[number, boolean]>,
200
+ ): boolean {
201
+ if (a.length !== b.length) return false;
202
+ for (let i = 0; i < a.length; i++) {
203
+ if (a[i][0] !== b[i][0] || a[i][1] !== b[i][1]) return false;
204
+ }
205
+ return true;
206
+ }
207
+
208
+ /** The run index whose override entry changed between snapshots (or null). */
209
+ function firstChangedRun(
210
+ previous: Array<[number, boolean]>,
211
+ current: Array<[number, boolean]>,
212
+ ignore: Set<number>,
213
+ ): number | null {
214
+ const currentMap = new Map(current);
215
+ for (const [run, value] of previous) {
216
+ if (ignore.has(run)) continue;
217
+ if (currentMap.get(run) !== value) return run; // value flipped
218
+ }
219
+ for (const [run] of current) {
220
+ if (ignore.has(run)) continue;
221
+ if (!previous.some(([pRun]) => pRun === run)) return run; // newly added
222
+ }
223
+ return null;
224
+ }
225
+
226
+ function installThinkingDisplayPatch(): void {
227
+ const proto = (
228
+ AssistantMessageComponent as unknown as {
229
+ prototype?: { updateContent?: (...args: unknown[]) => unknown };
230
+ }
231
+ ).prototype;
232
+ const original = proto?.updateContent;
233
+ if (!proto || typeof original !== "function") return;
234
+ if ("__smartFoldPatched" in original) return; // already installed by a previous load
235
+ try {
236
+ const patched = function (this: unknown, message: unknown, isStreaming?: boolean) {
237
+ const self = this as {
238
+ thinkingVisibilityOverrides?: Map<number, boolean>;
239
+ hiddenThinkingLabel?: string;
240
+ isStreaming?: boolean;
241
+ lastMessage?: unknown;
242
+ [key: symbol]: Array<[number, boolean]> | undefined;
243
+ };
244
+ try {
245
+ const state = smartFoldState();
246
+ const overrides = self.thinkingVisibilityOverrides;
247
+ if (state && state.behavior !== "inert" && overrides instanceof Map) {
248
+ const previous = self[OVERRIDE_SNAPSHOT];
249
+ const previousMap = previous === undefined ? undefined : new Map(previous);
250
+ const seeded = new Set<number>();
251
+ const finalized =
252
+ (isStreaming === undefined ? self.isStreaming !== true : isStreaming === false);
253
+
254
+ if (finalized) {
255
+ const runs = thinkingRuns(self.lastMessage ?? message);
256
+ if (state.behavior === "clear") {
257
+ if (overrides.size > 0) overrides.clear();
258
+ } else if (runs.length > 0) {
259
+ // Seed finished thinking runs as hidden (single-click expand),
260
+ // and refresh the per-message hidden label with its duration.
261
+ for (let run = 0; run < runs.length; run++) {
262
+ if (!overrides.has(run)) {
263
+ overrides.set(run, true);
264
+ seeded.add(run);
265
+ } else if (
266
+ overrides.get(run) === false &&
267
+ previousMap?.get(run) === false && // leftover, not a fresh click
268
+ runs[run] !== undefined &&
269
+ state.isFinishedRunOpen?.(runs[run]) !== true
270
+ ) {
271
+ // A streaming-era redirect left this run visible but folded
272
+ // (the user collapsed it again, or it was never opened).
273
+ // Normalize it into the seeded hidden state — the folded
274
+ // line and the per-message label read the same, so without
275
+ // this the next click would appear to change nothing.
276
+ // Marked seeded so the diff below does not mistake the
277
+ // normalization for a click.
278
+ overrides.set(run, true);
279
+ seeded.add(run);
280
+ }
281
+ }
282
+ self.hiddenThinkingLabel = collapsedLabelText(runs, state);
283
+ }
284
+ }
285
+ // (While streaming, the live run is kept visible by the redirect
286
+ // below, so it never renders the hidden label.)
287
+
288
+ let current = Array.from(overrides.entries());
289
+ if (
290
+ previous !== undefined &&
291
+ previous.length + current.length > 0 &&
292
+ !(current.length === 0 && previous.length > 0) && // clear = ctrl+t/settings/expand-all
293
+ !sameOverrideEntries(previous, current)
294
+ ) {
295
+ const changedRun = firstChangedRun(previous, current, seeded);
296
+ if (changedRun !== null) {
297
+ const runText = thinkingRunText(self.lastMessage ?? message, changedRun);
298
+ const clickedHidden = new Map(current).get(changedRun) === true;
299
+ if (clickedHidden && !finalized && runText !== null) {
300
+ // A click on a thinking run of a still-streaming message just
301
+ // flipped it to pi's hidden-label state — which would show the
302
+ // global `Thought…` label (per-message labels are only set
303
+ // once finalized). Redirect: keep the run visible and toggle
304
+ // our own mode instead. The click closure is rebuilt from the
305
+ // corrected map, so every later click lands here again — one
306
+ // click per state change, no hidden middle state.
307
+ if (state.isLiveRun?.(runText) === true) {
308
+ // Run still being written: scrolling tail ↔ full text so
309
+ // far, timing line pinned at the bottom of the full view.
310
+ const openPrefix = openStreamingPrefix();
311
+ if (openPrefix !== null && runText.startsWith(openPrefix)) {
312
+ state.onLiveCollapse?.(runText); // drop any stale reveal mark
313
+ setStreamingOpenPrefix(null); // full → scrolling tail
314
+ } else {
315
+ recordRevealClick(runText); // tail → full text so far
316
+ setStreamingOpenPrefix(runText);
317
+ }
318
+ } else if (state.isFinishedRunOpen?.(runText) === true) {
319
+ // Run already finished while the message keeps streaming:
320
+ // full text → folded duration line.
321
+ state.onFinishedCollapse?.(runText);
322
+ } else {
323
+ // Finished run, currently folded: → full text + footer.
324
+ recordRevealClick(runText);
325
+ }
326
+ overrides.set(changedRun, false);
327
+ current = Array.from(overrides.entries());
328
+ } else if (!clickedHidden) {
329
+ // Click that opened a finished run: mark it revealed.
330
+ recordRevealClick(runText);
331
+ }
332
+ }
333
+ }
334
+ self[OVERRIDE_SNAPSHOT] = current;
335
+ }
336
+ } catch {
337
+ // Display customization is best-effort; never break pi rendering.
338
+ }
339
+ return (original as (this: unknown, m: unknown, s?: boolean) => unknown).call(
340
+ this,
341
+ message,
342
+ isStreaming,
343
+ );
344
+ };
345
+ Object.defineProperty(patched, "__smartFoldPatched", { value: true });
346
+ proto.updateContent = patched as typeof original;
347
+ } catch {
348
+ // Best-effort: without the patch, thinking falls back to native behavior.
349
+ }
350
+ }
351
+
352
+ /** Joined texts of every thinking run in a message, in run order. */
353
+ function thinkingRuns(message: unknown): string[] {
354
+ const content = (message as { content?: Array<{ type?: string; thinking?: string }> })
355
+ ?.content;
356
+ if (!Array.isArray(content)) return [];
357
+ const runs: string[] = [];
358
+ for (let i = 0; i < content.length; i++) {
359
+ if (content[i]?.type !== "thinking") continue;
360
+ const blocks: string[] = [];
361
+ for (; i < content.length; i++) {
362
+ const block = content[i];
363
+ if (block?.type !== "thinking") break;
364
+ const text = typeof block.thinking === "string" ? block.thinking.trim() : "";
365
+ if (text) blocks.push(text);
366
+ }
367
+ i--;
368
+ if (blocks.length > 0) runs.push(blocks.join("\n\n"));
369
+ }
370
+ return runs;
371
+ }
372
+
373
+ /**
374
+ * Joined text of the `wantedRunIndex`-th thinking run of a message — mirrors
375
+ * exactly how pi's AssistantMessageComponent groups consecutive thinking
376
+ * blocks into runs (blocks trimmed, non-empty only, joined with a blank line).
377
+ */
378
+ function thinkingRunText(message: unknown, wantedRunIndex: number): string | null {
379
+ return thinkingRuns(message)[wantedRunIndex] ?? null;
380
+ }
381
+
382
+ /** Collapsed label text for a message's thinking runs (bold via SGR). */
383
+ function collapsedLabelText(runs: string[], state: SmartFoldGlobalState): string {
384
+ let text: string;
385
+ if (runs.length === 1) {
386
+ const ms = state.durationFor(runs[0]);
387
+ text = ms === undefined ? "Thought…" : `Thought for ${formatDuration(ms, "final")}`;
388
+ } else {
389
+ text = "Thought…";
390
+ }
391
+ return `\u001b[1m${text}\u001b[22m`;
392
+ }
393
+
394
+ // Click recording ------------------------------------------------------------
395
+
396
+ const CLICK_AT = Symbol.for("pi-smart-fold.clickAt");
397
+ const CLICK_HASH = Symbol.for("pi-smart-fold.clickHash");
398
+ const CLICK_OPEN_PREFIX = Symbol.for("pi-smart-fold.clickOpenPrefix");
399
+ type ClickGlobal = typeof globalThis & {
400
+ [CLICK_AT]?: number;
401
+ [CLICK_HASH]?: string;
402
+ [CLICK_OPEN_PREFIX]?: string | null;
403
+ };
404
+
405
+ /**
406
+ * Record a thinking click that opens a block: stamps the time and the
407
+ * block's content hash so the transformer's re-render (within the click
408
+ * window) marks it user-revealed (add-only).
409
+ */
410
+ function recordRevealClick(runText: string | null): void {
411
+ const clickGlobal = globalThis as ClickGlobal;
412
+ clickGlobal[CLICK_AT] = Date.now();
413
+ clickGlobal[CLICK_HASH] = runText !== null ? hashText(runText) : undefined;
414
+ }
415
+
416
+ /**
417
+ * Set (or clear) the streaming run the user clicked open. Thinking text
418
+ * only grows, so every later render of the same run starts with this prefix
419
+ * and stays expanded until toggled back. Only live-run clicks manage this;
420
+ * clicks on finished runs never clobber it.
421
+ */
422
+ function setStreamingOpenPrefix(runText: string | null): void {
423
+ (globalThis as ClickGlobal)[CLICK_OPEN_PREFIX] = runText;
424
+ }
425
+
426
+ /** Prefix of the streaming run the user clicked open, if any. */
427
+ function openStreamingPrefix(): string | null {
428
+ return (globalThis as ClickGlobal)[CLICK_OPEN_PREFIX] ?? null;
429
+ }
430
+
431
+ function clearOpenStreamingPrefix(): void {
432
+ (globalThis as ClickGlobal)[CLICK_OPEN_PREFIX] = null;
433
+ }
434
+
435
+ /**
436
+ * Forget the last recorded click so a re-render inside the click window
437
+ * cannot re-reveal a block the user just collapsed.
438
+ */
439
+ function clearRevealClick(): void {
440
+ const clickGlobal = globalThis as ClickGlobal;
441
+ clickGlobal[CLICK_AT] = undefined;
442
+ clickGlobal[CLICK_HASH] = undefined;
443
+ }
444
+
445
+ /** How long after a click its re-render is expected (generous for slow frames). */
446
+ const CLICK_WINDOW_MS = 250;
447
+
448
+ /** Info about the most recent thinking-click, if it identified a block. */
449
+ function lastClickInfo(): { at: number; hash: string | undefined } | undefined {
450
+ const clickGlobal = globalThis as ClickGlobal;
451
+ if (clickGlobal[CLICK_AT] === undefined) return undefined;
452
+ return { at: clickGlobal[CLICK_AT]!, hash: clickGlobal[CLICK_HASH] };
453
+ }
454
+
455
+ /** Renderer-facing theme subset (avoids coupling to the full Theme type). */
456
+ interface ThemeLike {
457
+ fg(color: string, text: string): string;
458
+ bold(text: string): string;
459
+ }
460
+
461
+ /** Structural copy of the context pi passes to tool renderCall slots. */
462
+ interface ToolRenderContextLike {
463
+ toolCallId: string;
464
+ invalidate(): void;
465
+ lastComponent?: unknown;
466
+ cwd?: string;
467
+ executionStarted?: boolean;
468
+ argsComplete?: boolean;
469
+ isPartial?: boolean;
470
+ expanded?: boolean;
471
+ showImages?: boolean;
472
+ isError?: boolean;
473
+ [key: string]: unknown;
474
+ }
475
+
476
+ interface CtxLike {
477
+ mode?: string;
478
+ hasUI: boolean;
479
+ ui: {
480
+ notify(message: string, type?: "info" | "warning" | "error"): void;
481
+ setToolsExpanded?(expanded: boolean): void;
482
+ setHiddenThinkingLabel?(label?: string): void;
483
+ custom?: unknown;
484
+ theme?: ThemeLike;
485
+ };
486
+ cwd?: string;
487
+ sessionManager: {
488
+ getBranch(): Array<{ type: string; customType?: string; data?: unknown }>;
489
+ };
490
+ }
491
+
492
+ const MAX_WRITE_FILE_BYTES = 8 * 1024 * 1024;
493
+ const MAX_REVEALED_BLOCKS = 2000;
494
+
495
+ /** Index of the first line with visible content (padding/blank lines skipped). */
496
+ function firstContentLine(lines: string[]): number {
497
+ const plain = (line: string): string =>
498
+ line
499
+ .replace(/\u001b\[[0-9;?]*[A-Za-z]/g, "")
500
+ .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g, "");
501
+ for (let i = 0; i < lines.length; i++) {
502
+ if (plain(lines[i]).trim() !== "") return i;
503
+ }
504
+ return 0;
505
+ }
506
+
507
+ /** Drop trailing filler padding (Box pads lines to the full width). */
508
+ function trimLineFiller(line: string): string {
509
+ return line
510
+ .replace(/[ \t]+(\u001b\[[0-9;]*[A-Za-z])$/, "$1")
511
+ .replace(/[ \t]+$/, "");
512
+ }
513
+
514
+ /**
515
+ * Insert text into a rendered line's trailing padding, eating an equal
516
+ * number of padding columns so the line keeps its exact width (and thus its
517
+ * full-width background bar). Returns null when there is not enough padding.
518
+ */
519
+ function insertIntoLinePadding(line: string, insert: string): string | null {
520
+ if (!insert) return line;
521
+ const resetMatch = line.match(/(?:\u001b\[[0-9;]*m)+$/);
522
+ const tail = resetMatch ? resetMatch[0] : "";
523
+ const bodyEnd = line.length - tail.length;
524
+ let end = bodyEnd;
525
+ while (end > 0 && (line[end - 1] === " " || line[end - 1] === "\t")) end--;
526
+ const insertWidth = displayWidth(insert);
527
+ if (bodyEnd - end < insertWidth) return null; // not enough padding
528
+ const keptPadding = " ".repeat(bodyEnd - end - insertWidth);
529
+ return line.slice(0, end) + insert + keptPadding + tail;
530
+ }
531
+
50
532
  export default function smartFold(pi: ExtensionAPI): void {
51
533
  const extensionDir = resolveExtensionDir();
52
- const config: SmartFoldConfig = extensionDir ? loadConfig(extensionDir) : { toolsFold: true, thinkingFold: true };
534
+ const config: SmartFoldConfig = extensionDir
535
+ ? loadConfig(extensionDir)
536
+ : { ...defaultConfig };
537
+
538
+ const persist = (): void => {
539
+ if (extensionDir) saveConfig(extensionDir, config);
540
+ };
541
+
542
+ installThinkingDisplayPatch();
543
+
544
+ /** Publish the display behavior the prototype patch reads on every render. */
545
+ const publishState = (): void => {
546
+ (globalThis as SmartFoldGlobal)[SF_STATE] = {
547
+ behavior:
548
+ config.thinking === "off"
549
+ ? "inert"
550
+ : config.thinking === "full" || expandAllThinking
551
+ ? "clear"
552
+ : "seed",
553
+ durationFor: (runText: string) =>
554
+ tracker.finalizedMs(hashText(runText)) ?? tracker.finalizeIfMatches(runText),
555
+ isLiveRun: (runText: string) => tracker.finalizedMs(hashText(runText)) === undefined,
556
+ isFinishedRunOpen: (runText: string) => {
557
+ if (revealedBlocks.has(hashText(runText))) return true;
558
+ const prefix = openStreamingPrefix();
559
+ if (prefix !== null && runText.startsWith(prefix)) return true;
560
+ return config.thinking === "full" || expandAllThinking;
561
+ },
562
+ onLiveCollapse: (runText: string) => {
563
+ revealedBlocks.delete(hashText(runText));
564
+ clearRevealClick();
565
+ },
566
+ onFinishedCollapse: (runText: string) => {
567
+ revealedBlocks.delete(hashText(runText));
568
+ const prefix = openStreamingPrefix();
569
+ if (prefix !== null && runText.startsWith(prefix)) {
570
+ setStreamingOpenPrefix(null); // that prefix was this run's text
571
+ }
572
+ clearRevealClick();
573
+ },
574
+ };
575
+ };
576
+
577
+ // Runtime state -----------------------------------------------------------
578
+
579
+ /** Thinking-run timing (live elapsed + finalized durations by content hash). */
580
+ const tracker = new ThinkingTracker();
581
+ let expandAllThinking = false;
582
+ publishState(); // the initial chat build can render before session_start fires
583
+
584
+ /**
585
+ * Content hashes of finished thinking blocks the user expanded by clicking.
586
+ * Toggled exactly once per observed click, so stray/global re-renders are
587
+ * inert and resize keeps user intent.
588
+ */
589
+ const revealedBlocks = new Set<string>();
590
+
591
+ /**
592
+ * Expand-all fallback (settings item / /fold expand). Per-block clicks are
593
+ * the primary interaction; this covers "show me everything".
594
+ */
595
+ /**
596
+ * Text shown by pi's internal hidden-thinking state (first click of the
597
+ * two-click gesture, and ctrl+t). We own it so the native "Thinking..."
598
+ * never appears anywhere.
599
+ */
600
+ const hiddenThinkingLabelText = (): string | undefined => {
601
+ if (config.thinking === "off") return undefined; // restore pi's default
602
+ return "Thought…"; // per-message labels (with durations) come from the patch
603
+ };
53
604
 
54
- // Runtime state (starts from persisted config; /fold mutates + persists).
55
- let thinkingFold = config.thinkingFold;
605
+ /** Apply (or restore) the hidden-thinking label; re-renders all messages. */
606
+ const applyHiddenLabel = (ctx: CtxLike): void => {
607
+ if (!ctx.hasUI) return;
608
+ try {
609
+ ctx.ui.setHiddenThinkingLabel?.(hiddenThinkingLabelText());
610
+ } catch {
611
+ // Best-effort.
612
+ }
613
+ };
614
+
615
+ /** write tool line-diff stats by toolCallId (live sessions only). */
616
+ const writeStats = new Map<string, { added: number; removed: number }>();
617
+
618
+ /** Live write call rows, so a stat computed after render can force a rerender. */
619
+ const writeRows = new Map<string, ToolCallWrapper>();
56
620
 
57
621
  // -------------------------------------------------------------------------
58
- // 1) Fold thinking blocks to their last line (display-only).
622
+ // 1) Thinking: live tail while streaming, folded line with duration after,
623
+ // per-block click expansion.
59
624
  // -------------------------------------------------------------------------
60
- // The transformer runs whenever an assistant message (re)renders — including
61
- // every streaming update — so the folded line automatically "scrolls" to the
62
- // latest thinking text while the model works.
63
625
  pi.registerMarkdownTransformer((markdown, context) => {
64
626
  if (context.messageType !== "assistant-thinking") return markdown;
65
- if (!thinkingFold) return markdown;
66
- // -1 gives the renderer a little slack for padding/borders; collapseThinking
67
- // sanitizes the width itself.
68
- return collapseThinking(markdown, context.availableWidth - 1);
627
+ const mode = config.thinking;
628
+ if (mode === "off") return markdown;
629
+
630
+ const width = context.availableWidth - 1; // renderer padding slack
631
+ const key = hashText(markdown);
632
+
633
+ let ms = tracker.finalizedMs(key);
634
+ if (ms === undefined && !context.isStreaming) ms = tracker.finalizeIfMatches(markdown);
635
+
636
+ // A click on this block re-renders it right after the click handler
637
+ // mutated that block's visibility override. The observer recorded exactly
638
+ // which block (by content hash) — mark it revealed (add-only). Collapse
639
+ // happens by clicking back to the hidden label, which never runs this
640
+ // transformer.
641
+ const click = lastClickInfo();
642
+ if (
643
+ click &&
644
+ Date.now() - click.at <= CLICK_WINDOW_MS &&
645
+ click.hash === key &&
646
+ !revealedBlocks.has(key)
647
+ ) {
648
+ revealedBlocks.add(key);
649
+ if (revealedBlocks.size > MAX_REVEALED_BLOCKS) revealedBlocks.clear();
650
+ }
651
+
652
+ if (context.isStreaming) {
653
+ // Finished runs of the still-streaming message behave like finalized
654
+ // blocks: folded duration line by default; full text with the footer
655
+ // when clicked open — including one the user opened while it streamed
656
+ // (identified by the open prefix) — or when expand-all is on.
657
+ if (ms !== undefined) {
658
+ const openPrefix = openStreamingPrefix();
659
+ const open =
660
+ revealedBlocks.has(key) ||
661
+ (openPrefix !== null && markdown.startsWith(openPrefix)) ||
662
+ mode === "full" ||
663
+ expandAllThinking;
664
+ if (open) {
665
+ // Close any unclosed code fence so the footer renders below the
666
+ // code block, not inside it as literal `**` characters.
667
+ return (
668
+ closeOpenFences(markdown.replace(/\s+$/, "")) + expandedThinkingSuffix(ms)
669
+ );
670
+ }
671
+ return foldedThinkingLine(markdown, ms, width, mode === "tail" ? "tail" : "smart");
672
+ }
673
+ // The run that is still being written: live tail by default; the full
674
+ // text so far when the user clicked it open (prefix identifies the
675
+ // run), with the ticking `Thinking… (Ns)` line pinned at the bottom.
676
+ const openPrefix = openStreamingPrefix();
677
+ if (openPrefix !== null && markdown.startsWith(openPrefix)) {
678
+ // Close any unclosed code fence so the ticking footer stays outside
679
+ // the code block (otherwise it shows literal `**` asterisks).
680
+ return (
681
+ closeOpenFences(markdown.replace(/\s+$/, "")) +
682
+ liveExpandedSuffix(tracker.liveElapsedMs())
683
+ );
684
+ }
685
+ return liveThinkingLine(markdown, tracker.liveElapsedMs(), width);
686
+ }
687
+
688
+ if (mode === "full" || expandAllThinking || revealedBlocks.has(key)) {
689
+ // Fully expanded: original text, with the duration footer at the bottom
690
+ // (after closing any unclosed code fence so it renders as bold text).
691
+ return closeOpenFences(markdown.replace(/\s+$/, "")) + expandedThinkingSuffix(ms);
692
+ }
693
+ return foldedThinkingLine(markdown, ms, width, mode);
69
694
  });
70
695
 
696
+ // Thinking timing events --------------------------------------------------
697
+
698
+ pi.on("message_update", async (event) => {
699
+ if (event.message.role !== "assistant") return;
700
+ const streamEvent = (event as { assistantMessageEvent?: Parameters<
701
+ typeof tracker.handleUpdate
702
+ >[0] }).assistantMessageEvent;
703
+ if (streamEvent) tracker.handleUpdate(streamEvent);
704
+ });
705
+
706
+ pi.on("message_end", async (event) => {
707
+ if (event.message.role !== "assistant") return;
708
+ const runs = tracker.handleMessageEnd(
709
+ event.message as Parameters<typeof tracker.handleMessageEnd>[0],
710
+ );
711
+ clearOpenStreamingPrefix(); // the streaming click no longer applies
712
+ if (runs.length > 0) {
713
+ try {
714
+ pi.appendEntry("smart-fold-thinking", { runs });
715
+ } catch {
716
+ // Persistence is best-effort; display still works in-session.
717
+ }
718
+ }
719
+ });
720
+
721
+ const restoreFromBranch = (ctx: CtxLike): void => {
722
+ writeStats.clear();
723
+ writeRows.clear();
724
+ try {
725
+ for (const entry of ctx.sessionManager.getBranch()) {
726
+ if (entry.type === "custom" && entry.customType === "smart-fold-thinking") {
727
+ const data = entry.data as { runs?: Array<{ hash?: unknown; ms?: unknown }> } | undefined;
728
+ if (Array.isArray(data?.runs)) {
729
+ tracker.restore(
730
+ data.runs.filter(
731
+ (run): run is { hash: string; ms: number } =>
732
+ typeof run?.hash === "string" && typeof run?.ms === "number",
733
+ ),
734
+ );
735
+ }
736
+ continue;
737
+ }
738
+ // Rebuild persisted write stats so history keeps showing +N -M.
739
+ if (entry.type === "message") {
740
+ const message = (
741
+ entry as {
742
+ message?: {
743
+ role?: string;
744
+ toolName?: string;
745
+ toolCallId?: string;
746
+ details?: { smartFold?: { added?: unknown; removed?: unknown } };
747
+ };
748
+ }
749
+ ).message;
750
+ if (
751
+ message?.role === "toolResult" &&
752
+ message.toolName === "write" &&
753
+ typeof message.toolCallId === "string"
754
+ ) {
755
+ const stat = message.details?.smartFold;
756
+ if (typeof stat?.added === "number" && typeof stat.removed === "number") {
757
+ writeStats.set(message.toolCallId, { added: stat.added, removed: stat.removed });
758
+ }
759
+ }
760
+ }
761
+ }
762
+ } catch {
763
+ // Best-effort restore.
764
+ }
765
+ };
766
+
71
767
  // -------------------------------------------------------------------------
72
- // 2) Collapse tool output on session start.
768
+ // 2) Tool output: collapse on session start.
73
769
  // -------------------------------------------------------------------------
74
770
  pi.on("session_start", async (_event, ctx) => {
771
+ const scoped = ctx as unknown as CtxLike;
772
+ restoreFromBranch(scoped);
773
+ revealedBlocks.clear(); // sessions start compact
774
+ clearOpenStreamingPrefix();
775
+ expandAllThinking = false;
776
+ publishState();
777
+ applyHiddenLabel(scoped);
75
778
  if (!config.toolsFold) return;
76
779
  if (!ctx.hasUI) return; // no-op guard for print / json modes
77
780
  try {
@@ -81,44 +784,449 @@ export default function smartFold(pi: ExtensionAPI): void {
81
784
  }
82
785
  });
83
786
 
787
+ pi.on("session_tree", async (_event, ctx) => {
788
+ restoreFromBranch(ctx as unknown as CtxLike);
789
+ });
790
+
791
+ // -------------------------------------------------------------------------
792
+ // 3) Tool calls: collapsed lines truncated with an ellipsis; write/edit
793
+ // headers gain git-style `+N -M` stats.
794
+ // -------------------------------------------------------------------------
795
+ class ToolCallWrapper implements Component {
796
+ inner: Component;
797
+ readonly toolCallId: string;
798
+ args: unknown;
799
+ expanded = false;
800
+ theme: ThemeLike | undefined;
801
+ invalidateRef: (() => void) | undefined;
802
+ /** Collapsed style: header-only (write/edit) vs truncated first line. */
803
+ headerOnly = false;
804
+ /** Stats computed straight from the call args (edit). */
805
+ statFromArgs: ((args: unknown) => { added: number; removed: number } | undefined) | undefined;
806
+ /** Stats computed from the pre-execution file snapshot (write). */
807
+ statFromMap = false;
808
+
809
+ constructor(toolCallId: string, inner: Component) {
810
+ this.toolCallId = toolCallId;
811
+ this.inner = inner;
812
+ }
813
+
814
+ invalidate(): void {
815
+ this.inner.invalidate();
816
+ }
817
+
818
+ render(width: number): string[] {
819
+ const lines = this.inner.render(width);
820
+ if (lines.length === 0) return lines;
821
+ const headerIndex = firstContentLine(lines);
822
+
823
+ const stat = config.writeStat
824
+ ? this.statFromMap
825
+ ? writeStats.get(this.toolCallId)
826
+ : this.statFromArgs?.(this.args)
827
+ : undefined;
828
+ const suffix =
829
+ stat && this.theme
830
+ ? ` ${this.theme.fg("success", `+${stat.added}`)}` +
831
+ ` ${this.theme.fg("error", `-${stat.removed}`)}`
832
+ : "";
833
+
834
+ if (this.expanded) {
835
+ // Expanded: everything, with the stat injected into the header line.
836
+ const out = lines.slice();
837
+ if (suffix) {
838
+ out[headerIndex] =
839
+ insertIntoLinePadding(out[headerIndex], suffix) ??
840
+ trimLineFiller(out[headerIndex]) + suffix;
841
+ }
842
+ return out;
843
+ }
844
+
845
+ if (this.headerOnly && config.writeCollapsed === "header") {
846
+ // Some tools render their own shell (edit: renderShell "self" — no
847
+ // outer box). Keep their full-width background lines and inject the
848
+ // stat into the header line's padding so the bar keeps its size.
849
+ if (headerIndex > 0) {
850
+ const header = suffix
851
+ ? insertIntoLinePadding(lines[headerIndex], suffix) ??
852
+ trimLineFiller(lines[headerIndex]) + suffix
853
+ : lines[headerIndex];
854
+ return [lines[headerIndex - 1], header, lines[lines.length - 1]];
855
+ }
856
+ // Plain inner (write): the outer box pads — trim, then append.
857
+ const header = suffix ? trimLineFiller(lines[headerIndex]) : lines[headerIndex];
858
+ const out = [header];
859
+ if (suffix) {
860
+ if (displayWidth(header) + displayWidth(suffix) <= width) {
861
+ out[0] = header + suffix;
862
+ } else {
863
+ out.splice(1, 0, suffix.trim());
864
+ }
865
+ }
866
+ return out;
867
+ }
868
+
869
+ // Collapsed: single first content line, truncated to the terminal width
870
+ // with an ellipsis (an extra `…` marks hidden continuation lines).
871
+ let line = trimLineFiller(lines[headerIndex]);
872
+ if (suffix) line += suffix;
873
+ if (lines.length > headerIndex + 1) line += " …";
874
+ return [truncateToWidth(line, width, "…")];
875
+ }
876
+ }
877
+
878
+ /**
879
+ * Build a renderCall that delegates to pi's own renderer and wraps its
880
+ * component in our ToolCallWrapper. Only the TUI rendering is customized;
881
+ * execution stays pi's own implementation.
882
+ */
883
+ const wrapToolRenderCall = (
884
+ base: { renderCall?: unknown } & Record<string, unknown>,
885
+ opts: {
886
+ headerOnly?: boolean;
887
+ statFromArgs?: (args: unknown) => { added: number; removed: number } | undefined;
888
+ statFromMap?: boolean;
889
+ } = {},
890
+ ) => {
891
+ const baseRenderCall = base.renderCall as (
892
+ args: unknown,
893
+ theme: ThemeLike,
894
+ context: ToolRenderContextLike,
895
+ ) => Component;
896
+ return (args: unknown, theme: ThemeLike, context: ToolRenderContextLike): Component => {
897
+ let wrapper =
898
+ context.lastComponent instanceof ToolCallWrapper
899
+ ? (context.lastComponent as ToolCallWrapper)
900
+ : undefined;
901
+ const inner = baseRenderCall(args, theme, {
902
+ ...context,
903
+ lastComponent: wrapper?.inner,
904
+ });
905
+ if (!wrapper) {
906
+ wrapper = new ToolCallWrapper(String(context.toolCallId ?? ""), inner);
907
+ wrapper.headerOnly = Boolean(opts.headerOnly);
908
+ wrapper.statFromArgs = opts.statFromArgs;
909
+ wrapper.statFromMap = Boolean(opts.statFromMap);
910
+ } else {
911
+ wrapper.inner = inner;
912
+ }
913
+ wrapper.args = args;
914
+ wrapper.theme = theme;
915
+ wrapper.expanded = Boolean(context.expanded);
916
+ wrapper.invalidateRef = context.invalidate;
917
+ if (wrapper.statFromMap && wrapper.toolCallId) {
918
+ writeRows.set(wrapper.toolCallId, wrapper);
919
+ }
920
+ return wrapper;
921
+ };
922
+ };
923
+
924
+ /**
925
+ * Build a renderResult wrapper for tools whose result area shows code
926
+ * details (edit renders its full diff). Collapsed + header mode shows
927
+ * nothing (errors stay visible); expanded delegates to pi's renderer.
928
+ */
929
+ const wrapToolRenderResult = (
930
+ base: { renderResult?: unknown } & Record<string, unknown>,
931
+ ) => {
932
+ const baseRenderResult = base.renderResult as (
933
+ result: unknown,
934
+ options: { expanded?: boolean },
935
+ theme: ThemeLike,
936
+ context: ToolRenderContextLike & { isError?: boolean },
937
+ ) => Component;
938
+ if (typeof baseRenderResult !== "function") return undefined;
939
+ return (
940
+ result: unknown,
941
+ options: { expanded?: boolean },
942
+ theme: ThemeLike,
943
+ context: ToolRenderContextLike & { isError?: boolean },
944
+ ): Component => {
945
+ if (!options.expanded && config.writeCollapsed === "header" && !context.isError) {
946
+ // Fully collapsed: no code/diff details at all in the result area.
947
+ return new Container();
948
+ }
949
+ return baseRenderResult(result, options, theme, context);
950
+ };
951
+ };
952
+
953
+ // Register the overrides: shell/file tools get collapsed-line truncation;
954
+ // write/edit additionally get `+N -M` stats, header-only collapse and a
955
+ // detail-free collapsed result area.
956
+ const toolOverrides: Array<[
957
+ Record<string, unknown>,
958
+ Parameters<typeof wrapToolRenderCall>[1] & { hideCollapsedResult?: boolean },
959
+ ]> = [
960
+ [createBashToolDefinition(process.cwd()) as unknown as Record<string, unknown>, {}],
961
+ [createReadToolDefinition(process.cwd()) as unknown as Record<string, unknown>, {}],
962
+ [createGrepToolDefinition(process.cwd()) as unknown as Record<string, unknown>, {}],
963
+ [createFindToolDefinition(process.cwd()) as unknown as Record<string, unknown>, {}],
964
+ [createLsToolDefinition(process.cwd()) as unknown as Record<string, unknown>, {}],
965
+ [
966
+ createEditToolDefinition(process.cwd()) as unknown as Record<string, unknown>,
967
+ { headerOnly: true, statFromArgs: countEditsLineDiff, hideCollapsedResult: true },
968
+ ],
969
+ [
970
+ createWriteToolDefinition(process.cwd()) as unknown as Record<string, unknown>,
971
+ { headerOnly: true, statFromMap: true, hideCollapsedResult: true },
972
+ ],
973
+ ];
974
+ for (const [baseTool, opts] of toolOverrides) {
975
+ pi.registerTool({
976
+ ...(baseTool as object),
977
+ renderCall: wrapToolRenderCall(baseTool, opts),
978
+ ...(opts.hideCollapsedResult
979
+ ? { renderResult: wrapToolRenderResult(baseTool) }
980
+ : {}),
981
+ } as never);
982
+ }
983
+
984
+ // Read the previous file content right before the write executes, so the
985
+ // header can show a git-style `+N -M` stat.
986
+ pi.on("tool_call", async (event, ctx) => {
987
+ if (event.toolName !== "write") return;
988
+ if (!config.writeStat) return;
989
+ if (writeStats.has(event.toolCallId)) return;
990
+
991
+ const input = event.input as { path?: unknown; file_path?: unknown; content?: unknown };
992
+ const rawPath =
993
+ typeof input.path === "string"
994
+ ? input.path
995
+ : typeof input.file_path === "string"
996
+ ? input.file_path
997
+ : undefined;
998
+ if (typeof rawPath !== "string" || typeof input.content !== "string") return;
999
+
1000
+ let absolutePath: string;
1001
+ try {
1002
+ absolutePath = resolvePath(ctx.cwd ?? process.cwd(), rawPath);
1003
+ } catch {
1004
+ return;
1005
+ }
1006
+
1007
+ let oldText: string | undefined;
1008
+ try {
1009
+ if (existsSync(absolutePath)) {
1010
+ if (statSync(absolutePath).size > MAX_WRITE_FILE_BYTES) return; // too big to diff
1011
+ oldText = await readFile(absolutePath, "utf8");
1012
+ }
1013
+ } catch {
1014
+ return;
1015
+ }
1016
+
1017
+ writeStats.set(event.toolCallId, countLineDiff(oldText, input.content));
1018
+ const row = writeRows.get(event.toolCallId);
1019
+ try {
1020
+ row?.invalidateRef?.(); // rerender the header with the stat
1021
+ } catch {
1022
+ // The row will pick the stat up on its next natural render.
1023
+ }
1024
+ });
1025
+
1026
+ // Persist the write stat into the tool result's details so restored
1027
+ // sessions keep showing `+N -M` in history (the pre-execution snapshot
1028
+ // only exists live).
1029
+ pi.on("tool_result", async (event) => {
1030
+ if (event.toolName !== "write") return;
1031
+ const stat = writeStats.get(event.toolCallId);
1032
+ if (!stat) return;
1033
+ if (event.isError) {
1034
+ writeStats.delete(event.toolCallId); // nothing was changed
1035
+ return;
1036
+ }
1037
+ const details = (event.details ?? {}) as Record<string, unknown>;
1038
+ return { details: { ...details, smartFold: stat } };
1039
+ });
1040
+
84
1041
  // -------------------------------------------------------------------------
85
- // 3) /fold command — inspect and toggle at runtime.
1042
+ // 4) /fold — /config-style settings list.
86
1043
  // -------------------------------------------------------------------------
87
- const persist = (next: SmartFoldConfig): void => {
88
- if (extensionDir) saveConfig(extensionDir, next);
1044
+ const applyThinking = (mode: ThinkingMode, ctx: CtxLike): void => {
1045
+ config.thinking = mode;
1046
+ persist();
1047
+ publishState();
1048
+ applyHiddenLabel(ctx);
1049
+ };
1050
+
1051
+ const applyExpandAll = (on: boolean, ctx: CtxLike): void => {
1052
+ expandAllThinking = on;
1053
+ publishState();
1054
+ applyHiddenLabel(ctx); // re-renders every message through the transformer
1055
+ };
1056
+
1057
+ const applyToolsFold = (on: boolean, ctx: CtxLike): void => {
1058
+ config.toolsFold = on;
1059
+ persist();
1060
+ if (ctx.hasUI) {
1061
+ try {
1062
+ ctx.ui.setToolsExpanded?.(!on); // on => collapsed
1063
+ } catch {
1064
+ // Applied on next session_start anyway.
1065
+ }
1066
+ }
1067
+ };
1068
+
1069
+ const applyWriteStat = (on: boolean): void => {
1070
+ config.writeStat = on;
1071
+ persist();
1072
+ };
1073
+
1074
+ const applyWriteCollapsed = (style: WriteCollapsedStyle): void => {
1075
+ config.writeCollapsed = style;
1076
+ persist();
89
1077
  };
90
1078
 
91
1079
  const statusLine = (): string =>
92
- `smart-fold — thinking: ${thinkingFold ? "折叠(folded)" : "展开(full)"} · 工具输出: ${
93
- config.toolsFold ? "启动时折叠(collapsed)" : "启动时展开(expanded)"
94
- }`;
1080
+ `smart-fold — thinking: ${config.thinking} · 全部展开: ${
1081
+ expandAllThinking ? "开" : "关"
1082
+ } · 工具输出: ${config.toolsFold ? "启动时折叠" : "启动时展开"} · write/edit统计: ${
1083
+ config.writeStat ? "开" : "关"
1084
+ } · write/edit折叠: ${config.writeCollapsed === "header" ? "仅首行" : "保留预览"}`;
1085
+
1086
+ const openSettings = async (ctx: CtxLike & {
1087
+ ui: CtxLike["ui"] & {
1088
+ custom<T>(
1089
+ factory: (
1090
+ tui: { requestRender(): void },
1091
+ theme: ThemeLike,
1092
+ keybindings: unknown,
1093
+ done: (result: T) => void,
1094
+ ) => Component & { handleInput?(data: string): void },
1095
+ ): Promise<T>;
1096
+ };
1097
+ }): Promise<void> => {
1098
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
1099
+ const items: SettingItem[] = [
1100
+ {
1101
+ id: "thinking",
1102
+ label: "Thinking 折叠",
1103
+ currentValue: config.thinking,
1104
+ values: ["smart", "tail", "full", "off"],
1105
+ description:
1106
+ "smart: 思考中显示 Thinking…(时长)+滚动结尾,点击在 滚动结尾↔完整全文 间切换(完整显示时计时行固定在底部);结束后折叠为 Thought for 时长,点击一次展开/收起",
1107
+ },
1108
+ {
1109
+ id: "expand",
1110
+ label: "展开全部思考",
1111
+ currentValue: expandAllThinking ? "on" : "off",
1112
+ values: ["on", "off"],
1113
+ description: "临时展开/折叠全部已完成的思考(备用;日常用点击单块展开,不持久化)",
1114
+ },
1115
+ {
1116
+ id: "tools",
1117
+ label: "工具输出折叠",
1118
+ currentValue: config.toolsFold ? "on" : "off",
1119
+ values: ["on", "off"],
1120
+ description: "会话启动/恢复时自动折叠全部工具输出(ctrl+o 随时切换)",
1121
+ },
1122
+ {
1123
+ id: "writeStat",
1124
+ label: "Write/Edit 增删统计",
1125
+ currentValue: config.writeStat ? "on" : "off",
1126
+ values: ["on", "off"],
1127
+ description:
1128
+ "write/edit 首行追加绿色 +新增 / 红色 -删除 行数(write 与写入前文件对比,edit 与原文片段对比)",
1129
+ },
1130
+ {
1131
+ id: "writeCollapsed",
1132
+ label: "Write/Edit 折叠样式",
1133
+ currentValue: config.writeCollapsed,
1134
+ values: ["header", "preview"],
1135
+ description: "header: 折叠时只显示首行;preview: 保留内容/差异预览(pi 默认)",
1136
+ },
1137
+ ];
1138
+
1139
+ const list = new SettingsList(
1140
+ items,
1141
+ items.length + 2,
1142
+ getSettingsListTheme(),
1143
+ (id, value) => {
1144
+ if (id === "thinking") applyThinking(value as ThinkingMode, ctx);
1145
+ else if (id === "expand") applyExpandAll(value === "on", ctx);
1146
+ else if (id === "tools") applyToolsFold(value === "on", ctx);
1147
+ else if (id === "writeStat") applyWriteStat(value === "on");
1148
+ else if (id === "writeCollapsed") applyWriteCollapsed(value as WriteCollapsedStyle);
1149
+ },
1150
+ () => done(undefined),
1151
+ );
1152
+
1153
+ const container = new Container();
1154
+ container.addChild(new Text(theme.fg("accent", theme.bold("smart-fold 设置")), 1, 1));
1155
+ container.addChild(list);
1156
+
1157
+ return {
1158
+ render: (width: number) => container.render(width),
1159
+ invalidate: () => container.invalidate(),
1160
+ handleInput: (data: string) => {
1161
+ list.handleInput?.(data);
1162
+ tui.requestRender();
1163
+ },
1164
+ };
1165
+ });
1166
+ };
95
1167
 
96
1168
  pi.registerCommand("fold", {
97
- description: "smart-fold: 查看/切换 thinking 折叠与工具输出折叠 (on|off)",
1169
+ description: "smart-fold 设置(选择式界面):thinking 折叠 / 展开 / 工具输出 / write 统计",
1170
+ getArgumentCompletions: (prefix: string) => {
1171
+ const make = (values: string[]) => values.map((value) => ({ value, label: value }));
1172
+ const trimmed = prefix.trimStart();
1173
+ const spaceIndex = trimmed.indexOf(" ");
1174
+ if (spaceIndex === -1) {
1175
+ const hits = ["thinking", "expand", "tools", "writestat", "writecollapsed"].filter(
1176
+ (option) => option.startsWith(trimmed.toLowerCase()),
1177
+ );
1178
+ return hits.length > 0 ? make(hits) : null;
1179
+ }
1180
+ const head = trimmed.slice(0, spaceIndex).toLowerCase();
1181
+ const sub = trimmed.slice(spaceIndex + 1).toLowerCase();
1182
+ const options: Record<string, string[]> = {
1183
+ thinking: ["smart", "tail", "full", "off"],
1184
+ expand: ["on", "off"],
1185
+ tools: ["on", "off"],
1186
+ writestat: ["on", "off"],
1187
+ writecollapsed: ["header", "preview"],
1188
+ };
1189
+ const hits = (options[head] ?? []).filter((value) => value.startsWith(sub));
1190
+ return hits.length > 0 ? make(hits) : null;
1191
+ },
98
1192
  handler: async (args, ctx) => {
1193
+ const scoped = ctx as unknown as CtxLike;
99
1194
  const parts = args.trim().split(/\s+/).filter(Boolean);
1195
+
1196
+ if (parts.length === 0) {
1197
+ if (ctx.mode === "tui") {
1198
+ await openSettings(scoped as never);
1199
+ return;
1200
+ }
1201
+ ctx.ui.notify(statusLine(), "info");
1202
+ return;
1203
+ }
1204
+
100
1205
  const target = parts[0]?.toLowerCase();
101
1206
  const value = parts[1]?.toLowerCase();
102
1207
 
103
1208
  if (target === "thinking") {
104
- if (value !== "on" && value !== "off") {
105
- ctx.ui.notify("用法: /fold thinking on|off", "warning");
1209
+ let mode: ThinkingMode | undefined;
1210
+ if (value === "on" || value === "smart") mode = "smart";
1211
+ else if (value === "tail" || value === "fold") mode = "tail";
1212
+ else if (value === "full") mode = "full";
1213
+ else if (value === "off" || value === "none") mode = "off";
1214
+ if (!mode) {
1215
+ ctx.ui.notify("用法: /fold thinking smart|tail|full|off", "warning");
106
1216
  return;
107
1217
  }
108
- thinkingFold = value === "on";
109
- config.thinkingFold = thinkingFold;
110
- persist(config);
111
- if (ctx.mode === "tui") {
112
- try {
113
- // Force every rendered assistant message to rebuild through the
114
- // markdown transformer so the toggle applies to history too.
115
- // (Resets a custom hidden-thinking label to its default, if any.)
116
- ctx.ui.setHiddenThinkingLabel();
117
- } catch {
118
- // New content still picks the change up on next render.
119
- }
1218
+ applyThinking(mode, scoped);
1219
+ ctx.ui.notify(`thinking: ${mode} — 已保存`, "info");
1220
+ return;
1221
+ }
1222
+
1223
+ if (target === "expand") {
1224
+ if (value !== "on" && value !== "off") {
1225
+ ctx.ui.notify("用法: /fold expand on|off(备用;日常单击思考块切换展开/收起)", "warning");
1226
+ return;
120
1227
  }
121
- ctx.ui.notify(`thinking: ${thinkingFold ? "折叠(folded)" : "展开(full)"} — 已保存`, "info");
1228
+ applyExpandAll(value === "on", scoped);
1229
+ ctx.ui.notify(`展开全部思考: ${value === "on" ? "开" : "关"}`, "info");
122
1230
  return;
123
1231
  }
124
1232
 
@@ -127,28 +1235,44 @@ export default function smartFold(pi: ExtensionAPI): void {
127
1235
  ctx.ui.notify("用法: /fold tools on|off", "warning");
128
1236
  return;
129
1237
  }
130
- config.toolsFold = value === "on";
131
- persist(config);
132
- if (ctx.hasUI) {
133
- try {
134
- ctx.ui.setToolsExpanded(!config.toolsFold); // on => collapsed
135
- } catch {
136
- // Applied on next session_start anyway.
137
- }
138
- }
1238
+ applyToolsFold(value === "on", scoped);
139
1239
  ctx.ui.notify(
140
- `工具输出: ${config.toolsFold ? "折叠(collapsed)" : "展开(expanded)"} — 已保存`,
1240
+ `工具输出: ${value === "on" ? "启动时折叠" : "启动时展开"} — 已保存`,
141
1241
  "info",
142
1242
  );
143
1243
  return;
144
1244
  }
145
1245
 
146
- if (target !== undefined) {
147
- ctx.ui.notify("用法: /fold [thinking|tools] [on|off]", "warning");
1246
+ if (target === "writestat" || target === "write-stat" || target === "editstat") {
1247
+ if (value !== "on" && value !== "off") {
1248
+ ctx.ui.notify("用法: /fold writestat on|off", "warning");
1249
+ return;
1250
+ }
1251
+ applyWriteStat(value === "on");
1252
+ ctx.ui.notify(`write/edit 增删统计: ${value === "on" ? "开" : "关"} — 已保存`, "info");
1253
+ return;
1254
+ }
1255
+
1256
+ if (target === "writecollapsed" || target === "write-collapsed") {
1257
+ const style: WriteCollapsedStyle | undefined =
1258
+ value === "header" || value === "on"
1259
+ ? "header"
1260
+ : value === "preview"
1261
+ ? "preview"
1262
+ : undefined;
1263
+ if (!style) {
1264
+ ctx.ui.notify("用法: /fold writecollapsed header|preview", "warning");
1265
+ return;
1266
+ }
1267
+ applyWriteCollapsed(style);
1268
+ ctx.ui.notify(`write 折叠样式: ${style} — 已保存`, "info");
148
1269
  return;
149
1270
  }
150
1271
 
151
- ctx.ui.notify(statusLine(), "info");
1272
+ ctx.ui.notify(
1273
+ "用法: /fold(打开设置)或 /fold [thinking|expand|tools|writestat|writecollapsed] <值>",
1274
+ "warning",
1275
+ );
152
1276
  },
153
1277
  });
154
1278
  }