pi-compact-transcript 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,37 +1,24 @@
1
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";
2
+ import { AssistantMessageComponent, ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
3
+ import { Markdown, Spacer, Text } from "@earendil-works/pi-tui";
4
4
  import { homedir } from "node:os";
5
5
 
6
- const LABEL = "Thinking...";
7
6
  // Older versions of this extension wrote a footer status under this key; it is
8
7
  // kept only to clear that status once per session for users upgrading in place.
9
8
  const STATUS_KEY = "compact-transcript";
10
9
  const CONFIG_ENTRY_TYPE = "compact-transcript-config";
11
-
12
- const BUILT_INS = new Set(["bash", "read", "write", "edit", "grep", "find", "ls"]);
10
+ const SUMMARY_ENTRY_TYPE = "compact-transcript-summary";
13
11
 
14
12
  const MIN_PREVIEW_WIDTH = 20;
15
- const AGGRESSIVE_PREVIEW_WIDTH = 72;
16
- const DEBUG_PREVIEW_WIDTH = 140;
17
- const DEFAULT_PREVIEW_WIDTH = 104;
13
+ const MAX_PREVIEW_WIDTH = 104;
18
14
  // Leave room for pi's row gutter/padding so compact lines never wrap.
19
15
  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";
16
+ const BLINK_INTERVAL_MS = 400;
17
+ // Status marker is two cells wide ("◆ ").
18
+ const MARKER_WIDTH = 2;
26
19
 
27
20
  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>;
21
+ enabled: boolean;
35
22
  };
36
23
 
37
24
  type ToolInfo = {
@@ -39,92 +26,97 @@ type ToolInfo = {
39
26
  name: string;
40
27
  args: any;
41
28
  preview: string;
42
- kind: ToolKind;
43
29
  hidden?: boolean;
30
+ running?: boolean;
44
31
  burstCount?: number;
45
- burstSummary?: string;
32
+ startedAt?: number;
33
+ durationMs?: number;
34
+ burstDurationMs?: number;
46
35
  result?: string;
47
36
  isError?: boolean;
48
37
  invalidate?: () => void;
49
38
  };
50
39
 
40
+ type RunStats = {
41
+ startedAt: number;
42
+ toolCount: number;
43
+ readFiles: Set<string>;
44
+ editFiles: Set<string>;
45
+ commandCount: number;
46
+ otherCount: number;
47
+ failedCount: number;
48
+ };
49
+
50
+ type SummaryData = {
51
+ reads: number;
52
+ edits: number;
53
+ commands: number;
54
+ others: number;
55
+ failed: number;
56
+ durationMs: number;
57
+ };
58
+
51
59
  type RuntimeState = {
52
60
  config: CompactTranscriptConfig;
53
61
  toolsById: Map<string, ToolInfo>;
54
- currentNoiseBurst: ToolInfo[];
62
+ currentBurst: ToolInfo[];
55
63
  hiddenToolIds: Set<string>;
64
+ runningToolIds: Set<string>;
56
65
  agentActive: boolean;
57
- lastThinkingSignalComponent?: any;
58
- thinkingSignalCount: number;
59
- consecutiveThinking: number;
66
+ blinkOn: boolean;
67
+ blinkTimer?: ReturnType<typeof setInterval>;
68
+ runStats: RunStats;
69
+ // Live transcript components, so toggling can re-render existing rows.
70
+ toolComponents: Set<any>;
71
+ assistantComponents: Set<any>;
60
72
  currentTheme?: Theme;
61
73
  };
62
74
 
63
75
  const DEFAULT_CONFIG: CompactTranscriptConfig = {
64
- mode: "balanced",
65
- compactCustomTools: true,
66
- showFailedTools: true,
67
- showBashMutations: true,
68
- alwaysShowTools: [],
69
- mutationTools: [],
70
- previewTemplates: {},
76
+ enabled: true,
71
77
  };
72
78
 
73
79
  const STATE_KEY = Symbol.for("pi-compact-transcript.state");
74
80
  const TOOL_PATCH_KEY = Symbol.for("pi-compact-transcript.tool-patch");
75
81
  const ASSISTANT_PATCH_KEY = Symbol.for("pi-compact-transcript.assistant-patch");
76
82
 
77
- function cloneConfig(config: CompactTranscriptConfig): CompactTranscriptConfig {
83
+ function newRunStats(): RunStats {
78
84
  return {
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 },
85
+ startedAt: Date.now(),
86
+ toolCount: 0,
87
+ readFiles: new Set(),
88
+ editFiles: new Set(),
89
+ commandCount: 0,
90
+ otherCount: 0,
91
+ failedCount: 0,
86
92
  };
87
93
  }
88
94
 
89
95
  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
- };
96
+ const source = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
97
+ let enabled = DEFAULT_CONFIG.enabled;
98
+ if (typeof source.enabled === "boolean") {
99
+ enabled = source.enabled;
100
+ } else if (typeof source.mode === "string") {
101
+ // Pre-0.5 config persisted a mode string instead of an enabled flag.
102
+ enabled = source.mode !== "disabled" && source.mode !== "off";
103
+ }
104
+ return { enabled };
116
105
  }
