@clapecho233/pi-smart-fold 0.1.0 → 0.1.1

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