pi-compact-transcript 0.2.1 → 0.4.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
@@ -3,9 +3,12 @@
3
3
  A compact transcript extension for [pi](https://pi.dev):
4
4
 
5
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>`.
6
+ - Shows only real collapsed thinking labels/counters; it does not invent next-step prose.
7
+ - Collapses tool calls/results into one-line previews, including custom/external tools added by other extensions.
8
+ - Consecutive non-mutating tool uses are summarized by count and kind, e.g. `Used 7 tools: 4 read, 2 grep, 1 bash · latest read src/foo.ts`.
9
+ - Mutating tools (`edit`, `write`, configured mutation tools, and destructive-looking `bash` commands) stay visible as transcript anchors and break tool bursts.
10
+ - Failed tools stay visible by default so errors are not hidden.
11
+ - Expanded tool output still falls back to pi's original renderer, so you can use pi's normal tool expansion when details matter.
9
12
  - Minimizes vertical space so long agent runs do not scroll away as quickly.
10
13
 
11
14
  ## Install from GitHub
@@ -47,8 +50,43 @@ The extension works best with hidden thinking and no output padding:
47
50
 
48
51
  Set these in `~/.pi/agent/settings.json`, or use `/settings` in pi.
49
52
 
53
+ ## Commands
54
+
55
+ ```text
56
+ /compact-transcript # open the settings-style panel
57
+ /compact-transcript status # same as above
58
+ /compact-transcript disabled|balanced|aggressive|debug
59
+ /compact-transcript custom-tools on|off
60
+ /compact-transcript failed on|off
61
+ /compact-transcript bash-mutations on|off
62
+ /compact-transcript always-show <tool[,tool]|/regex/|clear>
63
+ /compact-transcript mutation-tools <tool[,tool]|/regex/|clear>
64
+ /compact-transcript template <tool|/regex/> <template|clear>
65
+ ```
66
+
67
+ In interactive pi, `/compact-transcript` opens a focused settings-style panel in the editor area. Press `Enter`/`Space` to toggle values, edit list/template rows inline, and `Esc` to close it.
68
+
69
+ Modes:
70
+
71
+ - `balanced` (default): compact non-mutating bursts, keep mutations/failures visible, and preserve normal hidden-thinking markers.
72
+ - `aggressive`: shorter previews and coalesced thinking-only markers.
73
+ - `debug`: compact rows but do not hide earlier burst rows; useful when tuning rules.
74
+ - `disabled`: fully disable compact-transcript rendering for future rows. (`off` is accepted as a legacy alias.)
75
+
76
+ Switching modes never changes the other toggles (custom tools, failed tools, bash anchors); those are independent settings.
77
+
78
+ Preview templates support `{name}`, `{args}`, top-level argument names like `{path}` and `{command}`, and nested fields like `{arg.query.text}`. In the panel, edit templates as semicolon-separated `tool=template` pairs, so template text itself cannot contain `;`.
79
+
80
+ Examples:
81
+
82
+ ```text
83
+ /compact-transcript always-show ask_user_question,/^deploy_/
84
+ /compact-transcript mutation-tools db_query,kubectl
85
+ /compact-transcript template fetch_content fetch {url}
86
+ ```
87
+
50
88
  ## Notes
51
89
 
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.
90
+ This extension changes display only. Tool execution is still handled by pi and any other extensions that registered or override tools.
53
91
 
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.
92
+ For built-in and extension tools, compact rendering uses pi's public exported TUI components where available. The cross-tool burst compaction and consecutive-thinking coalescing 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,146 @@
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";
11
- import { Markdown, Spacer, Text } from "@earendil-works/pi-tui";
12
- import { createRequire } from "node:module";
1
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { AssistantMessageComponent, getSettingsListTheme, ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
3
+ import { Container, Input, Markdown, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
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
+ const LABEL = "Thinking...";
7
+ // Older versions of this extension wrote a footer status under this key; it is
8
+ // kept only to clear that status once per session for users upgrading in place.
9
+ const STATUS_KEY = "compact-transcript";
10
+ const CONFIG_ENTRY_TYPE = "compact-transcript-config";
11
+
12
+ const BUILT_INS = new Set(["bash", "read", "write", "edit", "grep", "find", "ls"]);
13
+
14
+ const MIN_PREVIEW_WIDTH = 20;
15
+ const AGGRESSIVE_PREVIEW_WIDTH = 72;
16
+ const DEBUG_PREVIEW_WIDTH = 140;
17
+ const DEFAULT_PREVIEW_WIDTH = 104;
18
+ // Leave room for pi's row gutter/padding so compact lines never wrap.
19
+ const PREVIEW_MARGIN = 6;
20
+ const SETTINGS_LIST_HEIGHT = 9;
21
+
22
+ type Mode = "disabled" | "balanced" | "aggressive" | "debug";
23
+ // "off" is a legacy alias for "disabled" accepted from commands and persisted config.
24
+ type ModeInput = Mode | "off";
25
+ type ToolKind = "always" | "mutation" | "noise";
26
+
27
+ type CompactTranscriptConfig = {
28
+ mode: Mode;
29
+ compactCustomTools: boolean;
30
+ showFailedTools: boolean;
31
+ showBashMutations: boolean;
32
+ alwaysShowTools: string[];
33
+ mutationTools: string[];
34
+ previewTemplates: Record<string, string>;
35
+ };
18
36
 
19
37
  type ToolInfo = {
20
38
  id: string;
21
39
  name: string;
22
40
  args: any;
23
41
  preview: string;
42
+ kind: ToolKind;
43
+ hidden?: boolean;
44
+ burstCount?: number;
45
+ burstSummary?: string;
24
46
  result?: string;
47
+ isError?: boolean;
25
48
  invalidate?: () => void;
26
49
  };
27
50
 
28
- const BUILT_INS: BuiltInName[] = ["bash", "read", "write", "edit", "grep", "find", "ls"];
29
- const LABEL = "Thinking...";
51
+ type RuntimeState = {
52
+ config: CompactTranscriptConfig;
53
+ toolsById: Map<string, ToolInfo>;
54
+ currentNoiseBurst: ToolInfo[];
55
+ hiddenToolIds: Set<string>;
56
+ agentActive: boolean;
57
+ lastThinkingSignalComponent?: any;
58
+ thinkingSignalCount: number;
59
+ consecutiveThinking: number;
60
+ currentTheme?: Theme;
61
+ };
30
62
 
31
- let currentTools: ToolInfo[] = [];
32
- let toolsById = new Map<string, ToolInfo>();
63
+ const DEFAULT_CONFIG: CompactTranscriptConfig = {
64
+ mode: "balanced",
65
+ compactCustomTools: true,
66
+ showFailedTools: true,
67
+ showBashMutations: true,
68
+ alwaysShowTools: [],
69
+ mutationTools: [],
70
+ previewTemplates: {},
71
+ };
33
72
 
34
- const toolCache = new Map<string, ReturnType<typeof createBuiltInTools>>();
73
+ const STATE_KEY = Symbol.for("pi-compact-transcript.state");
74
+ const TOOL_PATCH_KEY = Symbol.for("pi-compact-transcript.tool-patch");
75
+ const ASSISTANT_PATCH_KEY = Symbol.for("pi-compact-transcript.assistant-patch");
35
76
 
36
- function createBuiltInTools(cwd: string) {
77
+ function cloneConfig(config: CompactTranscriptConfig): CompactTranscriptConfig {
37
78
  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),
79
+ mode: config.mode,
80
+ compactCustomTools: config.compactCustomTools,
81
+ showFailedTools: config.showFailedTools,
82
+ showBashMutations: config.showBashMutations,
83
+ alwaysShowTools: [...config.alwaysShowTools],
84
+ mutationTools: [...config.mutationTools],
85
+ previewTemplates: { ...config.previewTemplates },
45
86
  };
46
87
  }
47
88
 
48
- function getBuiltInTools(cwd: string) {
49
- let tools = toolCache.get(cwd);
50
- if (!tools) {
51
- tools = createBuiltInTools(cwd);
52
- toolCache.set(cwd, tools);
53
- }
54
- return tools;
89
+ function normalizeConfig(input: unknown): CompactTranscriptConfig {
90
+ const source = (input && typeof input === "object" ? input : {}) as Partial<
91
+ Omit<CompactTranscriptConfig, "mode"> & { mode: ModeInput }
92
+ >;
93
+ const rawMode = source.mode;
94
+ const mode: Mode =
95
+ rawMode === "off" || rawMode === "disabled"
96
+ ? "disabled"
97
+ : rawMode === "aggressive" || rawMode === "debug"
98
+ ? rawMode
99
+ : "balanced";
100
+ return {
101
+ mode,
102
+ compactCustomTools: typeof source.compactCustomTools === "boolean" ? source.compactCustomTools : DEFAULT_CONFIG.compactCustomTools,
103
+ showFailedTools: typeof source.showFailedTools === "boolean" ? source.showFailedTools : DEFAULT_CONFIG.showFailedTools,
104
+ showBashMutations: typeof source.showBashMutations === "boolean" ? source.showBashMutations : DEFAULT_CONFIG.showBashMutations,
105
+ alwaysShowTools: Array.isArray(source.alwaysShowTools) ? source.alwaysShowTools.filter(isNonEmptyString) : [],
106
+ mutationTools: Array.isArray(source.mutationTools) ? source.mutationTools.filter(isNonEmptyString) : [],
107
+ previewTemplates:
108
+ source.previewTemplates && typeof source.previewTemplates === "object" && !Array.isArray(source.previewTemplates)
109
+ ? Object.fromEntries(
110
+ Object.entries(source.previewTemplates).filter(
111
+ ([key, value]) => isNonEmptyString(key) && typeof value === "string",
112
+ ),
113
+ )
114
+ : {},
115
+ };
116
+ }
117
+
118
+ function getState(): RuntimeState {
119
+ const globalWithState = globalThis as typeof globalThis & { [STATE_KEY]?: RuntimeState };
120
+ globalWithState[STATE_KEY] ??= {
121
+ config: cloneConfig(DEFAULT_CONFIG),
122
+ toolsById: new Map(),
123
+ currentNoiseBurst: [],
124
+ hiddenToolIds: new Set(),
125
+ agentActive: false,
126
+ thinkingSignalCount: 0,
127
+ consecutiveThinking: 0,
128
+ };
129
+ return globalWithState[STATE_KEY]!;
130
+ }
131
+
132
+ const state = getState();
133
+
134
+ function isEnabled(): boolean {
135
+ return state.config.mode !== "disabled";
136
+ }
137
+
138
+ function isDebugMode(): boolean {
139
+ return state.config.mode === "debug";
140
+ }
141
+
142
+ function isNonEmptyString(value: unknown): value is string {
143
+ return typeof value === "string" && value.trim().length > 0;
55
144
  }
56
145
 
57
146
  function shortenPath(path: unknown): string {
@@ -66,7 +155,17 @@ function oneLine(value: unknown): string {
66
155
  .trim();
67
156
  }
68
157
 
69
- function limitPlain(text: string, max = Math.max(20, (process.stdout.columns || 100) - 6)): string {
158
+ function previewWidth(base = process.stdout.columns || 100): number {
159
+ const modeMax =
160
+ state.config.mode === "aggressive"
161
+ ? AGGRESSIVE_PREVIEW_WIDTH
162
+ : state.config.mode === "debug"
163
+ ? DEBUG_PREVIEW_WIDTH
164
+ : DEFAULT_PREVIEW_WIDTH;
165
+ return Math.max(MIN_PREVIEW_WIDTH, Math.min(modeMax, base - PREVIEW_MARGIN));
166
+ }
167
+
168
+ function limitPlain(text: string, max = previewWidth()): string {
70
169
  const clean = oneLine(text);
71
170
  if (clean.length <= max) return clean;
72
171
  return `${clean.slice(0, Math.max(0, max - 1))}…`;
@@ -76,7 +175,99 @@ function quote(s: string): string {
76
175
  return JSON.stringify(s);
77
176
  }
78
177
 
178
+ function safeJson(value: unknown): string {
179
+ try {
180
+ return JSON.stringify(value ?? {});
181
+ } catch {
182
+ return String(value);
183
+ }
184
+ }
185
+
186
+ function matchRule(rule: string, toolName: string): boolean {
187
+ const trimmed = rule.trim();
188
+ if (!trimmed) return false;
189
+ if (trimmed.startsWith("/") && trimmed.lastIndexOf("/") > 0) {
190
+ const lastSlash = trimmed.lastIndexOf("/");
191
+ const pattern = trimmed.slice(1, lastSlash);
192
+ const flags = trimmed.slice(lastSlash + 1);
193
+ try {
194
+ return new RegExp(pattern, flags).test(toolName);
195
+ } catch {
196
+ return false;
197
+ }
198
+ }
199
+ if (trimmed.includes("*")) {
200
+ const pattern = `^${trimmed.split("*").map(escapeRegExp).join(".*")}$`;
201
+ return new RegExp(pattern).test(toolName);
202
+ }
203
+ return toolName === trimmed || toolName.endsWith(`.${trimmed}`);
204
+ }
205
+
206
+ function escapeRegExp(value: string): string {
207
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
208
+ }
209
+
210
+ function matchesAnyRule(rules: string[], toolName: string): boolean {
211
+ return rules.some((rule) => matchRule(rule, toolName));
212
+ }
213
+
214
+ function isMutatingBash(command: unknown): boolean {
215
+ if (typeof command !== "string") return false;
216
+ const compact = command.replace(/#[^\n]*/g, "").trim();
217
+ if (!compact) return false;
218
+
219
+ // Obvious file/process mutations and package/install actions. This is intentionally
220
+ // conservative enough to keep anchors for risky commands without treating every
221
+ // shell pipeline as destructive.
222
+ return /(^|[;&|]\s*)(rm\b|mv\b|cp\b|mkdir\b|rmdir\b|touch\b|chmod\b|chown\b|ln\b|truncate\b|tee\b|npm\s+(i|install|uninstall|remove|publish)\b|pnpm\s+(i|install|add|remove)\b|yarn\s+(add|remove|install|publish)\b|bun\s+(add|remove|install)\b|pip\s+(install|uninstall)\b|git\s+(add|apply|commit|checkout|clean|merge|mv|pull|push|rebase|reset|restore|stash|switch)\b)/.test(
223
+ compact,
224
+ ) || /(^|[^2])>>?\s*[^&\s]/.test(compact);
225
+ }
226
+
227
+ function classifyTool(name: string, args: any): ToolKind {
228
+ if (matchesAnyRule(state.config.alwaysShowTools, name)) return "always";
229
+ if (!BUILT_INS.has(name) && !state.config.compactCustomTools) return "always";
230
+ if (name === "edit" || name === "write" || name.endsWith(".edit") || name.endsWith(".write")) return "mutation";
231
+ if (matchesAnyRule(state.config.mutationTools, name)) return "mutation";
232
+ if (state.config.showBashMutations && (name === "bash" || name.endsWith(".bash")) && isMutatingBash(args?.command)) {
233
+ return "mutation";
234
+ }
235
+ return "noise";
236
+ }
237
+
238
+ function findTemplate(name: string): string | undefined {
239
+ if (state.config.previewTemplates[name]) return state.config.previewTemplates[name];
240
+ for (const [rule, template] of Object.entries(state.config.previewTemplates)) {
241
+ if (matchRule(rule, name)) return template;
242
+ }
243
+ return undefined;
244
+ }
245
+
246
+ function getByPath(value: any, path: string): unknown {
247
+ let current = value;
248
+ for (const part of path.split(".")) {
249
+ if (!part) continue;
250
+ if (current == null || typeof current !== "object") return undefined;
251
+ current = current[part];
252
+ }
253
+ return current;
254
+ }
255
+
256
+ function renderTemplate(template: string, name: string, args: any): string {
257
+ return template.replace(/\{(?:arg\.)?([a-zA-Z0-9_.-]+)\}/g, (_match, key: string) => {
258
+ if (key === "name") return name;
259
+ if (key === "args") return safeJson(args);
260
+ const value = getByPath(args, key);
261
+ if (value === undefined || value === null) return "";
262
+ if (typeof value === "string") return value;
263
+ return safeJson(value);
264
+ });
265
+ }
266
+
79
267
  function previewFor(name: string, args: any): string {
268
+ const template = findTemplate(name);
269
+ if (template) return renderTemplate(template, name, args);
270
+
80
271
  switch (name) {
81
272
  case "bash":
82
273
  return `$ ${oneLine(args?.command || "...")}`;
@@ -107,254 +298,795 @@ function previewFor(name: string, args: any): string {
107
298
  case "ls":
108
299
  return `ls ${shortenPath(args?.path) || "."}`;
109
300
  default:
110
- return `${name} ${oneLine(JSON.stringify(args ?? {}))}`;
301
+ return `${name} ${safeJson(args ?? {})}`;
111
302
  }
112
303
  }
113
304
 
114
- function resultPreview(result: any): string {
305
+ function resultPreview(result: any, isPartial = false): string {
115
306
  const text = Array.isArray(result?.content)
116
307
  ? result.content.find((c: any) => c?.type === "text" && typeof c.text === "string")?.text
117
308
  : undefined;
118
- if (!text) return "";
309
+ if (!text) return isPartial ? "running" : "";
119
310
  const lines = String(text).trim().split("\n").filter(Boolean);
120
- if (lines.length === 0) return "";
311
+ if (lines.length === 0) return isPartial ? "running" : "";
121
312
  if (lines.length === 1) return lines[0];
122
313
  return `${lines.length} lines`;
123
314
  }
124
315
 
125
- function toolCallName(block: any): string {
126
- return block?.name ?? block?.toolName ?? block?.tool_name ?? block?.tool?.name ?? "tool";
316
+ function summarizeBurst(tools: ToolInfo[]): string {
317
+ const counts = new Map<string, number>();
318
+ for (const tool of tools) {
319
+ counts.set(tool.name, (counts.get(tool.name) ?? 0) + 1);
320
+ }
321
+ return [...counts.entries()]
322
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
323
+ .map(([name, count]) => `${count} ${name}`)
324
+ .join(", ");
127
325
  }
128
326
 
129
- function toolCallArgs(block: any): any {
130
- return block?.args ?? block?.input ?? block?.arguments ?? block?.tool?.args ?? {};
327
+ function resetToolRun() {
328
+ state.toolsById = new Map();
329
+ state.currentNoiseBurst = [];
330
+ state.hiddenToolIds = new Set();
131
331
  }
132
332
 
133
- function resetToolRun() {
134
- currentTools = [];
135
- toolsById = new Map();
333
+ function resetThinkingSignals() {
334
+ state.lastThinkingSignalComponent = undefined;
335
+ state.thinkingSignalCount = 0;
336
+ }
337
+
338
+ function captureTheme(ctx: ExtensionContext) {
339
+ state.currentTheme = ctx.ui.theme;
340
+ }
341
+
342
+ function setToolHidden(info: ToolInfo, hidden: boolean) {
343
+ info.hidden = hidden;
344
+ if (hidden) state.hiddenToolIds.add(info.id);
345
+ else state.hiddenToolIds.delete(info.id);
346
+ }
347
+
348
+ function applyResult(info: ToolInfo, result: any, isError: boolean, isPartial: boolean) {
349
+ const suffix = resultPreview(result, isPartial);
350
+ if (suffix) info.result = suffix;
351
+ if (isError) {
352
+ info.isError = true;
353
+ setToolHidden(info, false);
354
+ }
136
355
  }
137
356
 
138
357
  function upsertToolInfo(id: string, name: string, args: any, invalidate?: () => void): ToolInfo {
139
- let info = toolsById.get(id);
358
+ let info = state.toolsById.get(id);
140
359
  if (!info) {
141
- info = { id, name, args, preview: previewFor(name, args) };
142
- toolsById.set(id, info);
143
- if (!currentTools.some((tool) => tool.id === id)) currentTools.push(info);
360
+ info = { id, name, args, preview: previewFor(name, args), kind: classifyTool(name, args) };
361
+ state.toolsById.set(id, info);
144
362
  }
145
363
  info.name = name;
146
364
  info.args = args;
147
365
  info.preview = previewFor(name, args);
366
+ info.kind = classifyTool(name, args);
148
367
  if (invalidate) info.invalidate = invalidate;
149
368
  return info;
150
369
  }
151
370
 
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;
371
+ function beginTool(id: string, name: string, args: any) {
372
+ const previousNoise = state.currentNoiseBurst[state.currentNoiseBurst.length - 1];
373
+ const info = upsertToolInfo(id, name, args);
374
+ setToolHidden(info, false);
375
+ info.burstCount = 1;
376
+ info.burstSummary = undefined;
377
+
378
+ if (!isEnabled() || info.kind !== "noise") {
379
+ state.currentNoiseBurst = [];
380
+ return;
381
+ }
156
382
 
157
- if (currentTools[currentTools.length - 1]?.id !== toolCallId) return "";
383
+ if (!state.currentNoiseBurst.some((tool) => tool.id === id)) state.currentNoiseBurst.push(info);
384
+ const summary = summarizeBurst(state.currentNoiseBurst);
385
+ for (const tool of state.currentNoiseBurst) tool.burstSummary = summary;
386
+
387
+ if (!isDebugMode()) {
388
+ for (const tool of state.currentNoiseBurst.slice(0, -1)) setToolHidden(tool, true);
389
+ }
390
+ info.burstCount = state.currentNoiseBurst.length;
391
+ previousNoise?.invalidate?.();
392
+ }
158
393
 
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)));
394
+ function updateToolResult(toolCallId: string, result: any, isError = false, isPartial = false) {
395
+ const info = state.toolsById.get(toolCallId);
396
+ if (!info) return;
397
+ applyResult(info, result, isError, isPartial);
398
+ info.invalidate?.();
163
399
  }
164
400
 
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.";
401
+ function compactToolLine(
402
+ toolCallId: string,
403
+ name: string,
404
+ args: any,
405
+ theme: Theme,
406
+ invalidate?: () => void,
407
+ result?: any,
408
+ isError = false,
409
+ isPartial = false,
410
+ ): string {
411
+ let info = state.toolsById.get(toolCallId);
168
412
 
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.`;
413
+ // Hydration/resume path: compact the individual row, but do not mutate burst
414
+ // state or invalidate older rows while pi is rebuilding chat history.
415
+ if (!state.agentActive && !info) {
416
+ const suffix = resultPreview(result, isPartial);
417
+ const preview = previewFor(name, args);
418
+ const details = suffix ? `${preview} {${oneLine(suffix)}}` : preview;
419
+ const marker = classifyTool(name, args) === "mutation" ? theme.fg("warning", "◆ ") : "";
420
+ return marker + theme.fg("muted", limitPlain(details));
173
421
  }
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}.`;
422
+
423
+ info = upsertToolInfo(toolCallId, name, args, invalidate);
424
+ applyResult(info, result, isError, isPartial);
425
+ if (info.hidden) return "";
426
+
427
+ const status = info.result ? ` {${oneLine(info.result)}}` : isPartial ? " {running}" : "";
428
+ const details = `${info.preview}${status}`;
429
+ if (info.kind !== "noise" || (info.burstCount ?? 1) <= 1 || isDebugMode()) {
430
+ const marker = info.kind === "mutation" ? theme.fg("warning", "◆ ") : isDebugMode() ? theme.fg("dim", "· ") : "";
431
+ return marker + theme.fg("muted", limitPlain(details));
432
+ }
433
+
434
+ const summary = info.burstSummary || summarizeBurst(state.currentNoiseBurst);
435
+ const prefix = `Used ${info.burstCount} tools`;
436
+ const middle = summary ? `: ${summary} · latest ` : " ";
437
+ const prefixText = `${prefix}${middle}`;
438
+ return (
439
+ theme.fg("toolTitle", prefixText) +
440
+ theme.fg("muted", limitPlain(details, previewWidth((process.stdout.columns || 100) - prefixText.length)))
441
+ );
177
442
  }
178
443
 
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
444
+ function shouldUseOriginalToolRow(row: any, info: ToolInfo): boolean {
445
+ if (!isEnabled()) return true;
446
+ if (row.expanded) return true;
447
+ if (info.kind === "always") return true;
448
+ if (info.isError && state.config.showFailedTools) return true;
449
+ return false;
450
+ }
451
+
452
+ function patchToolExecutionComponent() {
453
+ const proto = ToolExecutionComponent.prototype as any;
454
+ if (typeof proto.updateDisplay !== "function" || typeof proto.render !== "function") return;
455
+ const existing = proto[TOOL_PATCH_KEY] as
456
+ | { originalUpdateDisplay: (...args: any[]) => any; originalRender: (...args: any[]) => any }
457
+ | undefined;
458
+ const originalUpdateDisplay = existing?.originalUpdateDisplay ?? proto.updateDisplay;
459
+ const originalRender = existing?.originalRender ?? proto.render;
460
+
461
+ proto.updateDisplay = function patchedUpdateDisplay() {
462
+ if (!this.toolCallId || !this.toolName || !this.selfRenderContainer || typeof this.selfRenderContainer.clear !== "function") {
463
+ this.__compactTranscriptForceSelf = false;
464
+ return originalUpdateDisplay.call(this);
465
+ }
466
+
467
+ const invalidate = () => {
468
+ this.invalidate();
469
+ this.ui?.requestRender?.();
470
+ };
471
+ const info = upsertToolInfo(this.toolCallId, this.toolName, this.args, invalidate);
472
+ applyResult(info, this.result, this.result?.isError ?? false, this.isPartial);
473
+
474
+ if (shouldUseOriginalToolRow(this, info)) {
475
+ setToolHidden(info, false);
476
+ this.__compactTranscriptForceSelf = false;
477
+ return originalUpdateDisplay.call(this);
478
+ }
479
+
480
+ this.__compactTranscriptForceSelf = true;
481
+ this.hideComponent = false;
482
+ this.selfRenderContainer.clear();
483
+ for (const image of this.imageComponents ?? []) this.removeChild?.(image);
484
+ for (const spacer of this.imageSpacers ?? []) this.removeChild?.(spacer);
485
+ this.imageComponents = [];
486
+ this.imageSpacers = [];
487
+
488
+ const theme = state.currentTheme;
489
+ if (!theme) {
490
+ this.__compactTranscriptForceSelf = false;
491
+ return originalUpdateDisplay.call(this);
492
+ }
493
+
494
+ const line = compactToolLine(
495
+ this.toolCallId,
496
+ this.toolName,
497
+ this.args,
498
+ theme,
499
+ invalidate,
500
+ this.result,
501
+ this.result?.isError ?? false,
502
+ this.isPartial,
189
503
  );
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
504
 
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;
505
+ if (!line) {
506
+ this.hideComponent = true;
507
+ return;
227
508
  }
228
509
 
229
- const Component = assistantModule.AssistantMessageComponent;
230
- if (!Component?.prototype || Component.prototype.__compactTranscriptPatched) return;
231
- const original = Component.prototype.updateContent;
510
+ this.selfRenderContainer.addChild(new Text(line, 0, 0));
511
+ };
232
512
 
233
- Component.prototype.updateContent = function (message: any) {
234
- if (!this.hideThinkingBlock) return original.call(this, message);
513
+ proto.render = function patchedRender(width: number) {
514
+ if (this.hideComponent) return [];
515
+ if (this.__compactTranscriptForceSelf) return this.selfRenderContainer.render(width);
516
+ return originalRender.call(this, width);
517
+ };
235
518
 
236
- this.lastMessage = message;
237
- this.contentContainer.clear();
519
+ proto[TOOL_PATCH_KEY] = { originalUpdateDisplay, originalRender };
520
+ }
238
521
 
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));
522
+ function patchAssistantMessageComponent() {
523
+ const proto = AssistantMessageComponent.prototype as any;
524
+ if (typeof proto.updateContent !== "function") return;
525
+ const existing = proto[ASSISTANT_PATCH_KEY] as { originalUpdateContent: (...args: any[]) => any } | undefined;
526
+ const originalUpdateContent = existing?.originalUpdateContent ?? proto.updateContent;
243
527
 
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++;
528
+ proto.updateContent = function patchedUpdateContent(message: any) {
529
+ if (!isEnabled() || !this.hideThinkingBlock || !Array.isArray(message?.content)) {
530
+ this.__compactTranscriptHiddenThinkingSignal = false;
531
+ return originalUpdateContent.call(this, message);
532
+ }
533
+ if (!this.contentContainer || typeof this.contentContainer.clear !== "function") {
534
+ this.__compactTranscriptHiddenThinkingSignal = false;
535
+ return originalUpdateContent.call(this, message);
536
+ }
537
+
538
+ this.lastMessage = message;
539
+ this.contentContainer.clear();
540
+
541
+ const hasText = message.content.some((c: any) => c.type === "text" && c.text?.trim());
542
+ const hasThinking = message.content.some((c: any) => c.type === "thinking" && c.thinking?.trim());
543
+ this.hasToolCalls = message.content.some((c: any) => c.type === "toolCall");
544
+
545
+ // Only aggressive mode coalesces thinking-only assistant messages across
546
+ // tool turns. Balanced mode should preserve normal hidden-thinking markers
547
+ // so users can still see where the model thought before tool use.
548
+ const thinkingSignal = state.agentActive && hasThinking && !hasText;
549
+ const coalesceThinkingSignals = state.config.mode === "aggressive";
550
+ if (!coalesceThinkingSignals) {
551
+ this.__compactTranscriptHiddenThinkingSignal = false;
552
+ if (!thinkingSignal && hasText) resetThinkingSignals();
553
+ } else {
554
+ if (this.__compactTranscriptHiddenThinkingSignal) return;
555
+ if (thinkingSignal && state.lastThinkingSignalComponent !== this) {
556
+ if (state.lastThinkingSignalComponent) {
557
+ state.lastThinkingSignalComponent.__compactTranscriptHiddenThinkingSignal = true;
558
+ state.lastThinkingSignalComponent.invalidate?.();
258
559
  }
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));
560
+ state.lastThinkingSignalComponent = this;
561
+ state.thinkingSignalCount++;
562
+ } else if (!thinkingSignal && hasText) {
563
+ resetThinkingSignals();
271
564
  }
565
+ }
272
566
 
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.
567
+ const visible = message.content.filter(
568
+ (c: any) => (c.type === "text" && c.text?.trim()) || (c.type === "thinking" && c.thinking?.trim()),
569
+ );
570
+ if (visible.length) this.contentContainer.addChild(new Spacer(1));
571
+
572
+ let renderedThinkingSignal = false;
573
+ for (let i = 0; i < message.content.length; i++) {
574
+ const content = message.content[i];
575
+ if (content.type === "text" && content.text?.trim()) {
576
+ this.contentContainer.addChild(new Markdown(content.text.trim(), this.outputPad, 0, this.markdownTheme));
577
+ continue;
578
+ }
579
+ if (content.type !== "thinking" || !content.thinking?.trim()) continue;
580
+
581
+ let count = 1;
582
+ while (message.content[i + count]?.type === "thinking" && message.content[i + count]?.thinking?.trim()) {
583
+ count++;
584
+ }
585
+ i += count - 1;
586
+
587
+ if (coalesceThinkingSignals && thinkingSignal) {
588
+ if (renderedThinkingSignal) continue;
589
+ count = Math.max(count, state.thinkingSignalCount);
590
+ renderedThinkingSignal = true;
591
+ }
592
+
593
+ const label = count > 1 ? `${LABEL} (${count}x)` : LABEL;
594
+ const theme = state.currentTheme;
595
+ if (theme) {
596
+ this.contentContainer.addChild(new Text(theme.italic(theme.fg("thinkingText", label)), this.outputPad, 0));
597
+ } else {
598
+ this.contentContainer.addChild(new Text(label, this.outputPad, 0));
599
+ }
600
+
601
+ const hasVisibleAfter = message.content
602
+ .slice(i + 1)
603
+ .some((c: any) => (c.type === "text" && c.text?.trim()) || (c.type === "thinking" && c.thinking?.trim()));
604
+ if (hasVisibleAfter) this.contentContainer.addChild(new Spacer(1));
605
+ }
606
+ };
607
+
608
+ proto[ASSISTANT_PATCH_KEY] = { originalUpdateContent };
609
+ }
610
+
611
+ function patchRenderers() {
612
+ patchToolExecutionComponent();
613
+ patchAssistantMessageComponent();
614
+ }
615
+
616
+ function thinkingLabel() {
617
+ return state.consecutiveThinking > 1 ? `${LABEL} (${state.consecutiveThinking}x)` : LABEL;
618
+ }
619
+
620
+ function applyThinkingLabel(ctx: ExtensionContext) {
621
+ if (!isEnabled()) {
622
+ ctx.ui.setHiddenThinkingLabel();
623
+ return;
624
+ }
625
+ ctx.ui.setHiddenThinkingLabel(thinkingLabel());
626
+ }
627
+
628
+ function restoreConfigFromBranch(ctx: ExtensionContext) {
629
+ let nextConfig = cloneConfig(DEFAULT_CONFIG);
630
+ for (const entry of ctx.sessionManager.getBranch()) {
631
+ if (entry.type === "custom" && entry.customType === CONFIG_ENTRY_TYPE) {
632
+ nextConfig = normalizeConfig(entry.data);
633
+ }
278
634
  }
635
+ state.config = nextConfig;
279
636
  }
280
637
 
281
- export default async function (pi: ExtensionAPI) {
282
- await patchInternalRenderers();
283
- let consecutiveThinking = 0;
638
+ function persistConfig(pi: ExtensionAPI) {
639
+ pi.appendEntry(CONFIG_ENTRY_TYPE, cloneConfig(state.config));
640
+ }
284
641
 
285
- function thinkingLabel() {
286
- return consecutiveThinking > 1 ? `${LABEL} (${consecutiveThinking}x)` : LABEL;
642
+ function parseBoolean(value: string | undefined): boolean | undefined {
643
+ if (!value) return undefined;
644
+ const normalized = value.trim().toLowerCase();
645
+ if (["on", "true", "yes", "1", "enabled"].includes(normalized)) return true;
646
+ if (["off", "false", "no", "0", "disabled"].includes(normalized)) return false;
647
+ return undefined;
648
+ }
649
+
650
+ function splitList(value: string): string[] {
651
+ return value
652
+ .split(/[\s,]+/)
653
+ .map((item) => item.trim())
654
+ .filter(Boolean);
655
+ }
656
+
657
+ function formatList(values: string[]): string {
658
+ return values.length ? values.join(", ") : "(none)";
659
+ }
660
+
661
+ function formatTemplatesInput(templates = state.config.previewTemplates): string {
662
+ return Object.entries(templates)
663
+ .map(([tool, template]) => `${tool}=${template}`)
664
+ .join("; ");
665
+ }
666
+
667
+ function formatTemplatesDisplay(): string {
668
+ const keys = Object.keys(state.config.previewTemplates);
669
+ return keys.length ? keys.join(", ") : "(none)";
670
+ }
671
+
672
+ function parseTemplatesInput(value: string): Record<string, string> {
673
+ const trimmed = value.trim();
674
+ if (!trimmed || trimmed === "clear") return {};
675
+ const entries: Record<string, string> = {};
676
+ for (const part of trimmed.split(/;\s*/)) {
677
+ const index = part.indexOf("=");
678
+ if (index <= 0) continue;
679
+ const key = part.slice(0, index).trim();
680
+ const template = part.slice(index + 1).trim();
681
+ if (key && template) entries[key] = template;
287
682
  }
683
+ return entries;
684
+ }
288
685
 
289
- function resetTools() {
290
- resetToolRun();
686
+ function describeConfig(): string {
687
+ return [
688
+ `mode: ${state.config.mode}`,
689
+ `custom tools: ${state.config.compactCustomTools ? "on" : "off"}`,
690
+ `failed tools visible: ${state.config.showFailedTools ? "on" : "off"}`,
691
+ `bash mutation anchors: ${state.config.showBashMutations ? "on" : "off"}`,
692
+ `always show: ${formatList(state.config.alwaysShowTools)}`,
693
+ `mutation tools: ${formatList(state.config.mutationTools)}`,
694
+ `templates: ${formatTemplatesDisplay()}`,
695
+ ].join("\n");
696
+ }
697
+
698
+ function commandHelp(): string {
699
+ return [
700
+ "/compact-transcript [status]",
701
+ "/compact-transcript disabled|balanced|aggressive|debug",
702
+ "/compact-transcript custom-tools on|off",
703
+ "/compact-transcript failed on|off",
704
+ "/compact-transcript bash-mutations on|off",
705
+ "/compact-transcript always-show <tool[,tool]|/regex/|clear>",
706
+ "/compact-transcript mutation-tools <tool[,tool]|/regex/|clear>",
707
+ "/compact-transcript template <tool|/regex/> <template|clear>",
708
+ " template placeholders: {name}, {args}, {path}, {command}, {arg.foo}",
709
+ ].join("\n");
710
+ }
711
+
712
+ // Mode only selects the rendering style; it must not silently rewrite the
713
+ // user's other toggles, or switching modes becomes destructive.
714
+ function setMode(mode: ModeInput) {
715
+ state.config.mode = mode === "off" ? "disabled" : mode;
716
+ }
717
+
718
+ function onOff(value: boolean): "on" | "off" {
719
+ return value ? "on" : "off";
720
+ }
721
+
722
+ function createInputSubmenu(
723
+ title: string,
724
+ prefill: string,
725
+ done: (selectedValue?: string) => void,
726
+ help: string,
727
+ ): { render(width: number): string[]; handleInput(data: string): void; invalidate(): void } {
728
+ const input = new Input();
729
+ input.setValue(prefill);
730
+ // Input intentionally keeps cursor position when setValue() is called; for
731
+ // edit-in-place settings, start at the end like a normal command-line field.
732
+ (input as any).cursor = prefill.length;
733
+ input.onSubmit = (value) => done(value.trim());
734
+ input.onEscape = () => done(undefined);
735
+ return {
736
+ render(width: number) {
737
+ const theme = state.currentTheme;
738
+ const container = new Container();
739
+ container.addChild(new Text(theme ? theme.fg("accent", theme.bold(title)) : title, 0, 0));
740
+ container.addChild(new Text(theme ? theme.fg("dim", help) : help, 0, 0));
741
+ container.addChild(new Spacer(1));
742
+ container.addChild(input);
743
+ container.addChild(new Spacer(1));
744
+ container.addChild(new Text(theme ? theme.fg("dim", "Enter saves · Esc cancels") : "Enter saves · Esc cancels", 0, 0));
745
+ return container.render(width);
746
+ },
747
+ handleInput(data: string) {
748
+ input.handleInput(data);
749
+ },
750
+ invalidate() {
751
+ input.invalidate();
752
+ },
753
+ };
754
+ }
755
+
756
+ function buildSettingsItems(): SettingItem[] {
757
+ return [
758
+ {
759
+ id: "mode",
760
+ label: "Mode",
761
+ currentValue: state.config.mode,
762
+ values: ["balanced", "aggressive", "debug", "disabled"],
763
+ description:
764
+ "balanced compacts normal tool bursts; aggressive uses shorter previews; debug keeps rows visible while tuning; disabled turns compact-transcript rendering off.",
765
+ },
766
+ {
767
+ id: "customTools",
768
+ label: "Custom tools",
769
+ currentValue: onOff(state.config.compactCustomTools),
770
+ values: ["on", "off"],
771
+ description: "When on, compact custom/external tools added by extensions. When off, only built-in tools are compacted.",
772
+ },
773
+ {
774
+ id: "failedTools",
775
+ label: "Show failures",
776
+ currentValue: onOff(state.config.showFailedTools),
777
+ values: ["on", "off"],
778
+ description: "Keep failed tools visible even when they are part of an otherwise compacted burst.",
779
+ },
780
+ {
781
+ id: "bashMutations",
782
+ label: "Bash anchors",
783
+ currentValue: onOff(state.config.showBashMutations),
784
+ values: ["on", "off"],
785
+ description: "Keep destructive-looking bash commands visible as anchors and break compact tool bursts around them.",
786
+ },
787
+ {
788
+ id: "alwaysShowTools",
789
+ label: "Always show",
790
+ currentValue: formatList(state.config.alwaysShowTools),
791
+ description: "Press Enter to edit comma-separated tool names, wildcards, or /regex/ rules that should always stay visible.",
792
+ submenu: (_currentValue, done) =>
793
+ createInputSubmenu(
794
+ "Always show tools",
795
+ state.config.alwaysShowTools.join(", "),
796
+ done,
797
+ "Comma-separated names, wildcards, or /regex/. Empty input clears the list.",
798
+ ),
799
+ },
800
+ {
801
+ id: "mutationTools",
802
+ label: "Mutation tools",
803
+ currentValue: formatList(state.config.mutationTools),
804
+ description: "Press Enter to edit comma-separated tool names, wildcards, or /regex/ rules that act as mutation anchors.",
805
+ submenu: (_currentValue, done) =>
806
+ createInputSubmenu(
807
+ "Mutation tools",
808
+ state.config.mutationTools.join(", "),
809
+ done,
810
+ "Comma-separated names, wildcards, or /regex/. Empty input clears the list.",
811
+ ),
812
+ },
813
+ {
814
+ id: "templates",
815
+ label: "Templates",
816
+ currentValue: formatTemplatesDisplay(),
817
+ description: "Press Enter to edit preview templates as semicolon-separated tool=template pairs.",
818
+ submenu: (_currentValue, done) =>
819
+ createInputSubmenu(
820
+ "Preview templates",
821
+ formatTemplatesInput(),
822
+ done,
823
+ "Use tool=template; /regex/=template. Placeholders: {name}, {args}, {path}, {command}, {arg.foo}. `;` separates pairs, so templates cannot contain it.",
824
+ ),
825
+ },
826
+ {
827
+ id: "reset",
828
+ label: "Reset defaults",
829
+ currentValue: "press enter",
830
+ values: ["press enter"],
831
+ description: "Restore the default compact-transcript configuration.",
832
+ },
833
+ ];
834
+ }
835
+
836
+ function applySettingsItem(id: string, value: string, pi: ExtensionAPI, ctx: ExtensionContext) {
837
+ switch (id) {
838
+ case "mode":
839
+ setMode(value as ModeInput);
840
+ break;
841
+ case "customTools":
842
+ state.config.compactCustomTools = value === "on";
843
+ break;
844
+ case "failedTools":
845
+ state.config.showFailedTools = value === "on";
846
+ break;
847
+ case "bashMutations":
848
+ state.config.showBashMutations = value === "on";
849
+ break;
850
+ case "alwaysShowTools":
851
+ state.config.alwaysShowTools = splitList(value);
852
+ break;
853
+ case "mutationTools":
854
+ state.config.mutationTools = splitList(value);
855
+ break;
856
+ case "templates":
857
+ state.config.previewTemplates = parseTemplatesInput(value);
858
+ break;
859
+ case "reset":
860
+ state.config = cloneConfig(DEFAULT_CONFIG);
861
+ break;
862
+ default:
863
+ return;
291
864
  }
865
+ persistConfig(pi);
866
+ applyThinkingLabel(ctx);
867
+ }
292
868
 
293
- function applyThinkingLabel(ctx: ExtensionContext) {
294
- ctx.ui.setHiddenThinkingLabel(thinkingLabel());
869
+ async function showSettingsPanel(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
870
+ if (ctx.mode !== "tui") {
871
+ ctx.ui.notify(describeConfig(), "info");
872
+ return;
295
873
  }
874
+ state.currentTheme = ctx.ui.theme;
875
+
876
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
877
+ let settingsList: SettingsList;
878
+ const refreshValues = () => {
879
+ for (const item of buildSettingsItems()) {
880
+ settingsList.updateValue(item.id, item.currentValue);
881
+ }
882
+ };
883
+
884
+ settingsList = new SettingsList(
885
+ buildSettingsItems(),
886
+ SETTINGS_LIST_HEIGHT,
887
+ getSettingsListTheme(),
888
+ (id, value) => {
889
+ applySettingsItem(id, value, pi, ctx);
890
+ refreshValues();
891
+ tui.requestRender();
892
+ },
893
+ () => done(undefined),
894
+ );
895
+
896
+ return {
897
+ render(width: number) {
898
+ state.currentTheme = theme;
899
+ const title = theme.fg("accent", theme.bold("Compact Transcript"));
900
+ const subtitle = theme.fg("dim", "Configure transcript compaction. Enter/Space changes selected item; Esc closes.");
901
+ const container = new Container();
902
+ container.addChild(new Text(title, 0, 0));
903
+ container.addChild(new Text(subtitle, 0, 0));
904
+ container.addChild(new Spacer(1));
905
+ container.addChild(settingsList);
906
+ return container.render(width);
907
+ },
908
+ invalidate() {
909
+ settingsList.invalidate();
910
+ },
911
+ handleInput(data: string) {
912
+ settingsList.handleInput?.(data);
913
+ tui.requestRender();
914
+ },
915
+ };
916
+ });
917
+ }
918
+
919
+ function registerCommand(pi: ExtensionAPI) {
920
+ pi.registerCommand("compact-transcript", {
921
+ description: "Configure compact transcript rendering: disabled, balanced, aggressive, debug, and tool rules.",
922
+ getArgumentCompletions(prefix: string) {
923
+ const commands = [
924
+ "status",
925
+ "help",
926
+ "reset",
927
+ "disabled",
928
+ "off",
929
+ "balanced",
930
+ "aggressive",
931
+ "debug",
932
+ "custom-tools on",
933
+ "custom-tools off",
934
+ "failed on",
935
+ "failed off",
936
+ "bash-mutations on",
937
+ "bash-mutations off",
938
+ "always-show clear",
939
+ "mutation-tools clear",
940
+ "template ",
941
+ ];
942
+ return commands
943
+ .filter((value) => value.startsWith(prefix.trimStart()))
944
+ .map((value) => ({ value, label: value }));
945
+ },
946
+ handler: async (args, ctx) => {
947
+ captureTheme(ctx);
948
+ const trimmed = args.trim();
949
+ if (trimmed === "help" && ctx.mode !== "tui") {
950
+ ctx.ui.notify(commandHelp(), "info");
951
+ return;
952
+ }
953
+ if (!trimmed || trimmed === "status" || trimmed === "help") {
954
+ await showSettingsPanel(pi, ctx);
955
+ return;
956
+ }
957
+ if (trimmed === "reset") {
958
+ state.config = cloneConfig(DEFAULT_CONFIG);
959
+ persistConfig(pi);
960
+ applyThinkingLabel(ctx);
961
+ await showSettingsPanel(pi, ctx);
962
+ return;
963
+ }
964
+ if (["disabled", "off", "balanced", "aggressive", "debug"].includes(trimmed)) {
965
+ setMode(trimmed as ModeInput);
966
+ persistConfig(pi);
967
+ applyThinkingLabel(ctx);
968
+ await showSettingsPanel(pi, ctx);
969
+ return;
970
+ }
971
+
972
+ const [command, ...rest] = trimmed.split(/\s+/);
973
+ const restText = rest.join(" ").trim();
974
+ if (command === "custom-tools" || command === "failed" || command === "bash-mutations") {
975
+ const value = parseBoolean(rest[0]);
976
+ if (value === undefined) {
977
+ ctx.ui.notify(`Usage: /compact-transcript ${command} on|off`, "error");
978
+ return;
979
+ }
980
+ if (command === "custom-tools") state.config.compactCustomTools = value;
981
+ if (command === "failed") state.config.showFailedTools = value;
982
+ if (command === "bash-mutations") state.config.showBashMutations = value;
983
+ persistConfig(pi);
984
+ await showSettingsPanel(pi, ctx);
985
+ return;
986
+ }
987
+
988
+ if (command === "always-show" || command === "mutation-tools") {
989
+ if (!restText) {
990
+ ctx.ui.notify(`Usage: /compact-transcript ${command} <tool[,tool]|/regex/|clear>`, "error");
991
+ return;
992
+ }
993
+ const values = restText === "clear" ? [] : splitList(restText);
994
+ if (command === "always-show") state.config.alwaysShowTools = values;
995
+ else state.config.mutationTools = values;
996
+ persistConfig(pi);
997
+ await showSettingsPanel(pi, ctx);
998
+ return;
999
+ }
1000
+
1001
+ if (command === "template") {
1002
+ const [tool, ...templateParts] = rest;
1003
+ const template = templateParts.join(" ").trim();
1004
+ if (!tool || !template) {
1005
+ ctx.ui.notify("Usage: /compact-transcript template <tool|/regex/> <template|clear>", "error");
1006
+ return;
1007
+ }
1008
+ if (template === "clear") delete state.config.previewTemplates[tool];
1009
+ else state.config.previewTemplates[tool] = template;
1010
+ persistConfig(pi);
1011
+ await showSettingsPanel(pi, ctx);
1012
+ return;
1013
+ }
1014
+
1015
+ ctx.ui.notify(`Unknown option "${trimmed}".\n${commandHelp()}`, "error");
1016
+ },
1017
+ });
1018
+ }
1019
+
1020
+ export default function compactTranscript(pi: ExtensionAPI) {
1021
+ patchRenderers();
1022
+ registerCommand(pi);
296
1023
 
297
1024
  pi.on("session_start", async (_event, ctx) => {
1025
+ restoreConfigFromBranch(ctx);
1026
+ captureTheme(ctx);
298
1027
  applyThinkingLabel(ctx);
299
- ctx.ui.setWorkingMessage(LABEL);
1028
+ ctx.ui.setWorkingMessage();
1029
+ // Clear any footer status left behind by pre-0.4 versions of this extension.
1030
+ ctx.ui.setStatus(STATUS_KEY, undefined);
300
1031
  });
301
1032
 
302
- pi.on("agent_start", () => {
303
- resetTools();
1033
+ pi.on("session_shutdown", async (_event, ctx) => {
1034
+ ctx.ui.setHiddenThinkingLabel();
1035
+ ctx.ui.setStatus(STATUS_KEY, undefined);
1036
+ ctx.ui.setWorkingMessage();
1037
+ });
1038
+
1039
+ pi.on("agent_start", (_event, ctx) => {
1040
+ state.agentActive = true;
1041
+ captureTheme(ctx);
1042
+ resetToolRun();
1043
+ resetThinkingSignals();
1044
+ state.consecutiveThinking = 0;
1045
+ applyThinkingLabel(ctx);
1046
+ });
1047
+
1048
+ pi.on("agent_end", (_event, _ctx) => {
1049
+ state.agentActive = false;
1050
+ state.currentNoiseBurst = [];
1051
+ resetThinkingSignals();
304
1052
  });
305
1053
 
306
1054
  pi.on("turn_start", (_event, ctx) => {
307
- consecutiveThinking = 0;
1055
+ state.consecutiveThinking = 0;
1056
+ captureTheme(ctx);
308
1057
  applyThinkingLabel(ctx);
309
1058
  });
310
1059
 
311
- pi.on("message_update", (event: any, ctx) => {
1060
+ pi.on("message_update", (event, ctx) => {
1061
+ captureTheme(ctx);
312
1062
  const type = event.assistantMessageEvent?.type;
313
1063
  if (type === "thinking_start") {
314
- consecutiveThinking++;
1064
+ state.consecutiveThinking++;
315
1065
  applyThinkingLabel(ctx);
316
1066
  return;
317
1067
  }
318
1068
  if (type && !type.startsWith("thinking_")) {
319
- consecutiveThinking = 0;
1069
+ state.consecutiveThinking = 0;
320
1070
  applyThinkingLabel(ctx);
321
- if (type === "text_delta" || type === "text_start") resetTools();
1071
+ if (type === "text_delta" || type === "text_start") {
1072
+ resetToolRun();
1073
+ resetThinkingSignals();
1074
+ }
322
1075
  }
323
1076
  });
324
1077
 
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?.();
1078
+ pi.on("tool_execution_start", (event, ctx) => {
1079
+ captureTheme(ctx);
1080
+ beginTool(event.toolCallId, event.toolName, event.args);
329
1081
  });
330
1082
 
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?.();
1083
+ pi.on("tool_execution_update", (event, ctx) => {
1084
+ captureTheme(ctx);
1085
+ updateToolResult(event.toolCallId, event.partialResult, false, true);
337
1086
  });
338
1087
 
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
- }
1088
+ pi.on("tool_execution_end", (event, ctx) => {
1089
+ captureTheme(ctx);
1090
+ updateToolResult(event.toolCallId, event.result, event.isError, false);
1091
+ });
360
1092
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-compact-transcript",
3
- "version": "0.2.1",
3
+ "version": "0.4.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",