117
106
 
118
107
  function getState(): RuntimeState {
119
108
  const globalWithState = globalThis as typeof globalThis & { [STATE_KEY]?: RuntimeState };
120
109
  globalWithState[STATE_KEY] ??= {
121
- config: cloneConfig(DEFAULT_CONFIG),
110
+ config: { ...DEFAULT_CONFIG },
122
111
  toolsById: new Map(),
123
- currentNoiseBurst: [],
112
+ currentBurst: [],
124
113
  hiddenToolIds: new Set(),
114
+ runningToolIds: new Set(),
125
115
  agentActive: false,
126
- thinkingSignalCount: 0,
127
- consecutiveThinking: 0,
116
+ blinkOn: true,
117
+ runStats: newRunStats(),
118
+ toolComponents: new Set(),
119
+ assistantComponents: new Set(),
128
120
  };
129
121
  return globalWithState[STATE_KEY]!;
130
122
  }
@@ -132,11 +124,7 @@ function getState(): RuntimeState {
132
124
  const state = getState();
133
125
 
134
126
  function isEnabled(): boolean {
135
- return state.config.mode !== "disabled";
136
- }
137
-
138
- function isDebugMode(): boolean {
139
- return state.config.mode === "debug";
127
+ return state.config.enabled;
140
128
  }
141
129
 
142
130
  function isNonEmptyString(value: unknown): value is string {
@@ -156,13 +144,7 @@ function oneLine(value: unknown): string {
156
144
  }
157
145
 
158
146
  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));
147
+ return Math.max(MIN_PREVIEW_WIDTH, Math.min(MAX_PREVIEW_WIDTH, base - PREVIEW_MARGIN));
166
148
  }
167
149
 
168
150
  function limitPlain(text: string, max = previewWidth()): string {
@@ -183,91 +165,36 @@ function safeJson(value: unknown): string {
183
165
  }
184
166
  }
185
167
 
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
- }
168
+ // Sub-second durations render as "" so fast tools stay clutter-free.
169
+ function formatDuration(ms: number): string {
170
+ if (!Number.isFinite(ms) || ms < 1000) return "";
171
+ const totalSeconds = Math.round(ms / 1000);
172
+ if (totalSeconds < 60) return `${totalSeconds}s`;
173
+ const minutes = Math.floor(totalSeconds / 60);
174
+ const seconds = totalSeconds % 60;
175
+ return seconds ? `${minutes}m${seconds}s` : `${minutes}m`;
176
+ }
177
+
178
+ // Checked in order; earlier keys are more likely to be the argument a human
179
+ // would recognize the call by.
180
+ const PREFERRED_ARG_KEYS = [
181
+ "command",
182
+ "code",
183
+ "query",
184
+ "pattern",
185
+ "path",
186
+ "file_path",
187
+ "filePath",
188
+ "file",
189
+ "url",
190
+ "prompt",
191
+ "text",
192
+ "description",
193
+ "name",
194
+ ];
195
+ const PATH_ARG_KEYS = new Set(["path", "file_path", "filePath", "file"]);
266
196
 
