@clapecho233/pi-smart-fold 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -16
- package/index.ts +1109 -61
- package/lib/config.ts +55 -7
- package/lib/fold.ts +223 -7
- package/lib/thinking.ts +194 -0
- package/package.json +3 -2
- package/test/click-sim.mjs +224 -0
- package/test/fold.test.mjs +413 -6
package/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.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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
|
|
18
|
-
* /fold thinking
|
|
19
|
-
* /fold
|
|
20
|
-
*
|
|
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 {
|
|
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 {
|
|
28
|
-
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
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)
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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)
|
|
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)
|
|
716
|
+
// 3) Tool calls: collapsed lines truncated with an ellipsis; write/edit
|
|
717
|
+
// headers gain git-style `+N -M` stats.
|
|
86
718
|
// -------------------------------------------------------------------------
|
|
87
|
-
|
|
88
|
-
|
|
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: ${
|
|
93
|
-
|
|
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
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`工具输出: ${
|
|
1164
|
+
`工具输出: ${value === "on" ? "启动时折叠" : "启动时展开"} — 已保存`,
|
|
141
1165
|
"info",
|
|
142
1166
|
);
|
|
143
1167
|
return;
|
|
144
1168
|
}
|
|
145
1169
|
|
|
146
|
-
if (target
|
|
147
|
-
|
|
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(
|
|
1196
|
+
ctx.ui.notify(
|
|
1197
|
+
"用法: /fold(打开设置)或 /fold [thinking|expand|tools|writestat|writecollapsed] <值>",
|
|
1198
|
+
"warning",
|
|
1199
|
+
);
|
|
152
1200
|
},
|
|
153
1201
|
});
|
|
154
1202
|
}
|