pi-compact-transcript 0.2.0 → 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,54 +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
+ };
62
+
63
+ const DEFAULT_CONFIG: CompactTranscriptConfig = {
64
+ mode: "balanced",
65
+ compactCustomTools: true,
66
+ showFailedTools: true,
67
+ showBashMutations: true,
68
+ alwaysShowTools: [],
69
+ mutationTools: [],
70
+ previewTemplates: {},
71
+ };
30
72
 
31
- 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");
32
76
 
33
- function createBuiltInTools(cwd: string) {
77
+ function cloneConfig(config: CompactTranscriptConfig): CompactTranscriptConfig {
34
78
  return {
35
- bash: createBashTool(cwd),
36
- read: createReadTool(cwd),
37
- write: createWriteTool(cwd),
38
- edit: createEditTool(cwd),
39
- grep: createGrepTool(cwd),
40
- find: createFindTool(cwd),
41
- 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 },
42
86
  };
43
87
  }
44
88
 
45
- function getBuiltInTools(cwd: string) {
46
- let tools = toolCache.get(cwd);
47
- if (!tools) {
48
- tools = createBuiltInTools(cwd);
49
- toolCache.set(cwd, tools);
50
- }
51
- 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;
52
144
  }
53
145
 
54
146
  function shortenPath(path: unknown): string {
@@ -63,7 +155,17 @@ function oneLine(value: unknown): string {
63
155
  .trim();
64
156
  }
65
157
 
66
- 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 {
67
169
  const clean = oneLine(text);
68
170
  if (clean.length <= max) return clean;
69
171
  return `${clean.slice(0, Math.max(0, max - 1))}…`;
@@ -73,7 +175,99 @@ function quote(s: string): string {
73
175
  return JSON.stringify(s);
74
176
  }
75
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
+
76
267
  function previewFor(name: string, args: any): string {
268
+ const template = findTemplate(name);
269
+ if (template) return renderTemplate(template, name, args);
270
+
77
271
  switch (name) {
78
272
  case "bash":
79
273
  return `$ ${oneLine(args?.command || "...")}`;
@@ -104,236 +298,795 @@ function previewFor(name: string, args: any): string {
104
298
  case "ls":
105
299
  return `ls ${shortenPath(args?.path) || "."}`;
106
300
  default:
107
- return `${name} ${oneLine(JSON.stringify(args ?? {}))}`;
301
+ return `${name} ${safeJson(args ?? {})}`;
108
302
  }
109
303
  }
110
304
 
111
- function resultPreview(result: any): string {
305
+ function resultPreview(result: any, isPartial = false): string {
112
306
  const text = Array.isArray(result?.content)
113
307
  ? result.content.find((c: any) => c?.type === "text" && typeof c.text === "string")?.text
114
308
  : undefined;
115
- if (!text) return "";
309
+ if (!text) return isPartial ? "running" : "";
116
310
  const lines = String(text).trim().split("\n").filter(Boolean);
117
- if (lines.length === 0) return "";
311
+ if (lines.length === 0) return isPartial ? "running" : "";
118
312
  if (lines.length === 1) return lines[0];
119
313
  return `${lines.length} lines`;
120
314
  }
121
315
 
122
- function toolCallName(block: any): string {
123
- 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(", ");
124
325
  }
125
326
 
126
- function toolCallArgs(block: any): any {
127
- 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();
128
331
  }
129
332
 
130
- function nextStepSummary(content: any[], fromIndex: number): string {
131
- const tools = content.slice(fromIndex + 1).filter((c: any) => c?.type === "toolCall");
132
- if (tools.length === 0) return " Next, I’ll continue from this reasoning and respond with the result. I’ll keep the visible output brief.";
333
+ function resetThinkingSignals() {
334
+ state.lastThinkingSignalComponent = undefined;
335
+ state.thinkingSignalCount = 0;
336
+ }
133
337
 
134
- const first = tools[0];
135
- const preview = limitPlain(previewFor(toolCallName(first), toolCallArgs(first)), 90);
136
- if (tools.length === 1) {
137
- return ` Next, I’ll use ${preview}. I’ll inspect the result and decide the next visible step from there.`;
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);
138
354
  }
139
- const last = tools[tools.length - 1];
140
- const lastPreview = limitPlain(previewFor(toolCallName(last), toolCallArgs(last)), 70);
141
- 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}.`;
142
355
  }
143
356
 
144
- async function patchInternalRenderers() {
145
- try {
146
- const require = createRequire(import.meta.url);
147
- const piMain = require.resolve("@earendil-works/pi-coding-agent");
148
- const distDir = dirname(piMain);
149
- const assistantModule = await import(
150
- pathToFileURL(join(distDir, "modes/interactive/components/assistant-message.js")).href
151
- );
152
- const toolExecutionModule = await import(
153
- pathToFileURL(join(distDir, "modes/interactive/components/tool-execution.js")).href
357
+ function upsertToolInfo(id: string, name: string, args: any, invalidate?: () => void): ToolInfo {
358
+ let info = state.toolsById.get(id);
359
+ if (!info) {
360
+ info = { id, name, args, preview: previewFor(name, args), kind: classifyTool(name, args) };
361
+ state.toolsById.set(id, info);
362
+ }
363
+ info.name = name;
364
+ info.args = args;
365
+ info.preview = previewFor(name, args);
366
+ info.kind = classifyTool(name, args);
367
+ if (invalidate) info.invalidate = invalidate;
368
+ return info;
369
+ }
370
+
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
+ }
382
+
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
+ }
393
+
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?.();
399
+ }
400
+
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);
412
+
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));
421
+ }
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
+ );
442
+ }
443
+
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,
154
503
  );
155
- const themeModule = await import(pathToFileURL(join(distDir, "modes/interactive/theme/theme.js")).href);
156
-
157
- const ToolExecutionComponent = toolExecutionModule.ToolExecutionComponent;
158
- if (ToolExecutionComponent?.prototype && !ToolExecutionComponent.prototype.__compactTranscriptPatched) {
159
- const originalRender = ToolExecutionComponent.prototype.render;
160
- ToolExecutionComponent.prototype.render = function (width: number) {
161
- if (this.hideComponent) return [];
162
- if (this.hasRendererDefinition?.() && this.getRenderShell?.() === "self") {
163
- const contentLines = this.selfRenderContainer.render(width);
164
- if (contentLines.length === 0 && this.imageComponents.length === 0) return [];
165
- const lines = [...contentLines];
166
- for (let i = 0; i < this.imageComponents.length; i++) {
167
- const spacer = this.imageSpacers[i];
168
- if (spacer) lines.push(...spacer.render(width));
169
- const imageComponent = this.imageComponents[i];
170
- if (imageComponent) lines.push(...imageComponent.render(width));
171
- }
172
- return lines;
173
- }
174
- return originalRender.call(this, width);
175
- };
176
- ToolExecutionComponent.prototype.__compactTranscriptPatched = true;
504
+
505
+ if (!line) {
506
+ this.hideComponent = true;
507
+ return;
177
508
  }
178
509
 
179
- const Component = assistantModule.AssistantMessageComponent;
180
- if (!Component?.prototype || Component.prototype.__compactTranscriptPatched) return;
181
- const original = Component.prototype.updateContent;
510
+ this.selfRenderContainer.addChild(new Text(line, 0, 0));
511
+ };
182
512
 
183
- Component.prototype.updateContent = function (message: any) {
184
- 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
+ };
185
518
 
186
- this.lastMessage = message;
187
- this.contentContainer.clear();
519
+ proto[TOOL_PATCH_KEY] = { originalUpdateDisplay, originalRender };
520
+ }
188
521
 
189
- const visible = message.content.filter(
190
- (c: any) => (c.type === "text" && c.text?.trim()) || (c.type === "thinking" && c.thinking?.trim()),
191
- );
192
- 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;
193
527
 
194
- for (let i = 0; i < message.content.length; i++) {
195
- const content = message.content[i];
196
- if (content.type === "text" && content.text?.trim()) {
197
- this.contentContainer.addChild(new Markdown(content.text.trim(), this.outputPad, 0, this.markdownTheme));
198
- continue;
199
- }
200
- if (content.type !== "thinking" || !content.thinking?.trim()) continue;
201
-
202
- let count = 1;
203
- while (
204
- message.content[i + count]?.type === "thinking" &&
205
- message.content[i + count]?.thinking?.trim()
206
- ) {
207
- 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?.();
208
559
  }
209
- i += count - 1;
210
-
211
- const label = count > 1 ? `${LABEL} (${count}x)` : LABEL;
212
- const summary = nextStepSummary(message.content, i);
213
- this.contentContainer.addChild(
214
- new Text(themeModule.theme.italic(themeModule.theme.fg("thinkingText", limitPlain(`${label}${summary}`))), this.outputPad, 0),
215
- );
216
-
217
- const hasVisibleAfter = message.content
218
- .slice(i + 1)
219
- .some((c: any) => (c.type === "text" && c.text?.trim()) || (c.type === "thinking" && c.thinking?.trim()));
220
- if (hasVisibleAfter) this.contentContainer.addChild(new Spacer(1));
560
+ state.lastThinkingSignalComponent = this;
561
+ state.thinkingSignalCount++;
562
+ } else if (!thinkingSignal && hasText) {
563
+ resetThinkingSignals();
221
564
  }
565
+ }
222
566
 
223
- this.hasToolCalls = message.content.some((c: any) => c.type === "toolCall");
224
- };
225
- Component.prototype.__compactTranscriptPatched = true;
226
- } catch {
227
- // 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;
228
624
  }
625
+ ctx.ui.setHiddenThinkingLabel(thinkingLabel());
229
626
  }
230
627
 
231
- export default async function (pi: ExtensionAPI) {
232
- await patchInternalRenderers();
233
- let current: ToolInfo[] = [];
234
- let byId = new Map<string, ToolInfo>();
235
- let consecutiveThinking = 0;
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
+ }
634
+ }
635
+ state.config = nextConfig;
636
+ }
236
637
 
237
- function thinkingLabel() {
238
- return consecutiveThinking > 1 ? `${LABEL} (${consecutiveThinking}x)` : LABEL;
638
+ function persistConfig(pi: ExtensionAPI) {
639
+ pi.appendEntry(CONFIG_ENTRY_TYPE, cloneConfig(state.config));
640
+ }
641
+
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;
239
682
  }
683
+ return entries;
684
+ }
240
685
 
241
- function resetTools() {
242
- current = [];
243
- byId = new Map();
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;
244
864
  }
865
+ persistConfig(pi);
866
+ applyThinkingLabel(ctx);
867
+ }
245
868
 
246
- function applyThinkingLabel(ctx: ExtensionContext) {
247
- 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;
248
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);
249
1023
 
250
1024
  pi.on("session_start", async (_event, ctx) => {
1025
+ restoreConfigFromBranch(ctx);
1026
+ captureTheme(ctx);
1027
+ applyThinkingLabel(ctx);
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);
1031
+ });
1032
+
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;
251
1045
  applyThinkingLabel(ctx);
252
- ctx.ui.setWorkingMessage(LABEL);
253
1046
  });
254
1047
 
255
- pi.on("agent_start", () => {
256
- resetTools();
1048
+ pi.on("agent_end", (_event, _ctx) => {
1049
+ state.agentActive = false;
1050
+ state.currentNoiseBurst = [];
1051
+ resetThinkingSignals();
257
1052
  });
258
1053
 
259
1054
  pi.on("turn_start", (_event, ctx) => {
260
- consecutiveThinking = 0;
1055
+ state.consecutiveThinking = 0;
1056
+ captureTheme(ctx);
261
1057
  applyThinkingLabel(ctx);
262
1058
  });
263
1059
 
264
- pi.on("message_update", (event: any, ctx) => {
1060
+ pi.on("message_update", (event, ctx) => {
1061
+ captureTheme(ctx);
265
1062
  const type = event.assistantMessageEvent?.type;
266
1063
  if (type === "thinking_start") {
267
- consecutiveThinking++;
1064
+ state.consecutiveThinking++;
268
1065
  applyThinkingLabel(ctx);
269
1066
  return;
270
1067
  }
271
1068
  if (type && !type.startsWith("thinking_")) {
272
- consecutiveThinking = 0;
1069
+ state.consecutiveThinking = 0;
273
1070
  applyThinkingLabel(ctx);
274
- if (type === "text_delta" || type === "text_start") resetTools();
1071
+ if (type === "text_delta" || type === "text_start") {
1072
+ resetToolRun();
1073
+ resetThinkingSignals();
1074
+ }
275
1075
  }
276
1076
  });
277
1077
 
278
- pi.on("tool_execution_start", (event: any) => {
279
- const previous = current[current.length - 1];
280
- const info: ToolInfo = {
281
- id: event.toolCallId,
282
- name: event.toolName,
283
- args: event.args,
284
- preview: previewFor(event.toolName, event.args),
285
- };
286
- current.push(info);
287
- byId.set(info.id, info);
288
- previous?.invalidate?.();
1078
+ pi.on("tool_execution_start", (event, ctx) => {
1079
+ captureTheme(ctx);
1080
+ beginTool(event.toolCallId, event.toolName, event.args);
289
1081
  });
290
1082
 
291
- pi.on("tool_execution_end", (event: any) => {
292
- const info = byId.get(event.toolCallId);
293
- if (!info) return;
294
- const suffix = resultPreview(event.result);
295
- if (suffix) info.result = suffix;
296
- info.invalidate?.();
1083
+ pi.on("tool_execution_update", (event, ctx) => {
1084
+ captureTheme(ctx);
1085
+ updateToolResult(event.toolCallId, event.partialResult, false, true);
297
1086
  });
298
1087
 
299
- function compactLine(toolCallId: string, name: string, args: any, theme: any, context: any): string {
300
- let info = byId.get(toolCallId);
301
- if (!info) {
302
- info = { id: toolCallId, name, args, preview: previewFor(name, args) };
303
- byId.set(toolCallId, info);
304
- if (!current.some((t) => t.id === toolCallId)) current.push(info);
305
- }
306
- info.invalidate = context.invalidate;
307
- info.args = args;
308
- info.preview = previewFor(name, args);
309
-
310
- if (current[current.length - 1]?.id !== toolCallId) return "";
311
-
312
- const details = info.result ? `${info.preview} {${oneLine(info.result)}}` : info.preview;
313
- if (current.length === 1) return theme.fg("muted", limitPlain(details));
314
- const prefix = `Used ${current.length} tools `;
315
- return theme.fg("toolTitle", prefix) + theme.fg("muted", limitPlain(details, Math.max(20, (process.stdout.columns || 100) - prefix.length - 6)));
316
- }
317
-
318
- for (const name of BUILT_INS) {
319
- const base = getBuiltInTools(process.cwd())[name] as any;
320
- pi.registerTool({
321
- ...base,
322
- name,
323
- label: name,
324
- renderShell: "self",
325
- async execute(toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any, ctx: ExtensionContext) {
326
- const tool = (getBuiltInTools(ctx.cwd) as any)[name];
327
- return tool.execute(toolCallId, params, signal, onUpdate, ctx);
328
- },
329
- renderCall(args: any, theme: any, context: any) {
330
- const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
331
- text.setText(compactLine(context.toolCallId, name, args, theme, context));
332
- return text;
333
- },
334
- renderResult(_result: any, _options: any, _theme: any, _context: any) {
335
- return new Text("", 0, 0);
336
- },
337
- });
338
- }
1088
+ pi.on("tool_execution_end", (event, ctx) => {
1089
+ captureTheme(ctx);
1090
+ updateToolResult(event.toolCallId, event.result, event.isError, false);
1091
+ });
339
1092
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-compact-transcript",
3
- "version": "0.2.0",
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",