267
197
  function previewFor(name: string, args: any): string {
268
- const template = findTemplate(name);
269
- if (template) return renderTemplate(template, name, args);
270
-
271
198
  switch (name) {
272
199
  case "bash":
273
200
  return `$ ${oneLine(args?.command || "...")}`;
@@ -297,8 +224,22 @@ function previewFor(name: string, args: any): string {
297
224
  return `find ${args?.pattern ? quote(String(args.pattern)) : "..."} ${shortenPath(args?.path) || "."}`;
298
225
  case "ls":
299
226
  return `ls ${shortenPath(args?.path) || "."}`;
300
- default:
227
+ default: {
228
+ // Unknown tools: show the most meaningful string argument instead of
229
+ // dumping the whole args object as JSON.
230
+ if (args && typeof args === "object") {
231
+ for (const key of PREFERRED_ARG_KEYS) {
232
+ const value = (args as Record<string, unknown>)[key];
233
+ if (isNonEmptyString(value)) {
234
+ const rendered = PATH_ARG_KEYS.has(key) ? shortenPath(value) : oneLine(value);
235
+ return `${name} ${rendered}`;
236
+ }
237
+ }
238
+ const firstString = Object.values(args).find(isNonEmptyString);
239
+ if (firstString) return `${name} ${oneLine(firstString)}`;
240
+ }
301
241
  return `${name} ${safeJson(args ?? {})}`;
242
+ }
302
243
  }
303
244
  }
304
245
 
@@ -313,26 +254,12 @@ function resultPreview(result: any, isPartial = false): string {
313
254
  return `${lines.length} lines`;
314
255
  }
315
256
 
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(", ");
325
- }
326
-
327
257
  function resetToolRun() {
328
258
  state.toolsById = new Map();
329
- state.currentNoiseBurst = [];
259
+ state.currentBurst = [];
330
260
  state.hiddenToolIds = new Set();
331
- }
332
-
333
- function resetThinkingSignals() {
334
- state.lastThinkingSignalComponent = undefined;
335
- state.thinkingSignalCount = 0;
261
+ state.runningToolIds = new Set();
262
+ stopBlinkTimer();
336
263
  }
337
264
 
