pi-compact-transcript 0.2.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,23 @@
1
1
  # pi-compact-transcript
2
2
 
3
- A compact transcript extension for [pi](https://pi.dev):
4
-
5
- - Collapses hidden thinking blocks into `Thinking...` and consecutive thinking blocks into `Thinking... (Nx)`.
6
- - Adds a short, visible next-step summary to collapsed thinking rows without exposing raw chain-of-thought.
7
- - Collapses built-in tool calls/results into one-line previews.
8
- - Consecutive tool uses in the same agent run are summarized as `Used N tools <latest tool preview>`.
3
+ A compact transcript extension for [pi](https://pi.dev).
4
+
5
+ | Without the extension | With the extension |
6
+ |---|---|
7
+ | ![pi transcript without the extension](docs/plugin_disabled.png) | ![pi transcript with the extension](docs/plugin_enabled.png) |
8
+
9
+ What it does:
10
+
11
+ - Collapses every tool call/result into a one-line preview, including custom/external tools added by other extensions.
12
+ - Each tool row carries a status diamond: blinking gray `◆` while the tool runs, solid green `◆` on success, solid red `◆` on failure.
13
+ - Consecutive uses of the same tool coalesce into a single row, e.g. `◆ 4× read src/foo.ts {12 lines · 8s}`; a different tool starts a new row with its own diamond.
14
+ - Tool rows show durations when they take a second or longer; grouped rows show the total.
15
+ - Failed tools always get their own visible row (red diamond) and end the current burst.
16
+ - Tool rows render dimmed so assistant text stands out.
17
+ - Hidden thinking blocks are suppressed entirely — no `Thinking...` markers.
18
+ - Each agent run ends with a one-line summary, e.g. `Read 6 files, edited 2, ran 3 commands, 1 failed · 42s`.
19
+ - Unknown tools preview their most meaningful string argument (command, code, query, path, url, ...) instead of dumping raw JSON args.
20
+ - Expanded tool output still falls back to pi's original renderer, so you can use pi's normal tool expansion when details matter.
9
21
  - Minimizes vertical space so long agent runs do not scroll away as quickly.
10
22
 
11
23
  ## Install from GitHub
@@ -47,8 +59,20 @@ The extension works best with hidden thinking and no output padding:
47
59
 
48
60
  Set these in `~/.pi/agent/settings.json`, or use `/settings` in pi.
49
61
 
62
+ Thinking suppression only applies when `hideThinkingBlock` is on; with it off, pi renders thinking traces normally.
63
+
64
+ ## Commands
65
+
66
+ ```text
67
+ /compact-transcript # toggle on/off
68
+ /compact-transcript on|off # set explicitly
69
+ /compact-transcript status # show current state
70
+ ```
71
+
72
+ Toggling applies to the visible transcript immediately — existing rows re-render, no reload needed. The pre-0.5 mode names (`balanced`, `aggressive`, `debug`, `disabled`) are accepted as legacy aliases for `on`/`off`.
73
+
50
74
  ## Notes
51
75
 
52
- This extension overrides the built-in tool renderers for `bash`, `read`, `write`, `edit`, `grep`, `find`, and `ls`. It delegates execution to pi's original built-in tools; only display is changed.
76
+ This extension changes display only. Tool execution is still handled by pi and any other extensions that registered or override tools.
53
77
 
54
- The consecutive-thinking collapse and compact self-rendered tool rows use pi's current internal TUI components. If pi changes those internal paths in a future release, the extension falls back to the normal hidden-thinking/tool rendering behavior.
78
+ For built-in and extension tools, compact rendering uses pi's public exported TUI components where available. The cross-tool burst compaction and thinking suppression still rely on pi's current TUI component internals, so if pi changes those internals in a future release, the extension falls back to pi's normal rendering behavior for the affected rows.
@@ -1,57 +1,134 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import {
3
- createBashTool,
4
- createEditTool,
5
- createFindTool,
6
- createGrepTool,
7
- createLsTool,
8
- createReadTool,
9
- createWriteTool,
10
- } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { AssistantMessageComponent, ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
11
3
  import { Markdown, Spacer, Text } from "@earendil-works/pi-tui";
12
- import { createRequire } from "node:module";
13
4
  import { homedir } from "node:os";
14
- import { dirname, join } from "node:path";
15
- import { pathToFileURL } from "node:url";
16
5
 
17
- type BuiltInName = "bash" | "read" | "write" | "edit" | "grep" | "find" | "ls";
6
+ // Older versions of this extension wrote a footer status under this key; it is
7
+ // kept only to clear that status once per session for users upgrading in place.
8
+ const STATUS_KEY = "compact-transcript";
9
+ const CONFIG_ENTRY_TYPE = "compact-transcript-config";
10
+ const SUMMARY_ENTRY_TYPE = "compact-transcript-summary";
11
+
12
+ const MIN_PREVIEW_WIDTH = 20;
13
+ const MAX_PREVIEW_WIDTH = 104;
14
+ // Leave room for pi's row gutter/padding so compact lines never wrap.
15
+ const PREVIEW_MARGIN = 6;
16
+ const BLINK_INTERVAL_MS = 400;
17
+ // Status marker is two cells wide ("◆ ").
18
+ const MARKER_WIDTH = 2;
19
+
20
+ type CompactTranscriptConfig = {
21
+ enabled: boolean;
22
+ };
18
23
 
19
24
  type ToolInfo = {
20
25
  id: string;
21
26
  name: string;
22
27
  args: any;
23
28
  preview: string;
29
+ hidden?: boolean;
30
+ running?: boolean;
31
+ burstCount?: number;
32
+ startedAt?: number;
33
+ durationMs?: number;
34
+ burstDurationMs?: number;
24
35
  result?: string;
36
+ isError?: boolean;
25
37
  invalidate?: () => void;
26
38
  };
27
39
 
28
- const BUILT_INS: BuiltInName[] = ["bash", "read", "write", "edit", "grep", "find", "ls"];
29
- const LABEL = "Thinking...";
40
+ type RunStats = {
41
+ startedAt: number;
42
+ toolCount: number;
43
+ readFiles: Set<string>;
44
+ editFiles: Set<string>;
45
+ commandCount: number;
46
+ otherCount: number;
47
+ failedCount: number;
48
+ };
30
49
 
31
- let currentTools: ToolInfo[] = [];
32
- let toolsById = new Map<string, ToolInfo>();
50
+ type SummaryData = {
51
+ reads: number;
52
+ edits: number;
53
+ commands: number;
54
+ others: number;
55
+ failed: number;
56
+ durationMs: number;
57
+ };
33
58
 
34
- const toolCache = new Map<string, ReturnType<typeof createBuiltInTools>>();
59
+ type RuntimeState = {
60
+ config: CompactTranscriptConfig;
61
+ toolsById: Map<string, ToolInfo>;
62
+ currentBurst: ToolInfo[];
63
+ hiddenToolIds: Set<string>;
64
+ runningToolIds: Set<string>;
65
+ agentActive: boolean;
66
+ blinkOn: boolean;
67
+ blinkTimer?: ReturnType<typeof setInterval>;
68
+ runStats: RunStats;
69
+ // Live transcript components, so toggling can re-render existing rows.
70
+ toolComponents: Set<any>;
71
+ assistantComponents: Set<any>;
72
+ currentTheme?: Theme;
73
+ };
35
74
 
36
- function createBuiltInTools(cwd: string) {
75
+ const DEFAULT_CONFIG: CompactTranscriptConfig = {
76
+ enabled: true,
77
+ };
78
+
79
+ const STATE_KEY = Symbol.for("pi-compact-transcript.state");
80
+ const TOOL_PATCH_KEY = Symbol.for("pi-compact-transcript.tool-patch");
81
+ const ASSISTANT_PATCH_KEY = Symbol.for("pi-compact-transcript.assistant-patch");
82
+
83
+ function newRunStats(): RunStats {
37
84
  return {
38
- bash: createBashTool(cwd),
39
- read: createReadTool(cwd),
40
- write: createWriteTool(cwd),
41
- edit: createEditTool(cwd),
42
- grep: createGrepTool(cwd),
43
- find: createFindTool(cwd),
44
- ls: createLsTool(cwd),
85
+ startedAt: Date.now(),
86
+ toolCount: 0,
87
+ readFiles: new Set(),
88
+ editFiles: new Set(),
89
+ commandCount: 0,
90
+ otherCount: 0,
91
+ failedCount: 0,
45
92
  };
46
93
  }
47
94
 
48
- function getBuiltInTools(cwd: string) {
49
- let tools = toolCache.get(cwd);
50
- if (!tools) {
51
- tools = createBuiltInTools(cwd);
52
- toolCache.set(cwd, tools);
95
+ function normalizeConfig(input: unknown): CompactTranscriptConfig {
96
+ const source = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
97
+ let enabled = DEFAULT_CONFIG.enabled;
98
+ if (typeof source.enabled === "boolean") {
99
+ enabled = source.enabled;
100
+ } else if (typeof source.mode === "string") {
101
+ // Pre-0.5 config persisted a mode string instead of an enabled flag.
102
+ enabled = source.mode !== "disabled" && source.mode !== "off";
53
103
  }
54
- return tools;
104
+ return { enabled };
105
+ }
106
+
107
+ function getState(): RuntimeState {
108
+ const globalWithState = globalThis as typeof globalThis & { [STATE_KEY]?: RuntimeState };
109
+ globalWithState[STATE_KEY] ??= {
110
+ config: { ...DEFAULT_CONFIG },
111
+ toolsById: new Map(),
112
+ currentBurst: [],
113
+ hiddenToolIds: new Set(),
114
+ runningToolIds: new Set(),
115
+ agentActive: false,
116
+ blinkOn: true,
117
+ runStats: newRunStats(),
118
+ toolComponents: new Set(),
119
+ assistantComponents: new Set(),
120
+ };
121
+ return globalWithState[STATE_KEY]!;
122
+ }
123
+
124
+ const state = getState();
125
+
126
+ function isEnabled(): boolean {
127
+ return state.config.enabled;
128
+ }
129
+
130
+ function isNonEmptyString(value: unknown): value is string {
131
+ return typeof value === "string" && value.trim().length > 0;
55
132
  }
56
133
 
57
134
  function shortenPath(path: unknown): string {
@@ -66,7 +143,11 @@ function oneLine(value: unknown): string {
66
143
  .trim();
67
144
  }
68
145
 
69
- function limitPlain(text: string, max = Math.max(20, (process.stdout.columns || 100) - 6)): string {
146
+ function previewWidth(base = process.stdout.columns || 100): number {
147
+ return Math.max(MIN_PREVIEW_WIDTH, Math.min(MAX_PREVIEW_WIDTH, base - PREVIEW_MARGIN));
148
+ }
149
+
150
+ function limitPlain(text: string, max = previewWidth()): string {
70
151
  const clean = oneLine(text);
71
152
  if (clean.length <= max) return clean;
72
153
  return `${clean.slice(0, Math.max(0, max - 1))}…`;
@@ -76,6 +157,43 @@ function quote(s: string): string {
76
157
  return JSON.stringify(s);
77
158
  }
78
159
 
160
+ function safeJson(value: unknown): string {
161
+ try {
162
+ return JSON.stringify(value ?? {});
163
+ } catch {
164
+ return String(value);
165
+ }
166
+ }
167
+
168
+ // Sub-second durations render as "" so fast tools stay clutter-free.
169
+ function formatDuration(ms: number): string {
170
+ if (!Number.isFinite(ms) || ms < 1000) return "";
171
+ const totalSeconds = Math.round(ms / 1000);
172
+ if (totalSeconds < 60) return `${totalSeconds}s`;
173
+ const minutes = Math.floor(totalSeconds / 60);
174
+ const seconds = totalSeconds % 60;
175
+ return seconds ? `${minutes}m${seconds}s` : `${minutes}m`;
176
+ }
177
+
178
+ // Checked in order; earlier keys are more likely to be the argument a human
179
+ // would recognize the call by.
180
+ const PREFERRED_ARG_KEYS = [
181
+ "command",
182
+ "code",
183
+ "query",
184
+ "pattern",
185
+ "path",
186
+ "file_path",
187
+ "filePath",
188
+ "file",
189
+ "url",
190
+ "prompt",
191
+ "text",
192
+ "description",
193
+ "name",
194
+ ];
195
+ const PATH_ARG_KEYS = new Set(["path", "file_path", "filePath", "file"]);
196
+
79
197
  function previewFor(name: string, args: any): string {
80
198
  switch (name) {
81
199
  case "bash":
@@ -106,41 +224,97 @@ function previewFor(name: string, args: any): string {
106
224
  return `find ${args?.pattern ? quote(String(args.pattern)) : "..."} ${shortenPath(args?.path) || "."}`;
107
225
  case "ls":
108
226
  return `ls ${shortenPath(args?.path) || "."}`;
109
- default:
110
- return `${name} ${oneLine(JSON.stringify(args ?? {}))}`;
227
+ default: {
228
+ // Unknown tools: show the most meaningful string argument instead of
229
+ // dumping the whole args object as JSON.
230
+ if (args && typeof args === "object") {
231
+ for (const key of PREFERRED_ARG_KEYS) {
232
+ const value = (args as Record<string, unknown>)[key];
233
+ if (isNonEmptyString(value)) {
234
+ const rendered = PATH_ARG_KEYS.has(key) ? shortenPath(value) : oneLine(value);
235
+ return `${name} ${rendered}`;
236
+ }
237
+ }
238
+ const firstString = Object.values(args).find(isNonEmptyString);
239
+ if (firstString) return `${name} ${oneLine(firstString)}`;
240
+ }
241
+ return `${name} ${safeJson(args ?? {})}`;
242
+ }
111
243
  }
112
244
  }
113
245
 
114
- function resultPreview(result: any): string {
246
+ function resultPreview(result: any, isPartial = false): string {
115
247
  const text = Array.isArray(result?.content)
116
248
  ? result.content.find((c: any) => c?.type === "text" && typeof c.text === "string")?.text
117
249
  : undefined;
118
- if (!text) return "";
250
+ if (!text) return isPartial ? "running" : "";
119
251
  const lines = String(text).trim().split("\n").filter(Boolean);
120
- if (lines.length === 0) return "";
252
+ if (lines.length === 0) return isPartial ? "running" : "";
121
253
  if (lines.length === 1) return lines[0];
122
254
  return `${lines.length} lines`;
123
255
  }
124
256
 
125
- function toolCallName(block: any): string {
126
- return block?.name ?? block?.toolName ?? block?.tool_name ?? block?.tool?.name ?? "tool";
257
+ function resetToolRun() {
258
+ state.toolsById = new Map();
259
+ state.currentBurst = [];
260
+ state.hiddenToolIds = new Set();
261
+ state.runningToolIds = new Set();
262
+ stopBlinkTimer();
263
+ }
264
+
265
+ function captureTheme(ctx: ExtensionContext) {
266
+ state.currentTheme = ctx.ui.theme;
127
267
  }
128
268
 
129
- function toolCallArgs(block: any): any {
130
- return block?.args ?? block?.input ?? block?.arguments ?? block?.tool?.args ?? {};
269
+ function setToolHidden(info: ToolInfo, hidden: boolean) {
270
+ info.hidden = hidden;
271
+ if (hidden) state.hiddenToolIds.add(info.id);
272
+ else state.hiddenToolIds.delete(info.id);
131
273
  }
132
274
 
133
- function resetToolRun() {
134
- currentTools = [];
135
- toolsById = new Map();
275
+ function applyResult(info: ToolInfo, result: any, isError: boolean, isPartial: boolean) {
276
+ const suffix = resultPreview(result, isPartial);
277
+ if (suffix) info.result = suffix;
278
+ if (isError) {
279
+ info.isError = true;
280
+ // A failed tool always gets its own visible row, even if a burst had
281
+ // hidden it; render it as itself rather than as a burst summary.
282
+ info.burstCount = 1;
283
+ setToolHidden(info, false);
284
+ }
285
+ }
286
+
287
+ function ensureBlinkTimer() {
288
+ if (state.blinkTimer || state.runningToolIds.size === 0) return;
289
+ state.blinkTimer = setInterval(() => {
290
+ if (state.runningToolIds.size === 0) {
291
+ stopBlinkTimer();
292
+ return;
293
+ }
294
+ state.blinkOn = !state.blinkOn;
295
+ for (const id of state.runningToolIds) state.toolsById.get(id)?.invalidate?.();
296
+ }, BLINK_INTERVAL_MS);
297
+ state.blinkTimer.unref?.();
298
+ }
299
+
300
+ function stopBlinkTimer() {
301
+ if (state.blinkTimer) clearInterval(state.blinkTimer);
302
+ state.blinkTimer = undefined;
303
+ state.blinkOn = true;
304
+ }
305
+
306
+ function statusMarker(theme: Theme, opts: { running?: boolean; isError?: boolean; hasResult?: boolean }): string {
307
+ if (opts.isError) return theme.fg("error", "◆ ");
308
+ if (opts.running) return theme.fg("dim", state.blinkOn ? "◆ " : "◇ ");
309
+ if (opts.hasResult) return theme.fg("success", "◆ ");
310
+ return theme.fg("dim", "◆ ");
136
311
  }
137
312
 
138
313
  function upsertToolInfo(id: string, name: string, args: any, invalidate?: () => void): ToolInfo {
139
- let info = toolsById.get(id);
314
+ let info = state.toolsById.get(id);
140
315
  if (!info) {
141
316
  info = { id, name, args, preview: previewFor(name, args) };
142
- toolsById.set(id, info);
143
- if (!currentTools.some((tool) => tool.id === id)) currentTools.push(info);
317
+ state.toolsById.set(id, info);
144
318
  }
145
319
  info.name = name;
146
320
  info.args = args;
@@ -149,212 +323,446 @@ function upsertToolInfo(id: string, name: string, args: any, invalidate?: () =>
149
323
  return info;
150
324
  }
151
325
 
152
- function compactToolLine(toolCallId: string, name: string, args: any, theme: any, invalidate?: () => void, result?: any): string {
153
- const info = upsertToolInfo(toolCallId, name, args, invalidate);
154
- const suffix = resultPreview(result);
155
- if (suffix) info.result = suffix;
326
+ function recordToolStart(name: string, args: any) {
327
+ const base = name.split(".").pop() ?? name;
328
+ state.runStats.toolCount++;
329
+ if (base === "read") {
330
+ if (isNonEmptyString(args?.path)) state.runStats.readFiles.add(args.path);
331
+ } else if (base === "edit" || base === "write") {
332
+ if (isNonEmptyString(args?.path)) state.runStats.editFiles.add(args.path);
333
+ } else if (base === "bash") {
334
+ state.runStats.commandCount++;
335
+ } else {
336
+ state.runStats.otherCount++;
337
+ }
338
+ }
156
339
 
157
- if (currentTools[currentTools.length - 1]?.id !== toolCallId) return "";
340
+ function joinBurst(info: ToolInfo) {
341
+ const previous = state.currentBurst[state.currentBurst.length - 1];
342
+
343
+ if (!isEnabled()) {
344
+ state.currentBurst = [];
345
+ return;
346
+ }
347
+
348
+ // Bursts only group repeats of the same tool; a different tool starts a
349
+ // fresh row (and leaves the previous row visible with its count).
350
+ if (state.currentBurst.length && state.currentBurst[state.currentBurst.length - 1].name !== info.name) {
351
+ state.currentBurst = [];
352
+ }
158
353
 
159
- const details = info.result ? `${info.preview} {${oneLine(info.result)}}` : info.preview;
160
- if (currentTools.length === 1) return theme.fg("muted", limitPlain(details));
161
- const prefix = `Used ${currentTools.length} tools `;
162
- return theme.fg("toolTitle", prefix) + theme.fg("muted", limitPlain(details, Math.max(20, (process.stdout.columns || 100) - prefix.length - 6)));
354
+ if (!state.currentBurst.some((tool) => tool.id === info.id)) state.currentBurst.push(info);
355
+ for (const tool of state.currentBurst.slice(0, -1)) setToolHidden(tool, true);
356
+ info.burstCount = state.currentBurst.length;
357
+ previous?.invalidate?.();
163
358
  }
164
359
 
165
- function nextStepSummary(content: any[], fromIndex: number): string {
166
- const tools = content.slice(fromIndex + 1).filter((c: any) => c?.type === "toolCall");
167
- if (tools.length === 0) return " Next, I’ll continue from this reasoning and respond with the result. I’ll keep the visible output brief.";
360
+ function beginTool(id: string, name: string, args: any) {
361
+ const info = upsertToolInfo(id, name, args);
362
+ setToolHidden(info, false);
363
+ info.burstCount = 1;
364
+ info.running = true;
365
+ info.isError = false;
366
+ info.startedAt = Date.now();
367
+ state.runningToolIds.add(id);
368
+ ensureBlinkTimer();
369
+ recordToolStart(name, args);
370
+ joinBurst(info);
371
+ // The component may already be on screen from argument streaming; repaint
372
+ // now so the running marker and burst count appear immediately instead of
373
+ // waiting for the next result event or blink tick.
374
+ info.invalidate?.();
375
+ }
168
376
 
169
- const first = tools[0];
170
- const preview = limitPlain(previewFor(toolCallName(first), toolCallArgs(first)), 90);
171
- if (tools.length === 1) {
172
- return ` Next, I’ll use ${preview}. I’ll inspect the result and decide the next visible step from there.`;
377
+ // A tool row rendered without a live tool_execution_start event — pi is
378
+ // rebuilding chat history after resume/reload. Reconstruct burst grouping so
379
+ // scrollback coalesces the same way the live view did, but skip timers,
380
+ // stats, and running state.
381
+ function hydrateTool(id: string, name: string, args: any, isError: boolean): ToolInfo {
382
+ const info = upsertToolInfo(id, name, args);
383
+ setToolHidden(info, false);
384
+ info.burstCount = 1;
385
+ if (isError) {
386
+ info.isError = true;
387
+ state.currentBurst = [];
388
+ return info;
173
389
  }
174
- const last = tools[tools.length - 1];
175
- const lastPreview = limitPlain(previewFor(toolCallName(last), toolCallArgs(last)), 70);
176
- return ` Next, I’ll run ${tools.length} tool calls, starting with ${preview}. I’ll use the latest result to continue, ending this batch around ${lastPreview}.`;
390
+ joinBurst(info);
391
+ return info;
177
392
  }
178
393
 
179
- async function patchInternalRenderers() {
180
- try {
181
- const require = createRequire(import.meta.url);
182
- const piMain = require.resolve("@earendil-works/pi-coding-agent");
183
- const distDir = dirname(piMain);
184
- const assistantModule = await import(
185
- pathToFileURL(join(distDir, "modes/interactive/components/assistant-message.js")).href
186
- );
187
- const toolExecutionModule = await import(
188
- pathToFileURL(join(distDir, "modes/interactive/components/tool-execution.js")).href
394
+ function updateToolResult(toolCallId: string, result: any, isError = false, isPartial = false) {
395
+ if (!isPartial) {
396
+ state.runningToolIds.delete(toolCallId);
397
+ if (state.runningToolIds.size === 0) stopBlinkTimer();
398
+ }
399
+ const info = state.toolsById.get(toolCallId);
400
+ if (!info) return;
401
+ if (!isPartial) {
402
+ info.running = false;
403
+ if (info.startedAt) info.durationMs = Date.now() - info.startedAt;
404
+ if (state.currentBurst.includes(info) && state.currentBurst.length > 1) {
405
+ info.burstDurationMs = state.currentBurst.reduce((total, tool) => total + (tool.durationMs ?? 0), 0);
406
+ }
407
+ if (isError) state.runStats.failedCount++;
408
+ }
409
+ applyResult(info, result, isError, isPartial);
410
+ // A failure ends the current burst so the red row stays visible and the
411
+ // next tool starts a fresh count.
412
+ if (isError) state.currentBurst = [];
413
+ info.invalidate?.();
414
+ }
415
+
416
+ function compactToolLine(
417
+ toolCallId: string,
418
+ name: string,
419
+ args: any,
420
+ theme: Theme,
421
+ invalidate?: () => void,
422
+ result?: any,
423
+ isError = false,
424
+ isPartial = false,
425
+ ): string {
426
+ if (!state.toolsById.has(toolCallId)) hydrateTool(toolCallId, name, args, isError);
427
+ const info = upsertToolInfo(toolCallId, name, args, invalidate);
428
+ applyResult(info, result, isError, isPartial);
429
+ if (info.hidden) return "";
430
+
431
+ const isBurst = (info.burstCount ?? 1) > 1;
432
+ const durationText = formatDuration((isBurst ? (info.burstDurationMs ?? info.durationMs) : info.durationMs) ?? 0);
433
+ const inner = [info.result ? oneLine(info.result) : "", durationText].filter(Boolean).join(" · ");
434
+ const status = inner ? ` {${inner}}` : info.running ? " {running}" : "";
435
+ const details = `${info.preview}${status}`;
436
+ const marker = statusMarker(theme, {
437
+ running: info.running,
438
+ isError: info.isError,
439
+ hasResult: result != null || !!info.result,
440
+ });
441
+ if (!isBurst) {
442
+ return marker + theme.fg("muted", limitPlain(details));
443
+ }
444
+
445
+ const prefix = `${info.burstCount}× `;
446
+ const budget = previewWidth((process.stdout.columns || 100) - prefix.length - MARKER_WIDTH);
447
+ return marker + theme.fg("muted", prefix + limitPlain(details, budget));
448
+ }
449
+
450
+ function patchToolExecutionComponent() {
451
+ const proto = ToolExecutionComponent.prototype as any;
452
+ if (typeof proto.updateDisplay !== "function" || typeof proto.render !== "function") return;
453
+ const existing = proto[TOOL_PATCH_KEY] as
454
+ | { originalUpdateDisplay: (...args: any[]) => any; originalRender: (...args: any[]) => any }
455
+ | undefined;
456
+ const originalUpdateDisplay = existing?.originalUpdateDisplay ?? proto.updateDisplay;
457
+ const originalRender = existing?.originalRender ?? proto.render;
458
+
459
+ proto.updateDisplay = function patchedUpdateDisplay() {
460
+ if (!this.toolCallId || !this.toolName || !this.selfRenderContainer || typeof this.selfRenderContainer.clear !== "function") {
461
+ this.__compactTranscriptForceSelf = false;
462
+ this.__compactTranscriptHidden = false;
463
+ return originalUpdateDisplay.call(this);
464
+ }
465
+ state.toolComponents.add(this);
466
+
467
+ const invalidate = () => {
468
+ this.invalidate();
469
+ this.ui?.requestRender?.();
470
+ };
471
+ // Must run before upsertToolInfo, which would make the row look
472
+ // already-known and skip burst reconstruction for rehydrated history.
473
+ if (!state.toolsById.has(this.toolCallId)) {
474
+ hydrateTool(this.toolCallId, this.toolName, this.args, this.result?.isError ?? false);
475
+ }
476
+ const info = upsertToolInfo(this.toolCallId, this.toolName, this.args, invalidate);
477
+ applyResult(info, this.result, this.result?.isError ?? false, this.isPartial);
478
+
479
+ if (!isEnabled() || this.expanded) {
480
+ setToolHidden(info, false);
481
+ this.__compactTranscriptForceSelf = false;
482
+ this.__compactTranscriptHidden = false;
483
+ return originalUpdateDisplay.call(this);
484
+ }
485
+
486
+ this.__compactTranscriptForceSelf = true;
487
+ this.__compactTranscriptHidden = false;
488
+ this.selfRenderContainer.clear();
489
+ for (const image of this.imageComponents ?? []) this.removeChild?.(image);
490
+ for (const spacer of this.imageSpacers ?? []) this.removeChild?.(spacer);
491
+ this.imageComponents = [];
492
+ this.imageSpacers = [];
493
+
494
+ const theme = state.currentTheme;
495
+ if (!theme) {
496
+ this.__compactTranscriptForceSelf = false;
497
+ this.__compactTranscriptHidden = false;
498
+ return originalUpdateDisplay.call(this);
499
+ }
500
+
501
+ const line = compactToolLine(
502
+ this.toolCallId,
503
+ this.toolName,
504
+ this.args,
505
+ theme,
506
+ invalidate,
507
+ this.result,
508
+ this.result?.isError ?? false,
509
+ this.isPartial,
189
510
  );
190
- const themeModule = await import(pathToFileURL(join(distDir, "modes/interactive/theme/theme.js")).href);
191
-
192
- const ToolExecutionComponent = toolExecutionModule.ToolExecutionComponent;
193
- if (ToolExecutionComponent?.prototype && !ToolExecutionComponent.prototype.__compactTranscriptPatched) {
194
- const originalRender = ToolExecutionComponent.prototype.render;
195
- ToolExecutionComponent.prototype.updateDisplay = function () {
196
- this.__compactTranscriptForceSelf = true;
197
- this.hideComponent = false;
198
- this.selfRenderContainer.clear();
199
- this.imageComponents = [];
200
- this.imageSpacers = [];
201
-
202
- const line = compactToolLine(
203
- this.toolCallId,
204
- this.toolName,
205
- this.args,
206
- themeModule.theme,
207
- () => {
208
- this.invalidate();
209
- this.ui?.requestRender?.();
210
- },
211
- this.result,
212
- );
213
-
214
- if (!line) {
215
- this.hideComponent = true;
216
- return;
217
- }
218
511
 
219
- this.selfRenderContainer.addChild(new Text(line, 0, 0));
220
- };
221
- ToolExecutionComponent.prototype.render = function (width: number) {
222
- if (this.hideComponent) return [];
223
- if (this.__compactTranscriptForceSelf) return this.selfRenderContainer.render(width);
224
- return originalRender.call(this, width);
225
- };
226
- ToolExecutionComponent.prototype.__compactTranscriptPatched = true;
512
+ if (!line) {
513
+ this.__compactTranscriptHidden = true;
514
+ return;
227
515
  }
228
516
 
229
- const Component = assistantModule.AssistantMessageComponent;
230
- if (!Component?.prototype || Component.prototype.__compactTranscriptPatched) return;
231
- const original = Component.prototype.updateContent;
517
+ this.selfRenderContainer.addChild(new Text(line, 0, 0));
518
+ };
232
519
 
233
- Component.prototype.updateContent = function (message: any) {
234
- if (!this.hideThinkingBlock) return original.call(this, message);
520
+ proto.render = function patchedRender(width: number) {
521
+ if (this.hideComponent || this.__compactTranscriptHidden) return [];
522
+ if (this.__compactTranscriptForceSelf) return this.selfRenderContainer.render(width);
523
+ return originalRender.call(this, width);
524
+ };
235
525
 
236
- this.lastMessage = message;
237
- this.contentContainer.clear();
526
+ proto[TOOL_PATCH_KEY] = { originalUpdateDisplay, originalRender };
527
+ }
238
528
 
239
- const visible = message.content.filter(
240
- (c: any) => (c.type === "text" && c.text?.trim()) || (c.type === "thinking" && c.thinking?.trim()),
241
- );
242
- if (visible.length) this.contentContainer.addChild(new Spacer(1));
529
+ function patchAssistantMessageComponent() {
530
+ const proto = AssistantMessageComponent.prototype as any;
531
+ if (typeof proto.updateContent !== "function") return;
532
+ const existing = proto[ASSISTANT_PATCH_KEY] as { originalUpdateContent: (...args: any[]) => any } | undefined;
533
+ const originalUpdateContent = existing?.originalUpdateContent ?? proto.updateContent;
243
534
 
244
- for (let i = 0; i < message.content.length; i++) {
245
- const content = message.content[i];
246
- if (content.type === "text" && content.text?.trim()) {
247
- this.contentContainer.addChild(new Markdown(content.text.trim(), this.outputPad, 0, this.markdownTheme));
248
- continue;
249
- }
250
- if (content.type !== "thinking" || !content.thinking?.trim()) continue;
251
-
252
- let count = 1;
253
- while (
254
- message.content[i + count]?.type === "thinking" &&
255
- message.content[i + count]?.thinking?.trim()
256
- ) {
257
- count++;
258
- }
259
- i += count - 1;
260
-
261
- const label = count > 1 ? `${LABEL} (${count}x)` : LABEL;
262
- const summary = nextStepSummary(message.content, i);
263
- this.contentContainer.addChild(
264
- new Text(themeModule.theme.italic(themeModule.theme.fg("thinkingText", limitPlain(`${label}${summary}`))), this.outputPad, 0),
265
- );
266
-
267
- const hasVisibleAfter = message.content
268
- .slice(i + 1)
269
- .some((c: any) => (c.type === "text" && c.text?.trim()) || (c.type === "thinking" && c.thinking?.trim()));
270
- if (hasVisibleAfter) this.contentContainer.addChild(new Spacer(1));
271
- }
535
+ proto.updateContent = function patchedUpdateContent(message: any) {
536
+ state.assistantComponents.add(this);
537
+ if (!isEnabled() || !this.hideThinkingBlock || !Array.isArray(message?.content)) {
538
+ return originalUpdateContent.call(this, message);
539
+ }
540
+ if (!this.contentContainer || typeof this.contentContainer.clear !== "function") {
541
+ return originalUpdateContent.call(this, message);
542
+ }
272
543
 
273
- this.hasToolCalls = message.content.some((c: any) => c.type === "toolCall");
274
- };
275
- Component.prototype.__compactTranscriptPatched = true;
276
- } catch {
277
- // Best-effort: older/bundled pi builds may not expose internal component modules.
278
- }
544
+ this.lastMessage = message;
545
+ this.contentContainer.clear();
546
+ this.hasToolCalls = message.content.some((c: any) => c.type === "toolCall");
547
+
548
+ // Thinking blocks are suppressed entirely; only real text is rendered.
549
+ const texts = message.content.filter((c: any) => c.type === "text" && c.text?.trim());
550
+ if (texts.length === 0) return;
551
+
552
+ // Assistant text ends a tool burst. The live path also does this via
553
+ // message_update events; doing it here too keeps hydrated history from
554
+ // grouping tool rows across turn boundaries.
555
+ state.currentBurst = [];
556
+
557
+ this.contentContainer.addChild(new Spacer(1));
558
+ for (const content of texts) {
559
+ this.contentContainer.addChild(new Markdown(content.text.trim(), this.outputPad, 0, this.markdownTheme));
560
+ }
561
+ };
562
+
563
+ proto[ASSISTANT_PATCH_KEY] = { originalUpdateContent };
279
564
  }
280
565
 
281
- export default async function (pi: ExtensionAPI) {
282
- await patchInternalRenderers();
283
- let consecutiveThinking = 0;
566
+ function patchRenderers() {
567
+ patchToolExecutionComponent();
568
+ patchAssistantMessageComponent();
569
+ }
284
570
 
285
- function thinkingLabel() {
286
- return consecutiveThinking > 1 ? `${LABEL} (${consecutiveThinking}x)` : LABEL;
571
+ // Re-render every transcript row we have touched so toggling applies to the
572
+ // visible transcript immediately instead of only to future rows.
573
+ function refreshTranscript() {
574
+ let ui: any;
575
+ for (const component of state.toolComponents) {
576
+ try {
577
+ // ToolExecutionComponent.invalidate() re-runs updateDisplay.
578
+ component.invalidate?.();
579
+ ui ??= component.ui;
580
+ } catch {
581
+ state.toolComponents.delete(component);
582
+ }
287
583
  }
288
-
289
- function resetTools() {
290
- resetToolRun();
584
+ for (const component of state.assistantComponents) {
585
+ try {
586
+ if (component.lastMessage) component.updateContent?.(component.lastMessage);
587
+ component.invalidate?.();
588
+ } catch {
589
+ state.assistantComponents.delete(component);
590
+ }
291
591
  }
592
+ ui?.requestRender?.();
593
+ }
594
+
595
+ function summaryLine(data: SummaryData): string {
596
+ const plural = (count: number) => (count === 1 ? "" : "s");
597
+ const parts: string[] = [];
598
+ if (data.reads) parts.push(`read ${data.reads} file${plural(data.reads)}`);
599
+ if (data.edits) parts.push(`edited ${data.edits} file${plural(data.edits)}`);
600
+ if (data.commands) parts.push(`ran ${data.commands} command${plural(data.commands)}`);
601
+ if (data.others) parts.push(`${data.others} other tool${plural(data.others)}`);
602
+ if (data.failed) parts.push(`${data.failed} failed`);
603
+ if (parts.length === 0) return "";
604
+ const text = parts.join(", ");
605
+ const capitalized = text[0].toUpperCase() + text.slice(1);
606
+ const duration = formatDuration(data.durationMs);
607
+ return duration ? `${capitalized} · ${duration}` : capitalized;
608
+ }
609
+
610
+ function normalizeSummary(input: unknown): SummaryData {
611
+ const source = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
612
+ const num = (value: unknown) => (typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0);
613
+ return {
614
+ reads: num(source.reads),
615
+ edits: num(source.edits),
616
+ commands: num(source.commands),
617
+ others: num(source.others),
618
+ failed: num(source.failed),
619
+ durationMs: num(source.durationMs),
620
+ };
621
+ }
292
622
 
293
- function applyThinkingLabel(ctx: ExtensionContext) {
294
- ctx.ui.setHiddenThinkingLabel(thinkingLabel());
623
+ function appendRunSummary(pi: ExtensionAPI) {
624
+ const stats = state.runStats;
625
+ // Single-tool runs are self-evident from the transcript; a summary line
626
+ // would just repeat the row above it.
627
+ if (!isEnabled() || stats.toolCount < 2) return;
628
+ const data: SummaryData = {
629
+ reads: stats.readFiles.size,
630
+ edits: stats.editFiles.size,
631
+ commands: stats.commandCount,
632
+ others: stats.otherCount,
633
+ failed: stats.failedCount,
634
+ durationMs: Date.now() - stats.startedAt,
635
+ };
636
+ pi.appendEntry(SUMMARY_ENTRY_TYPE, data);
637
+ }
638
+
639
+ function restoreConfigFromBranch(ctx: ExtensionContext) {
640
+ let nextConfig = { ...DEFAULT_CONFIG };
641
+ for (const entry of ctx.sessionManager.getBranch()) {
642
+ if (entry.type === "custom" && entry.customType === CONFIG_ENTRY_TYPE) {
643
+ nextConfig = normalizeConfig(entry.data);
644
+ }
295
645
  }
646
+ state.config = nextConfig;
647
+ }
648
+
649
+ function setEnabled(enabled: boolean, pi: ExtensionAPI, ctx: ExtensionContext) {
650
+ state.config.enabled = enabled;
651
+ pi.appendEntry(CONFIG_ENTRY_TYPE, { ...state.config });
652
+ refreshTranscript();
653
+ ctx.ui.notify(`Compact transcript: ${enabled ? "on" : "off"}`, "info");
654
+ }
655
+
656
+ function parseBoolean(value: string | undefined): boolean | undefined {
657
+ if (!value) return undefined;
658
+ const normalized = value.trim().toLowerCase();
659
+ if (["on", "true", "yes", "1", "enabled"].includes(normalized)) return true;
660
+ if (["off", "false", "no", "0", "disabled"].includes(normalized)) return false;
661
+ return undefined;
662
+ }
663
+
664
+ function registerCommand(pi: ExtensionAPI) {
665
+ pi.registerCommand("compact-transcript", {
666
+ description: "Toggle compact transcript rendering (on/off).",
667
+ getArgumentCompletions(prefix: string) {
668
+ return ["on", "off", "status"]
669
+ .filter((value) => value.startsWith(prefix.trimStart()))
670
+ .map((value) => ({ value, label: value }));
671
+ },
672
+ handler: async (args, ctx) => {
673
+ captureTheme(ctx);
674
+ const trimmed = args.trim().toLowerCase();
675
+ if (trimmed === "status") {
676
+ ctx.ui.notify(`Compact transcript: ${isEnabled() ? "on" : "off"}`, "info");
677
+ return;
678
+ }
679
+ if (!trimmed) {
680
+ setEnabled(!isEnabled(), pi, ctx);
681
+ return;
682
+ }
683
+ // on/off, plus pre-0.5 mode names as legacy aliases.
684
+ const legacyOn = ["balanced", "aggressive", "debug"].includes(trimmed);
685
+ const parsed = legacyOn ? true : parseBoolean(trimmed);
686
+ if (parsed !== undefined) {
687
+ setEnabled(parsed, pi, ctx);
688
+ return;
689
+ }
690
+ ctx.ui.notify(`Unknown option "${trimmed}". Usage: /compact-transcript [on|off|status]`, "error");
691
+ },
692
+ });
693
+ }
694
+
695
+ export default function compactTranscript(pi: ExtensionAPI) {
696
+ patchRenderers();
697
+ registerCommand(pi);
698
+
699
+ pi.registerEntryRenderer<SummaryData>(SUMMARY_ENTRY_TYPE, (entry, _options, theme) => {
700
+ const line = summaryLine(normalizeSummary(entry.data));
701
+ if (!line) return undefined;
702
+ return new Text(theme.fg("muted", line), 0, 0);
703
+ });
296
704
 
297
705
  pi.on("session_start", async (_event, ctx) => {
298
- applyThinkingLabel(ctx);
299
- ctx.ui.setWorkingMessage(LABEL);
706
+ restoreConfigFromBranch(ctx);
707
+ captureTheme(ctx);
708
+ // Drop all per-tool state and component registries from the previous
709
+ // session; stale burst counts otherwise corrupt the rebuilt transcript
710
+ // after resume or /reload.
711
+ resetToolRun();
712
+ state.runStats = newRunStats();
713
+ state.toolComponents = new Set();
714
+ state.assistantComponents = new Set();
715
+ ctx.ui.setWorkingMessage();
716
+ // Clear any footer status left behind by pre-0.4 versions of this extension.
717
+ ctx.ui.setStatus(STATUS_KEY, undefined);
718
+ });
719
+
720
+ pi.on("session_shutdown", async (_event, ctx) => {
721
+ stopBlinkTimer();
722
+ ctx.ui.setStatus(STATUS_KEY, undefined);
723
+ ctx.ui.setWorkingMessage();
724
+ });
725
+
726
+ pi.on("agent_start", (_event, ctx) => {
727
+ state.agentActive = true;
728
+ captureTheme(ctx);
729
+ resetToolRun();
730
+ state.runStats = newRunStats();
300
731
  });
301
732
 
302
- pi.on("agent_start", () => {
303
- resetTools();
733
+ pi.on("agent_end", (_event, _ctx) => {
734
+ state.agentActive = false;
735
+ state.currentBurst = [];
736
+ state.runningToolIds.clear();
737
+ stopBlinkTimer();
738
+ appendRunSummary(pi);
304
739
  });
305
740
 
306
741
  pi.on("turn_start", (_event, ctx) => {
307
- consecutiveThinking = 0;
308
- applyThinkingLabel(ctx);
742
+ captureTheme(ctx);
309
743
  });
310
744
 
311
- pi.on("message_update", (event: any, ctx) => {
745
+ pi.on("message_update", (event, ctx) => {
746
+ captureTheme(ctx);
312
747
  const type = event.assistantMessageEvent?.type;
313
- if (type === "thinking_start") {
314
- consecutiveThinking++;
315
- applyThinkingLabel(ctx);
316
- return;
317
- }
318
- if (type && !type.startsWith("thinking_")) {
319
- consecutiveThinking = 0;
320
- applyThinkingLabel(ctx);
321
- if (type === "text_delta" || type === "text_start") resetTools();
748
+ if (type === "text_delta" || type === "text_start") {
749
+ // Visible assistant text ends the current tool burst.
750
+ state.currentBurst = [];
322
751
  }
323
752
  });
324
753
 
325
- pi.on("tool_execution_start", (event: any) => {
326
- const previous = currentTools[currentTools.length - 1];
327
- upsertToolInfo(event.toolCallId, event.toolName, event.args);
328
- previous?.invalidate?.();
754
+ pi.on("tool_execution_start", (event, ctx) => {
755
+ captureTheme(ctx);
756
+ beginTool(event.toolCallId, event.toolName, event.args);
329
757
  });
330
758
 
331
- pi.on("tool_execution_end", (event: any) => {
332
- const info = toolsById.get(event.toolCallId);
333
- if (!info) return;
334
- const suffix = resultPreview(event.result);
335
- if (suffix) info.result = suffix;
336
- info.invalidate?.();
759
+ pi.on("tool_execution_update", (event, ctx) => {
760
+ captureTheme(ctx);
761
+ updateToolResult(event.toolCallId, event.partialResult, false, true);
337
762
  });
338
763
 
339
- for (const name of BUILT_INS) {
340
- const base = getBuiltInTools(process.cwd())[name] as any;
341
- pi.registerTool({
342
- ...base,
343
- name,
344
- label: name,
345
- renderShell: "self",
346
- async execute(toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any, ctx: ExtensionContext) {
347
- const tool = (getBuiltInTools(ctx.cwd) as any)[name];
348
- return tool.execute(toolCallId, params, signal, onUpdate, ctx);
349
- },
350
- renderCall(args: any, theme: any, context: any) {
351
- const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
352
- text.setText(compactToolLine(context.toolCallId, name, args, theme, context.invalidate));
353
- return text;
354
- },
355
- renderResult(_result: any, _options: any, _theme: any, _context: any) {
356
- return new Text("", 0, 0);
357
- },
358
- });
359
- }
764
+ pi.on("tool_execution_end", (event, ctx) => {
765
+ captureTheme(ctx);
766
+ updateToolResult(event.toolCallId, event.result, event.isError, false);
767
+ });
360
768
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-compact-transcript",
3
- "version": "0.2.1",
3
+ "version": "0.5.0",
4
4
  "description": "A compact transcript extension for pi: collapsed thinking summaries and one-line tool previews.",
5
5
  "type": "module",
6
6
  "license": "MIT",