338
265
  function captureTheme(ctx: ExtensionContext) {
@@ -350,51 +277,139 @@ function applyResult(info: ToolInfo, result: any, isError: boolean, isPartial: b
350
277
  if (suffix) info.result = suffix;
351
278
  if (isError) {
352
279
  info.isError = true;
280
+ // A failed tool always gets its own visible row, even if a burst had
281
+ // hidden it; render it as itself rather than as a burst summary.
282
+ info.burstCount = 1;
353
283
  setToolHidden(info, false);
354
284
  }
355
285
  }
356
286
 
287
+ function ensureBlinkTimer() {
288
+ if (state.blinkTimer || state.runningToolIds.size === 0) return;
289
+ state.blinkTimer = setInterval(() => {
290
+ if (state.runningToolIds.size === 0) {
291
+ stopBlinkTimer();
292
+ return;
293
+ }
294
+ state.blinkOn = !state.blinkOn;
295
+ for (const id of state.runningToolIds) state.toolsById.get(id)?.invalidate?.();
296
+ }, BLINK_INTERVAL_MS);
297
+ state.blinkTimer.unref?.();
298
+ }
299
+
300
+ function stopBlinkTimer() {
301
+ if (state.blinkTimer) clearInterval(state.blinkTimer);
302
+ state.blinkTimer = undefined;
303
+ state.blinkOn = true;
304
+ }
305
+
306
+ function statusMarker(theme: Theme, opts: { running?: boolean; isError?: boolean; hasResult?: boolean }): string {
307
+ if (opts.isError) return theme.fg("error", "◆ ");
308
+ if (opts.running) return theme.fg("dim", state.blinkOn ? "◆ " : "◇ ");
309
+ if (opts.hasResult) return theme.fg("success", "◆ ");
310
+ return theme.fg("dim", "◆ ");
311
+ }
312
+
357
313
  function upsertToolInfo(id: string, name: string, args: any, invalidate?: () => void): ToolInfo {
358
314
  let info = state.toolsById.get(id);
359
315
  if (!info) {
360
- info = { id, name, args, preview: previewFor(name, args), kind: classifyTool(name, args) };
316
+ info = { id, name, args, preview: previewFor(name, args) };
361
317
  state.toolsById.set(id, info);
362
318
  }
363
319
  info.name = name;
364
320
  info.args = args;
365
321
  info.preview = previewFor(name, args);
366
- info.kind = classifyTool(name, args);
367
322
  if (invalidate) info.invalidate = invalidate;
368
323
  return info;
369
324
  }
370
325
 
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;
326
+ function recordToolStart(name: string, args: any) {
327
+ const base = name.split(".").pop() ?? name;
328
+ state.runStats.toolCount++;
329
+ if (base === "read") {
330
+ if (isNonEmptyString(args?.path)) state.runStats.readFiles.add(args.path);
331
+ } else if (base === "edit" || base === "write") {
332
+ if (isNonEmptyString(args?.path)) state.runStats.editFiles.add(args.path);
333
+ } else if (base === "bash") {
334
+ state.runStats.commandCount++;
335
+ } else {
336
+ state.runStats.otherCount++;
337
+ }
338
+ }
377
339
 
378
- if (!isEnabled() || info.kind !== "noise") {
379
- state.currentNoiseBurst = [];
340
+ function joinBurst(info: ToolInfo) {
341
+ const previous = state.currentBurst[state.currentBurst.length - 1];
342
+
343
+ if (!isEnabled()) {
344
+ state.currentBurst = [];
380
345
  return;
381
346
  }
382
347
 
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;
348
+ // Bursts only group repeats of the same tool; a different tool starts a
349
+ // fresh row (and leaves the previous row visible with its count).
350
+ if (state.currentBurst.length && state.currentBurst[state.currentBurst.length - 1].name !== info.name) {
351
+ state.currentBurst = [];
352
+ }
353
+
354
+ if (!state.currentBurst.some((tool) => tool.id === info.id)) state.currentBurst.push(info);
355
+ for (const tool of state.currentBurst.slice(0, -1)) setToolHidden(tool, true);
356
+ info.burstCount = state.currentBurst.length;
357
+ previous?.invalidate?.();
358
+ }
359
+
360
+ function beginTool(id: string, name: string, args: any) {
361
+ const info = upsertToolInfo(id, name, args);
362
+ setToolHidden(info, false);
363
+ info.burstCount = 1;
364
+ info.running = true;
365
+ info.isError = false;
366
+ info.startedAt = Date.now();
367
+ state.runningToolIds.add(id);
368
+ ensureBlinkTimer();
369
+ recordToolStart(name, args);
370
+ joinBurst(info);
371
+ // The component may already be on screen from argument streaming; repaint
372
+ // now so the running marker and burst count appear immediately instead of
373
+ // waiting for the next result event or blink tick.
374
+ info.invalidate?.();
375
+ }
386
376
 
387
- if (!isDebugMode()) {
388
- for (const tool of state.currentNoiseBurst.slice(0, -1)) setToolHidden(tool, true);
377
+ // A tool row rendered without a live tool_execution_start event — pi is
378
+ // rebuilding chat history after resume/reload. Reconstruct burst grouping so
379
+ // scrollback coalesces the same way the live view did, but skip timers,
380
+ // stats, and running state.
381
+ function hydrateTool(id: string, name: string, args: any, isError: boolean): ToolInfo {
382
+ const info = upsertToolInfo(id, name, args);
383
+ setToolHidden(info, false);
384
+ info.burstCount = 1;
385
+ if (isError) {
386
+ info.isError = true;
387
+ state.currentBurst = [];
388
+ return info;
389
389
  }
390
- info.burstCount = state.currentNoiseBurst.length;
391
- previousNoise?.invalidate?.();
390
+ joinBurst(info);
391
+ return info;
392
392
  }
393
393
 
394
394
  function updateToolResult(toolCallId: string, result: any, isError = false, isPartial = false) {
395
+ if (!isPartial) {
396
+ state.runningToolIds.delete(toolCallId);
397
+ if (state.runningToolIds.size === 0) stopBlinkTimer();
398
+ }
395
399
  const info = state.toolsById.get(toolCallId);
396
400
  if (!info) return;
401
+ if (!isPartial) {
402
+ info.running = false;
403
+ if (info.startedAt) info.durationMs = Date.now() - info.startedAt;
404
+ if (state.currentBurst.includes(info) && state.currentBurst.length > 1) {
405
+ info.burstDurationMs = state.currentBurst.reduce((total, tool) => total + (tool.durationMs ?? 0), 0);
406
+ }
407
+ if (isError) state.runStats.failedCount++;
408
+ }
397
409
  applyResult(info, result, isError, isPartial);
410
+ // A failure ends the current burst so the red row stays visible and the
411
+ // next tool starts a fresh count.
412
+ if (isError) state.currentBurst = [];
398
413
  info.invalidate?.();
399
414
  }
400
415
 
@@ -408,45 +423,28 @@ function compactToolLine(
408
423
  isError = false,
409
424
  isPartial = false,
410
425
  ): 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);
426
+ if (!state.toolsById.has(toolCallId)) hydrateTool(toolCallId, name, args, isError);
427
+ const info = upsertToolInfo(toolCallId, name, args, invalidate);
424
428
  applyResult(info, result, isError, isPartial);
425
429
  if (info.hidden) return "";
426
430
 
427
- const status = info.result ? ` {${oneLine(info.result)}}` : isPartial ? " {running}" : "";
431
+ const isBurst = (info.burstCount ?? 1) > 1;
432
+ const durationText = formatDuration((isBurst ? (info.burstDurationMs ?? info.durationMs) : info.durationMs) ?? 0);
433
+ const inner = [info.result ? oneLine(info.result) : "", durationText].filter(Boolean).join(" · ");
434
+ const status = inner ? ` {${inner}}` : info.running ? " {running}" : "";
428
435
  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", "· ") : "";
436
+ const marker = statusMarker(theme, {
437
+ running: info.running,
438
+ isError: info.isError,
439
+ hasResult: result != null || !!info.result,
440
+ });
441
+ if (!isBurst) {
431
442
  return marker + theme.fg("muted", limitPlain(details));
432
443
  }
433
444
 
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;
445
+ const prefix = `${info.burstCount}× `;
446
+ const budget = previewWidth((process.stdout.columns || 100) - prefix.length - MARKER_WIDTH);
447
+ return marker + theme.fg("muted", prefix + limitPlain(details, budget));
450
448
  }
451
449
 
452
450
  function patchToolExecutionComponent() {
@@ -461,24 +459,32 @@ function patchToolExecutionComponent() {
461
459
  proto.updateDisplay = function patchedUpdateDisplay() {
462
460
  if (!this.toolCallId || !this.toolName || !this.selfRenderContainer || typeof this.selfRenderContainer.clear !== "function") {
463
461
  this.__compactTranscriptForceSelf = false;
462
+ this.__compactTranscriptHidden = false;
464
463
  return originalUpdateDisplay.call(this);
465
464
  }
465
+ state.toolComponents.add(this);
466
466
 
467
467
  const invalidate = () => {
468
468
  this.invalidate();
469
469
  this.ui?.requestRender?.();
470
470
  };
471
+ // Must run before upsertToolInfo, which would make the row look
472
+ // already-known and skip burst reconstruction for rehydrated history.
473
+ if (!state.toolsById.has(this.toolCallId)) {
474
+ hydrateTool(this.toolCallId, this.toolName, this.args, this.result?.isError ?? false);
475
+ }
471
476
  const info = upsertToolInfo(this.toolCallId, this.toolName, this.args, invalidate);
472
477
  applyResult(info, this.result, this.result?.isError ?? false, this.isPartial);
473
478
 
474
- if (shouldUseOriginalToolRow(this, info)) {
479
+ if (!isEnabled() || this.expanded) {
475
480
  setToolHidden(info, false);
476
481
  this.__compactTranscriptForceSelf = false;
482
+ this.__compactTranscriptHidden = false;
477
483
  return originalUpdateDisplay.call(this);
478
484
  }
479
485
 
480
486
  this.__compactTranscriptForceSelf = true;
481
- this.hideComponent = false;
487
+ this.__compactTranscriptHidden = false;
482
488
  this.selfRenderContainer.clear();
483
489
  for (const image of this.imageComponents ?? []) this.removeChild?.(image);
484
490
  for (const spacer of this.imageSpacers ?? []) this.removeChild?.(spacer);
@@ -488,6 +494,7 @@ function patchToolExecutionComponent() {
488
494
  const theme = state.currentTheme;
489
495
  if (!theme) {
490
496
  this.__compactTranscriptForceSelf = false;
497
+ this.__compactTranscriptHidden = false;
491
498
  return originalUpdateDisplay.call(this);
492
499
  }
493
500
 
@@ -503,7 +510,7 @@ function patchToolExecutionComponent() {
503
510
  );
504
511
 
505
512
  if (!line) {
506
- this.hideComponent = true;
513
+ this.__compactTranscriptHidden = true;
507
514
  return;
508
515
  }
509
516
 
@@ -511,7 +518,7 @@ function patchToolExecutionComponent() {
511
518
  };
512
519
 
513
520
  proto.render = function patchedRender(width: number) {
514
- if (this.hideComponent) return [];
521
+ if (this.hideComponent || this.__compactTranscriptHidden) return [];
515
522
  if (this.__compactTranscriptForceSelf) return this.selfRenderContainer.render(width);
516
523
  return originalRender.call(this, width);
517
524
  };
@@ -526,82 +533,30 @@ function patchAssistantMessageComponent() {
526
533
  const originalUpdateContent = existing?.originalUpdateContent ?? proto.updateContent;
527
534
 
528
535
  proto.updateContent = function patchedUpdateContent(message: any) {
536
+ state.assistantComponents.add(this);
529
537
  if (!isEnabled() || !this.hideThinkingBlock || !Array.isArray(message?.content)) {
530
- this.__compactTranscriptHiddenThinkingSignal = false;
531
538
  return originalUpdateContent.call(this, message);
532
539
  }
533
540
  if (!this.contentContainer || typeof this.contentContainer.clear !== "function") {
534
- this.__compactTranscriptHiddenThinkingSignal = false;
535
541
  return originalUpdateContent.call(this, message);
536
542
  }
537
543
 
538
544
  this.lastMessage = message;
539
545
  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
546
  this.hasToolCalls = message.content.some((c: any) => c.type === "toolCall");
544
547
 
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?.();
559
- }
560
- state.lastThinkingSignalComponent = this;
561
- state.thinkingSignalCount++;
562
- } else if (!thinkingSignal && hasText) {
563
- resetThinkingSignals();
564
- }
565
- }
548
+ // Thinking blocks are suppressed entirely; only real text is rendered.
549
+ const texts = message.content.filter((c: any) => c.type === "text" && c.text?.trim());
550
+ if (texts.length === 0) return;
566
551
 
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;
552
+ // Assistant text ends a tool burst. The live path also does this via
553
+ // message_update events; doing it here too keeps hydrated history from
554
+ // grouping tool rows across turn boundaries.
555
+ state.currentBurst = [];
580
556
 
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));
557
+ this.contentContainer.addChild(new Spacer(1));
558
+ for (const content of texts) {
559
+ this.contentContainer.addChild(new Markdown(content.text.trim(), this.outputPad, 0, this.markdownTheme));
605
560
  }
606
561
  };
607
562
 
@@ -613,20 +568,76 @@ function patchRenderers() {
613
568
  patchAssistantMessageComponent();
614
569
  }
615
570
 
616
- function thinkingLabel() {
617
- return state.consecutiveThinking > 1 ? `${LABEL} (${state.consecutiveThinking}x)` : LABEL;
571
+ // Re-render every transcript row we have touched so toggling applies to the
572
+ // visible transcript immediately instead of only to future rows.
573
+ function refreshTranscript() {
574
+ let ui: any;
575
+ for (const component of state.toolComponents) {
576
+ try {
577
+ // ToolExecutionComponent.invalidate() re-runs updateDisplay.
578
+ component.invalidate?.();
579
+ ui ??= component.ui;
580
+ } catch {
581
+ state.toolComponents.delete(component);
582
+ }
583
+ }
584
+ for (const component of state.assistantComponents) {
585
+ try {
586
+ if (component.lastMessage) component.updateContent?.(component.lastMessage);
587
+ component.invalidate?.();
588
+ } catch {
589
+ state.assistantComponents.delete(component);
590
+ }
591
+ }
592
+ ui?.requestRender?.();
593
+ }
594
+
595
+ function summaryLine(data: SummaryData): string {
596
+ const plural = (count: number) => (count === 1 ? "" : "s");
597
+ const parts: string[] = [];
598
+ if (data.reads) parts.push(`read ${data.reads} file${plural(data.reads)}`);
599
+ if (data.edits) parts.push(`edited ${data.edits} file${plural(data.edits)}`);
600
+ if (data.commands) parts.push(`ran ${data.commands} command${plural(data.commands)}`);
601
+ if (data.others) parts.push(`${data.others} other tool${plural(data.others)}`);
602
+ if (data.failed) parts.push(`${data.failed} failed`);
603
+ if (parts.length === 0) return "";
604
+ const text = parts.join(", ");
605
+ const capitalized = text[0].toUpperCase() + text.slice(1);
606
+ const duration = formatDuration(data.durationMs);
607
+ return duration ? `${capitalized} · ${duration}` : capitalized;
608
+ }
609
+
610
+ function normalizeSummary(input: unknown): SummaryData {
611
+ const source = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
612
+ const num = (value: unknown) => (typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0);
613
+ return {
614
+ reads: num(source.reads),
615
+ edits: num(source.edits),
616
+ commands: num(source.commands),
617
+ others: num(source.others),
618
+ failed: num(source.failed),
619
+ durationMs: num(source.durationMs),
620
+ };
618
621
  }
619
622
 
620
- function applyThinkingLabel(ctx: ExtensionContext) {
621
- if (!isEnabled()) {
622
- ctx.ui.setHiddenThinkingLabel();
623
- return;
624
- }
625
- ctx.ui.setHiddenThinkingLabel(thinkingLabel());
623
+ function appendRunSummary(pi: ExtensionAPI) {
624
+ const stats = state.runStats;
625
+ // Single-tool runs are self-evident from the transcript; a summary line
626
+ // would just repeat the row above it.
627
+ if (!isEnabled() || stats.toolCount < 2) return;
628
+ const data: SummaryData = {
629
+ reads: stats.readFiles.size,
630
+ edits: stats.editFiles.size,
631
+ commands: stats.commandCount,
632
+ others: stats.otherCount,
633
+ failed: stats.failedCount,
634
+ durationMs: Date.now() - stats.startedAt,
635
+ };
636
+ pi.appendEntry(SUMMARY_ENTRY_TYPE, data);
626
637
  }
627
638
 
628
639
  function restoreConfigFromBranch(ctx: ExtensionContext) {
629
- let nextConfig = cloneConfig(DEFAULT_CONFIG);
640
+ let nextConfig = { ...DEFAULT_CONFIG };
630
641
  for (const entry of ctx.sessionManager.getBranch()) {
631
642
  if (entry.type === "custom" && entry.customType === CONFIG_ENTRY_TYPE) {
632
643
  nextConfig = normalizeConfig(entry.data);
@@ -635,8 +646,11 @@ function restoreConfigFromBranch(ctx: ExtensionContext) {
635
646
  state.config = nextConfig;
636
647
  }
637
648
 
638
- function persistConfig(pi: ExtensionAPI) {
639
- pi.appendEntry(CONFIG_ENTRY_TYPE, cloneConfig(state.config));
649
+ function setEnabled(enabled: boolean, pi: ExtensionAPI, ctx: ExtensionContext) {
650
+ state.config.enabled = enabled;
651
+ pi.appendEntry(CONFIG_ENTRY_TYPE, { ...state.config });
652
+ refreshTranscript();
653
+ ctx.ui.notify(`Compact transcript: ${enabled ? "on" : "off"}`, "info");
640
654
  }
641
655
 
642
656
  function parseBoolean(value: string | undefined): boolean | undefined {
@@ -647,372 +661,33 @@ function parseBoolean(value: string | undefined): boolean | undefined {
647
661
  return undefined;
648
662
  }
649
663
 
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;
682
- }
683
- return entries;
684
- }
685
-
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;
864
- }
865
- persistConfig(pi);
866
- applyThinkingLabel(ctx);
867
- }
868
-
869
- async function showSettingsPanel(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
870
- if (ctx.mode !== "tui") {
871
- ctx.ui.notify(describeConfig(), "info");
872
- return;
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
664
  function registerCommand(pi: ExtensionAPI) {
920
665
  pi.registerCommand("compact-transcript", {
921
- description: "Configure compact transcript rendering: disabled, balanced, aggressive, debug, and tool rules.",
666
+ description: "Toggle compact transcript rendering (on/off).",
922
667
  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
668
+ return ["on", "off", "status"]
943
669
  .filter((value) => value.startsWith(prefix.trimStart()))
944
670
  .map((value) => ({ value, label: value }));
945
671
  },
946
672
  handler: async (args, ctx) => {
947
673
  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);
674
+ const trimmed = args.trim().toLowerCase();
675
+ if (trimmed === "status") {
676
+ ctx.ui.notify(`Compact transcript: ${isEnabled() ? "on" : "off"}`, "info");
962
677
  return;
963
678
  }
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);
679
+ if (!trimmed) {
680
+ setEnabled(!isEnabled(), pi, ctx);
969
681
  return;
970
682
  }
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);
683
+ // on/off, plus pre-0.5 mode names as legacy aliases.
684
+ const legacyOn = ["balanced", "aggressive", "debug"].includes(trimmed);
685
+ const parsed = legacyOn ? true : parseBoolean(trimmed);
686
+ if (parsed !== undefined) {
687
+ setEnabled(parsed, pi, ctx);
998
688
  return;
999
689
  }
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");
690
+ ctx.ui.notify(`Unknown option "${trimmed}". Usage: /compact-transcript [on|off|status]`, "error");
1016
691
  },
1017
692
  });
1018
693
  }
@@ -1021,17 +696,29 @@ export default function compactTranscript(pi: ExtensionAPI) {
1021
696
  patchRenderers();
1022
697
  registerCommand(pi);
1023
698
 
699
+ pi.registerEntryRenderer<SummaryData>(SUMMARY_ENTRY_TYPE, (entry, _options, theme) => {
700
+ const line = summaryLine(normalizeSummary(entry.data));
701
+ if (!line) return undefined;
702
+ return new Text(theme.fg("muted", line), 0, 0);
703
+ });
704
+
1024
705
  pi.on("session_start", async (_event, ctx) => {
1025
706
  restoreConfigFromBranch(ctx);
1026
707
  captureTheme(ctx);
1027
- applyThinkingLabel(ctx);
708
+ // Drop all per-tool state and component registries from the previous
709
+ // session; stale burst counts otherwise corrupt the rebuilt transcript
710
+ // after resume or /reload.
711
+ resetToolRun();
712
+ state.runStats = newRunStats();
713
+ state.toolComponents = new Set();
714
+ state.assistantComponents = new Set();
1028
715
  ctx.ui.setWorkingMessage();
1029
716
  // Clear any footer status left behind by pre-0.4 versions of this extension.
1030
717
  ctx.ui.setStatus(STATUS_KEY, undefined);
1031
718
  });
1032
719
 
1033
720
  pi.on("session_shutdown", async (_event, ctx) => {
1034
- ctx.ui.setHiddenThinkingLabel();
721
+ stopBlinkTimer();
1035
722
  ctx.ui.setStatus(STATUS_KEY, undefined);
1036
723
  ctx.ui.setWorkingMessage();
1037
724
  });
@@ -1040,38 +727,27 @@ export default function compactTranscript(pi: ExtensionAPI) {
1040
727
  state.agentActive = true;
1041
728
  captureTheme(ctx);
1042
729
  resetToolRun();
1043
- resetThinkingSignals();
1044
- state.consecutiveThinking = 0;
1045
- applyThinkingLabel(ctx);
730
+ state.runStats = newRunStats();
1046
731
  });
1047
732
 
1048
733
  pi.on("agent_end", (_event, _ctx) => {
1049
734
  state.agentActive = false;
1050
- state.currentNoiseBurst = [];
1051
- resetThinkingSignals();
735
+ state.currentBurst = [];
736
+ state.runningToolIds.clear();
737
+ stopBlinkTimer();
738
+ appendRunSummary(pi);
1052
739
  });
1053
740
 
1054
741
  pi.on("turn_start", (_event, ctx) => {
1055
- state.consecutiveThinking = 0;
1056
742
  captureTheme(ctx);
1057
- applyThinkingLabel(ctx);
1058
743
  });
1059
744
 
1060
745
  pi.on("message_update", (event, ctx) => {
1061
746
  captureTheme(ctx);
1062
747
  const type = event.assistantMessageEvent?.type;
1063
- if (type === "thinking_start") {
1064
- state.consecutiveThinking++;
1065
- applyThinkingLabel(ctx);
1066
- return;
1067
- }
1068
- if (type && !type.startsWith("thinking_")) {
1069
- state.consecutiveThinking = 0;
1070
- applyThinkingLabel(ctx);
1071
- if (type === "text_delta" || type === "text_start") {
1072
- resetToolRun();
1073
- resetThinkingSignals();
1074
- }
748
+ if (type === "text_delta" || type === "text_start") {
749
+ // Visible assistant text ends the current tool burst.
750
+ state.currentBurst = [];
1075
751
  }
1076
752
  });
1077
753