pi-compact-transcript 0.4.0 → 0.6.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,104 +26,113 @@ 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;
73
+ thinkingHidden: boolean;
74
+ currentThoughtHeading?: string;
75
+ thoughtAnchorId?: string;
61
76
  };
62
77
 
63
78
  const DEFAULT_CONFIG: CompactTranscriptConfig = {
64
- mode: "balanced",
65
- compactCustomTools: true,
66
- showFailedTools: true,
67
- showBashMutations: true,
68
- alwaysShowTools: [],
69
- mutationTools: [],
70
- previewTemplates: {},
79
+ enabled: true,
71
80
  };
72
81
 
73
82
  const STATE_KEY = Symbol.for("pi-compact-transcript.state");
74
83
  const TOOL_PATCH_KEY = Symbol.for("pi-compact-transcript.tool-patch");
75
84
  const ASSISTANT_PATCH_KEY = Symbol.for("pi-compact-transcript.assistant-patch");
76
85
 
77
- function cloneConfig(config: CompactTranscriptConfig): CompactTranscriptConfig {
86
+ function newRunStats(): RunStats {
78
87
  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 },
88
+ startedAt: Date.now(),
89
+ toolCount: 0,
90
+ readFiles: new Set(),
91
+ editFiles: new Set(),
92
+ commandCount: 0,
93
+ otherCount: 0,
94
+ failedCount: 0,
86
95
  };
87
96
  }
88
97
 
89
98
  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
- };
99
+ const source = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
100
+ let enabled = DEFAULT_CONFIG.enabled;
101
+ if (typeof source.enabled === "boolean") {
102
+ enabled = source.enabled;
103
+ } else if (typeof source.mode === "string") {
104
+ // Pre-0.5 config persisted a mode string instead of an enabled flag.
105
+ enabled = source.mode !== "disabled" && source.mode !== "off";
106
+ }
107
+ return { enabled };
116
108
  }
117
109
 
118
110
  function getState(): RuntimeState {
119
111
  const globalWithState = globalThis as typeof globalThis & { [STATE_KEY]?: RuntimeState };
120
112
  globalWithState[STATE_KEY] ??= {
121
- config: cloneConfig(DEFAULT_CONFIG),
113
+ config: { ...DEFAULT_CONFIG },
122
114
  toolsById: new Map(),
123
- currentNoiseBurst: [],
115
+ currentBurst: [],
124
116
  hiddenToolIds: new Set(),
117
+ runningToolIds: new Set(),
125
118
  agentActive: false,
126
- thinkingSignalCount: 0,
127
- consecutiveThinking: 0,
119
+ blinkOn: true,
120
+ runStats: newRunStats(),
121
+ toolComponents: new Set(),
122
+ assistantComponents: new Set(),
123
+ thinkingHidden: true,
128
124
  };
129
- return globalWithState[STATE_KEY]!;
125
+ const runtimeState = globalWithState[STATE_KEY]!;
126
+ // /reload keeps the global object alive; initialize fields added by newer
127
+ // versions when an older extension instance created the state object.
128
+ runtimeState.thinkingHidden ??= true;
129
+ return runtimeState;
130
130
  }
131
131
 
132
132
  const state = getState();
133
133
 
134
134
  function isEnabled(): boolean {
135
- return state.config.mode !== "disabled";
136
- }
137
-
138
- function isDebugMode(): boolean {
139
- return state.config.mode === "debug";
135
+ return state.config.enabled;
140
136
  }
141
137
 
142
138
  function isNonEmptyString(value: unknown): value is string {
@@ -156,13 +152,7 @@ function oneLine(value: unknown): string {
156
152
  }
157
153
 
158
154
  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));
155
+ return Math.max(MIN_PREVIEW_WIDTH, Math.min(MAX_PREVIEW_WIDTH, base - PREVIEW_MARGIN));
166
156
  }
167
157
 
168
158
  function limitPlain(text: string, max = previewWidth()): string {
@@ -183,91 +173,36 @@ function safeJson(value: unknown): string {
183
173
  }
184
174
  }
185
175
 
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
- }
176
+ // Sub-second durations render as "" so fast tools stay clutter-free.
177
+ function formatDuration(ms: number): string {
178
+ if (!Number.isFinite(ms) || ms < 1000) return "";
179
+ const totalSeconds = Math.round(ms / 1000);
180
+ if (totalSeconds < 60) return `${totalSeconds}s`;
181
+ const minutes = Math.floor(totalSeconds / 60);
182
+ const seconds = totalSeconds % 60;
183
+ return seconds ? `${minutes}m${seconds}s` : `${minutes}m`;
184
+ }
185
+
186
+ // Checked in order; earlier keys are more likely to be the argument a human
187
+ // would recognize the call by.
188
+ const PREFERRED_ARG_KEYS = [
189
+ "command",
190
+ "code",
191
+ "query",
192
+ "pattern",
193
+ "path",
194
+ "file_path",
195
+ "filePath",
196
+ "file",
197
+ "url",
198
+ "prompt",
199
+ "text",
200
+ "description",
201
+ "name",
202
+ ];
203
+ const PATH_ARG_KEYS = new Set(["path", "file_path", "filePath", "file"]);
266
204
 
267
205
  function previewFor(name: string, args: any): string {
268
- const template = findTemplate(name);
269
- if (template) return renderTemplate(template, name, args);
270
-
271
206
  switch (name) {
272
207
  case "bash":
273
208
  return `$ ${oneLine(args?.command || "...")}`;
@@ -297,8 +232,22 @@ function previewFor(name: string, args: any): string {
297
232
  return `find ${args?.pattern ? quote(String(args.pattern)) : "..."} ${shortenPath(args?.path) || "."}`;
298
233
  case "ls":
299
234
  return `ls ${shortenPath(args?.path) || "."}`;
300
- default:
235
+ default: {
236
+ // Unknown tools: show the most meaningful string argument instead of
237
+ // dumping the whole args object as JSON.
238
+ if (args && typeof args === "object") {
239
+ for (const key of PREFERRED_ARG_KEYS) {
240
+ const value = (args as Record<string, unknown>)[key];
241
+ if (isNonEmptyString(value)) {
242
+ const rendered = PATH_ARG_KEYS.has(key) ? shortenPath(value) : oneLine(value);
243
+ return `${name} ${rendered}`;
244
+ }
245
+ }
246
+ const firstString = Object.values(args).find(isNonEmptyString);
247
+ if (firstString) return `${name} ${oneLine(firstString)}`;
248
+ }
301
249
  return `${name} ${safeJson(args ?? {})}`;
250
+ }
302
251
  }
303
252
  }
304
253
 
@@ -313,26 +262,14 @@ function resultPreview(result: any, isPartial = false): string {
313
262
  return `${lines.length} lines`;
314
263
  }
315
264
 
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
265
  function resetToolRun() {
328
266
  state.toolsById = new Map();
329
- state.currentNoiseBurst = [];
267
+ state.currentBurst = [];
330
268
  state.hiddenToolIds = new Set();
331
- }
332
-
333
- function resetThinkingSignals() {
334
- state.lastThinkingSignalComponent = undefined;
335
- state.thinkingSignalCount = 0;
269
+ state.runningToolIds = new Set();
270
+ state.currentThoughtHeading = undefined;
271
+ state.thoughtAnchorId = undefined;
272
+ stopBlinkTimer();
336
273
  }
337
274
 
338
275
  function captureTheme(ctx: ExtensionContext) {
@@ -350,51 +287,244 @@ function applyResult(info: ToolInfo, result: any, isError: boolean, isPartial: b
350
287
  if (suffix) info.result = suffix;
351
288
  if (isError) {
352
289
  info.isError = true;
290
+ // A failed tool always gets its own visible row, even if a burst had
291
+ // hidden it; render it as itself rather than as a burst summary.
292
+ info.burstCount = 1;
353
293
  setToolHidden(info, false);
354
294
  }
355
295
  }
356
296
 
297
+ function ensureBlinkTimer() {
298
+ if (state.blinkTimer || state.runningToolIds.size === 0) return;
299
+ state.blinkTimer = setInterval(() => {
300
+ if (state.runningToolIds.size === 0) {
301
+ stopBlinkTimer();
302
+ return;
303
+ }
304
+ state.blinkOn = !state.blinkOn;
305
+ for (const id of state.runningToolIds) state.toolsById.get(id)?.invalidate?.();
306
+ }, BLINK_INTERVAL_MS);
307
+ state.blinkTimer.unref?.();
308
+ }
309
+
310
+ function stopBlinkTimer() {
311
+ if (state.blinkTimer) clearInterval(state.blinkTimer);
312
+ state.blinkTimer = undefined;
313
+ state.blinkOn = true;
314
+ }
315
+
316
+ function statusMarker(theme: Theme, opts: { running?: boolean; isError?: boolean; hasResult?: boolean }): string {
317
+ if (opts.isError) return theme.fg("error", "◆ ");
318
+ if (opts.running) return theme.fg("dim", state.blinkOn ? "◆ " : "◇ ");
319
+ if (opts.hasResult) return theme.fg("success", "◆ ");
320
+ return theme.fg("dim", "◆ ");
321
+ }
322
+
323
+ function textSignalHasVisibleContent(assistantMessageEvent: any): boolean {
324
+ const type = assistantMessageEvent?.type;
325
+ if (type === "text_delta") {
326
+ return typeof assistantMessageEvent.delta === "string" && assistantMessageEvent.delta.trim().length > 0;
327
+ }
328
+ if (type === "text_end") {
329
+ return typeof assistantMessageEvent.content === "string" && assistantMessageEvent.content.trim().length > 0;
330
+ }
331
+ return false;
332
+ }
333
+
334
+ function thoughtTickerEnabled(): boolean {
335
+ // The ticker is a compact replacement for hidden thinking. When thinking is
336
+ // fully visible, showing the same headline under a tool would be duplicate.
337
+ return isEnabled() && state.thinkingHidden;
338
+ }
339
+
340
+ function cleanThoughtHeading(line: string): string {
341
+ let clean = oneLine(line)
342
+ .replace(/^#{1,6}\s+/, "")
343
+ .replace(/^[-*]\s+/, "")
344
+ .trim();
345
+ clean = clean
346
+ .replace(/^\*\*(.+)\*\*$/, "$1")
347
+ .replace(/^__(.+)__$/, "$1")
348
+ .replace(/^`(.+)`$/, "$1");
349
+ return clean.trim();
350
+ }
351
+
352
+ function extractThoughtHeading(text: unknown): string {
353
+ if (typeof text !== "string") return "";
354
+ const firstLine = text.split(/\r?\n/).find((line) => line.trim().length > 0);
355
+ return firstLine ? cleanThoughtHeading(firstLine) : "";
356
+ }
357
+
358
+ function latestThoughtHeading(message: any): string {
359
+ if (!Array.isArray(message?.content)) return "";
360
+ for (let i = message.content.length - 1; i >= 0; i--) {
361
+ const content = message.content[i];
362
+ if (content?.type === "thinking") return extractThoughtHeading(content.thinking);
363
+ }
364
+ return "";
365
+ }
366
+
367
+ function invalidateToolById(id: string | undefined) {
368
+ if (!id) return;
369
+ state.toolsById.get(id)?.invalidate?.();
370
+ }
371
+
372
+ function latestVisibleTool(): ToolInfo | undefined {
373
+ return Array.from(state.toolsById.values())
374
+ .reverse()
375
+ .find((tool) => !tool.hidden);
376
+ }
377
+
378
+ function clearCurrentThought() {
379
+ if (!state.currentThoughtHeading && !state.thoughtAnchorId) return;
380
+ const previousAnchorId = state.thoughtAnchorId;
381
+ state.currentThoughtHeading = undefined;
382
+ state.thoughtAnchorId = undefined;
383
+ invalidateToolById(previousAnchorId);
384
+ }
385
+
386
+ function setCurrentThought(heading: string) {
387
+ const nextHeading = oneLine(heading);
388
+ if (!thoughtTickerEnabled() || !nextHeading) {
389
+ clearCurrentThought();
390
+ return;
391
+ }
392
+
393
+ const previousAnchorId = state.thoughtAnchorId;
394
+ const nextAnchorId = latestVisibleTool()?.id;
395
+ const changed = state.currentThoughtHeading !== nextHeading || previousAnchorId !== nextAnchorId;
396
+ state.currentThoughtHeading = nextHeading;
397
+ state.thoughtAnchorId = nextAnchorId;
398
+ if (!changed) return;
399
+
400
+ invalidateToolById(previousAnchorId);
401
+ if (nextAnchorId !== previousAnchorId) invalidateToolById(nextAnchorId);
402
+ }
403
+
404
+ function updateCurrentThoughtFromMessage(message: any) {
405
+ const heading = latestThoughtHeading(message);
406
+ // A new thinking block starts empty; keep showing the previous heading until
407
+ // the replacement has real text so the ticker does not blink off/on between
408
+ // tool completion and the next streamed thought.
409
+ if (heading) setCurrentThought(heading);
410
+ }
411
+
412
+ function anchorCurrentThoughtTo(info: ToolInfo) {
413
+ if (!thoughtTickerEnabled() || !state.currentThoughtHeading || state.thoughtAnchorId === info.id) return;
414
+ const previousAnchorId = state.thoughtAnchorId;
415
+ state.thoughtAnchorId = info.id;
416
+ invalidateToolById(previousAnchorId);
417
+ info.invalidate?.();
418
+ }
419
+
420
+ function currentThoughtLine(toolCallId: string, theme: Theme): string {
421
+ if (!thoughtTickerEnabled() || state.thoughtAnchorId !== toolCallId || !state.currentThoughtHeading) return "";
422
+ const prefix = " ↳ ";
423
+ const budget = previewWidth((process.stdout.columns || 100) - prefix.length);
424
+ return theme.fg("dim", prefix) + theme.fg("thinkingText", limitPlain(state.currentThoughtHeading, budget));
425
+ }
426
+
357
427
  function upsertToolInfo(id: string, name: string, args: any, invalidate?: () => void): ToolInfo {
358
428
  let info = state.toolsById.get(id);
359
429
  if (!info) {
360
- info = { id, name, args, preview: previewFor(name, args), kind: classifyTool(name, args) };
430
+ info = { id, name, args, preview: previewFor(name, args) };
361
431
  state.toolsById.set(id, info);
362
432
  }
363
433
  info.name = name;
364
434
  info.args = args;
365
435
  info.preview = previewFor(name, args);
366
- info.kind = classifyTool(name, args);
367
436
  if (invalidate) info.invalidate = invalidate;
368
437
  return info;
369
438
  }
370
439
 
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;
440
+ function recordToolStart(name: string, args: any) {
441
+ const base = name.split(".").pop() ?? name;
442
+ state.runStats.toolCount++;
443
+ if (base === "read") {
444
+ if (isNonEmptyString(args?.path)) state.runStats.readFiles.add(args.path);
445
+ } else if (base === "edit" || base === "write") {
446
+ if (isNonEmptyString(args?.path)) state.runStats.editFiles.add(args.path);
447
+ } else if (base === "bash") {
448
+ state.runStats.commandCount++;
449
+ } else {
450
+ state.runStats.otherCount++;
451
+ }
452
+ }
377
453
 
378
- if (!isEnabled() || info.kind !== "noise") {
379
- state.currentNoiseBurst = [];
454
+ function joinBurst(info: ToolInfo) {
455
+ const previous = state.currentBurst[state.currentBurst.length - 1];
456
+
457
+ if (!isEnabled()) {
458
+ state.currentBurst = [];
380
459
  return;
381
460
  }
382
461
 
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;
462
+ // Bursts only group repeats of the same tool; a different tool starts a
463
+ // fresh row (and leaves the previous row visible with its count).
464
+ if (state.currentBurst.length && state.currentBurst[state.currentBurst.length - 1].name !== info.name) {
465
+ state.currentBurst = [];
466
+ }
467
+
468
+ if (!state.currentBurst.some((tool) => tool.id === info.id)) state.currentBurst.push(info);
469
+ for (const tool of state.currentBurst.slice(0, -1)) setToolHidden(tool, true);
470
+ info.burstCount = state.currentBurst.length;
471
+ previous?.invalidate?.();
472
+ }
386
473
 
387
- if (!isDebugMode()) {
388
- for (const tool of state.currentNoiseBurst.slice(0, -1)) setToolHidden(tool, true);
474
+ function beginTool(id: string, name: string, args: any) {
475
+ const info = upsertToolInfo(id, name, args);
476
+ setToolHidden(info, false);
477
+ info.burstCount = 1;
478
+ info.running = true;
479
+ info.isError = false;
480
+ info.startedAt = Date.now();
481
+ state.runningToolIds.add(id);
482
+ ensureBlinkTimer();
483
+ recordToolStart(name, args);
484
+ joinBurst(info);
485
+ anchorCurrentThoughtTo(info);
486
+ // The component may already be on screen from argument streaming; repaint
487
+ // now so the running marker, burst count, and thought ticker appear
488
+ // immediately instead of waiting for the next result event or blink tick.
489
+ info.invalidate?.();
490
+ }
491
+
492
+ // A tool row rendered without a live tool_execution_start event — pi is
493
+ // rebuilding chat history after resume/reload. Reconstruct burst grouping so
494
+ // scrollback coalesces the same way the live view did, but skip timers,
495
+ // stats, and running state.
496
+ function hydrateTool(id: string, name: string, args: any, isError: boolean): ToolInfo {
497
+ const info = upsertToolInfo(id, name, args);
498
+ setToolHidden(info, false);
499
+ info.burstCount = 1;
500
+ if (isError) {
501
+ info.isError = true;
502
+ state.currentBurst = [];
503
+ return info;
389
504
  }
390
- info.burstCount = state.currentNoiseBurst.length;
391
- previousNoise?.invalidate?.();
505
+ joinBurst(info);
506
+ return info;
392
507
  }
393
508
 
394
509
  function updateToolResult(toolCallId: string, result: any, isError = false, isPartial = false) {
510
+ if (!isPartial) {
511
+ state.runningToolIds.delete(toolCallId);
512
+ if (state.runningToolIds.size === 0) stopBlinkTimer();
513
+ }
395
514
  const info = state.toolsById.get(toolCallId);
396
515
  if (!info) return;
516
+ if (!isPartial) {
517
+ info.running = false;
518
+ if (info.startedAt) info.durationMs = Date.now() - info.startedAt;
519
+ if (state.currentBurst.includes(info) && state.currentBurst.length > 1) {
520
+ info.burstDurationMs = state.currentBurst.reduce((total, tool) => total + (tool.durationMs ?? 0), 0);
521
+ }
522
+ if (isError) state.runStats.failedCount++;
523
+ }
397
524
  applyResult(info, result, isError, isPartial);
525
+ // A failure ends the current burst so the red row stays visible and the
526
+ // next tool starts a fresh count.
527
+ if (isError) state.currentBurst = [];
398
528
  info.invalidate?.();
399
529
  }
400
530
 
@@ -408,45 +538,28 @@ function compactToolLine(
408
538
  isError = false,
409
539
  isPartial = false,
410
540
  ): 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);
541
+ if (!state.toolsById.has(toolCallId)) hydrateTool(toolCallId, name, args, isError);
542
+ const info = upsertToolInfo(toolCallId, name, args, invalidate);
424
543
  applyResult(info, result, isError, isPartial);
425
544
  if (info.hidden) return "";
426
545
 
427
- const status = info.result ? ` {${oneLine(info.result)}}` : isPartial ? " {running}" : "";
546
+ const isBurst = (info.burstCount ?? 1) > 1;
547
+ const durationText = formatDuration((isBurst ? (info.burstDurationMs ?? info.durationMs) : info.durationMs) ?? 0);
548
+ const inner = [info.result ? oneLine(info.result) : "", durationText].filter(Boolean).join(" · ");
549
+ const status = inner ? ` {${inner}}` : info.running ? " {running}" : "";
428
550
  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", "· ") : "";
551
+ const marker = statusMarker(theme, {
552
+ running: info.running,
553
+ isError: info.isError,
554
+ hasResult: result != null || !!info.result,
555
+ });
556
+ if (!isBurst) {
431
557
  return marker + theme.fg("muted", limitPlain(details));
432
558
  }
433
559
 
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;
560
+ const prefix = `${info.burstCount}× `;
561
+ const budget = previewWidth((process.stdout.columns || 100) - prefix.length - MARKER_WIDTH);
562
+ return marker + theme.fg("muted", prefix + limitPlain(details, budget));
450
563
  }
451
564
 
452
565
  function patchToolExecutionComponent() {
@@ -461,24 +574,32 @@ function patchToolExecutionComponent() {
461
574
  proto.updateDisplay = function patchedUpdateDisplay() {
462
575
  if (!this.toolCallId || !this.toolName || !this.selfRenderContainer || typeof this.selfRenderContainer.clear !== "function") {
463
576
  this.__compactTranscriptForceSelf = false;
577
+ this.__compactTranscriptHidden = false;
464
578
  return originalUpdateDisplay.call(this);
465
579
  }
580
+ state.toolComponents.add(this);
466
581
 
467
582
  const invalidate = () => {
468
583
  this.invalidate();
469
584
  this.ui?.requestRender?.();
470
585
  };
586
+ // Must run before upsertToolInfo, which would make the row look
587
+ // already-known and skip burst reconstruction for rehydrated history.
588
+ if (!state.toolsById.has(this.toolCallId)) {
589
+ hydrateTool(this.toolCallId, this.toolName, this.args, this.result?.isError ?? false);
590
+ }
471
591
  const info = upsertToolInfo(this.toolCallId, this.toolName, this.args, invalidate);
472
592
  applyResult(info, this.result, this.result?.isError ?? false, this.isPartial);
473
593
 
474
- if (shouldUseOriginalToolRow(this, info)) {
594
+ if (!isEnabled() || this.expanded) {
475
595
  setToolHidden(info, false);
476
596
  this.__compactTranscriptForceSelf = false;
597
+ this.__compactTranscriptHidden = false;
477
598
  return originalUpdateDisplay.call(this);
478
599
  }
479
600
 
480
601
  this.__compactTranscriptForceSelf = true;
481
- this.hideComponent = false;
602
+ this.__compactTranscriptHidden = false;
482
603
  this.selfRenderContainer.clear();
483
604
  for (const image of this.imageComponents ?? []) this.removeChild?.(image);
484
605
  for (const spacer of this.imageSpacers ?? []) this.removeChild?.(spacer);
@@ -488,6 +609,7 @@ function patchToolExecutionComponent() {
488
609
  const theme = state.currentTheme;
489
610
  if (!theme) {
490
611
  this.__compactTranscriptForceSelf = false;
612
+ this.__compactTranscriptHidden = false;
491
613
  return originalUpdateDisplay.call(this);
492
614
  }
493
615
 
@@ -503,15 +625,17 @@ function patchToolExecutionComponent() {
503
625
  );
504
626
 
505
627
  if (!line) {
506
- this.hideComponent = true;
628
+ this.__compactTranscriptHidden = true;
507
629
  return;
508
630
  }
509
631
 
510
632
  this.selfRenderContainer.addChild(new Text(line, 0, 0));
633
+ const thoughtLine = currentThoughtLine(this.toolCallId, theme);
634
+ if (thoughtLine) this.selfRenderContainer.addChild(new Text(thoughtLine, 0, 0));
511
635
  };
512
636
 
513
637
  proto.render = function patchedRender(width: number) {
514
- if (this.hideComponent) return [];
638
+ if (this.hideComponent || this.__compactTranscriptHidden) return [];
515
639
  if (this.__compactTranscriptForceSelf) return this.selfRenderContainer.render(width);
516
640
  return originalRender.call(this, width);
517
641
  };
@@ -526,82 +650,33 @@ function patchAssistantMessageComponent() {
526
650
  const originalUpdateContent = existing?.originalUpdateContent ?? proto.updateContent;
527
651
 
528
652
  proto.updateContent = function patchedUpdateContent(message: any) {
653
+ state.assistantComponents.add(this);
654
+ state.thinkingHidden = !!this.hideThinkingBlock;
655
+ if (!state.thinkingHidden) clearCurrentThought();
529
656
  if (!isEnabled() || !this.hideThinkingBlock || !Array.isArray(message?.content)) {
530
- this.__compactTranscriptHiddenThinkingSignal = false;
531
657
  return originalUpdateContent.call(this, message);
532
658
  }
533
659
  if (!this.contentContainer || typeof this.contentContainer.clear !== "function") {
534
- this.__compactTranscriptHiddenThinkingSignal = false;
535
660
  return originalUpdateContent.call(this, message);
536
661
  }
537
662
 
538
663
  this.lastMessage = message;
539
664
  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
665
  this.hasToolCalls = message.content.some((c: any) => c.type === "toolCall");
544
666
 
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
- }
566
-
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
- }
667
+ // Thinking blocks are suppressed entirely; only real text is rendered.
668
+ const texts = message.content.filter((c: any) => c.type === "text" && c.text?.trim());
669
+ if (texts.length === 0) return;
592
670
 
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
- }
671
+ clearCurrentThought();
672
+ // Assistant text ends a tool burst. The live path also does this via
673
+ // message_update events; doing it here too keeps hydrated history from
674
+ // grouping tool rows across turn boundaries.
675
+ state.currentBurst = [];
600
676
 
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));
677
+ this.contentContainer.addChild(new Spacer(1));
678
+ for (const content of texts) {
679
+ this.contentContainer.addChild(new Markdown(content.text.trim(), this.outputPad, 0, this.markdownTheme));
605
680
  }
606
681
  };
607
682
 
@@ -613,20 +688,76 @@ function patchRenderers() {
613
688
  patchAssistantMessageComponent();
614
689
  }
615
690
 
616
- function thinkingLabel() {
617
- return state.consecutiveThinking > 1 ? `${LABEL} (${state.consecutiveThinking}x)` : LABEL;
691
+ // Re-render every transcript row we have touched so toggling applies to the
692
+ // visible transcript immediately instead of only to future rows.
693
+ function refreshTranscript() {
694
+ let ui: any;
695
+ for (const component of state.toolComponents) {
696
+ try {
697
+ // ToolExecutionComponent.invalidate() re-runs updateDisplay.
698
+ component.invalidate?.();
699
+ ui ??= component.ui;
700
+ } catch {
701
+ state.toolComponents.delete(component);
702
+ }
703
+ }
704
+ for (const component of state.assistantComponents) {
705
+ try {
706
+ if (component.lastMessage) component.updateContent?.(component.lastMessage);
707
+ component.invalidate?.();
708
+ } catch {
709
+ state.assistantComponents.delete(component);
710
+ }
711
+ }
712
+ ui?.requestRender?.();
713
+ }
714
+
715
+ function summaryLine(data: SummaryData): string {
716
+ const plural = (count: number) => (count === 1 ? "" : "s");
717
+ const parts: string[] = [];
718
+ if (data.reads) parts.push(`read ${data.reads} file${plural(data.reads)}`);
719
+ if (data.edits) parts.push(`edited ${data.edits} file${plural(data.edits)}`);
720
+ if (data.commands) parts.push(`ran ${data.commands} command${plural(data.commands)}`);
721
+ if (data.others) parts.push(`${data.others} other tool${plural(data.others)}`);
722
+ if (data.failed) parts.push(`${data.failed} failed`);
723
+ if (parts.length === 0) return "";
724
+ const text = parts.join(", ");
725
+ const capitalized = text[0].toUpperCase() + text.slice(1);
726
+ const duration = formatDuration(data.durationMs);
727
+ return duration ? `${capitalized} · ${duration}` : capitalized;
728
+ }
729
+
730
+ function normalizeSummary(input: unknown): SummaryData {
731
+ const source = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
732
+ const num = (value: unknown) => (typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0);
733
+ return {
734
+ reads: num(source.reads),
735
+ edits: num(source.edits),
736
+ commands: num(source.commands),
737
+ others: num(source.others),
738
+ failed: num(source.failed),
739
+ durationMs: num(source.durationMs),
740
+ };
618
741
  }
619
742
 
620
- function applyThinkingLabel(ctx: ExtensionContext) {
621
- if (!isEnabled()) {
622
- ctx.ui.setHiddenThinkingLabel();
623
- return;
624
- }
625
- ctx.ui.setHiddenThinkingLabel(thinkingLabel());
743
+ function appendRunSummary(pi: ExtensionAPI) {
744
+ const stats = state.runStats;
745
+ // Single-tool runs are self-evident from the transcript; a summary line
746
+ // would just repeat the row above it.
747
+ if (!isEnabled() || stats.toolCount < 2) return;
748
+ const data: SummaryData = {
749
+ reads: stats.readFiles.size,
750
+ edits: stats.editFiles.size,
751
+ commands: stats.commandCount,
752
+ others: stats.otherCount,
753
+ failed: stats.failedCount,
754
+ durationMs: Date.now() - stats.startedAt,
755
+ };
756
+ pi.appendEntry(SUMMARY_ENTRY_TYPE, data);
626
757
  }
627
758
 
628
759
  function restoreConfigFromBranch(ctx: ExtensionContext) {
629
- let nextConfig = cloneConfig(DEFAULT_CONFIG);
760
+ let nextConfig = { ...DEFAULT_CONFIG };
630
761
  for (const entry of ctx.sessionManager.getBranch()) {
631
762
  if (entry.type === "custom" && entry.customType === CONFIG_ENTRY_TYPE) {
632
763
  nextConfig = normalizeConfig(entry.data);
@@ -635,8 +766,11 @@ function restoreConfigFromBranch(ctx: ExtensionContext) {
635
766
  state.config = nextConfig;
636
767
  }
637
768
 
638
- function persistConfig(pi: ExtensionAPI) {
639
- pi.appendEntry(CONFIG_ENTRY_TYPE, cloneConfig(state.config));
769
+ function setEnabled(enabled: boolean, pi: ExtensionAPI, ctx: ExtensionContext) {
770
+ state.config.enabled = enabled;
771
+ pi.appendEntry(CONFIG_ENTRY_TYPE, { ...state.config });
772
+ refreshTranscript();
773
+ ctx.ui.notify(`Compact transcript: ${enabled ? "on" : "off"}`, "info");
640
774
  }
641
775
 
642
776
  function parseBoolean(value: string | undefined): boolean | undefined {
@@ -647,372 +781,33 @@ function parseBoolean(value: string | undefined): boolean | undefined {
647
781
  return undefined;
648
782
  }
649
783
 
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
784
  function registerCommand(pi: ExtensionAPI) {
920
785
  pi.registerCommand("compact-transcript", {
921
- description: "Configure compact transcript rendering: disabled, balanced, aggressive, debug, and tool rules.",
786
+ description: "Toggle compact transcript rendering (on/off).",
922
787
  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
788
+ return ["on", "off", "status"]
943
789
  .filter((value) => value.startsWith(prefix.trimStart()))
944
790
  .map((value) => ({ value, label: value }));
945
791
  },
946
792
  handler: async (args, ctx) => {
947
793
  captureTheme(ctx);
948
- const trimmed = args.trim();
949
- if (trimmed === "help" && ctx.mode !== "tui") {
950
- ctx.ui.notify(commandHelp(), "info");
794
+ const trimmed = args.trim().toLowerCase();
795
+ if (trimmed === "status") {
796
+ ctx.ui.notify(`Compact transcript: ${isEnabled() ? "on" : "off"}`, "info");
951
797
  return;
952
798
  }
953
- if (!trimmed || trimmed === "status" || trimmed === "help") {
954
- await showSettingsPanel(pi, ctx);
799
+ if (!trimmed) {
800
+ setEnabled(!isEnabled(), pi, ctx);
955
801
  return;
956
802
  }
957
- if (trimmed === "reset") {
958
- state.config = cloneConfig(DEFAULT_CONFIG);
959
- persistConfig(pi);
960
- applyThinkingLabel(ctx);
961
- await showSettingsPanel(pi, ctx);
803
+ // on/off, plus pre-0.5 mode names as legacy aliases.
804
+ const legacyOn = ["balanced", "aggressive", "debug"].includes(trimmed);
805
+ const parsed = legacyOn ? true : parseBoolean(trimmed);
806
+ if (parsed !== undefined) {
807
+ setEnabled(parsed, pi, ctx);
962
808
  return;
963
809
  }
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");
810
+ ctx.ui.notify(`Unknown option "${trimmed}". Usage: /compact-transcript [on|off|status]`, "error");
1016
811
  },
1017
812
  });
1018
813
  }
@@ -1021,17 +816,29 @@ export default function compactTranscript(pi: ExtensionAPI) {
1021
816
  patchRenderers();
1022
817
  registerCommand(pi);
1023
818
 
819
+ pi.registerEntryRenderer<SummaryData>(SUMMARY_ENTRY_TYPE, (entry, _options, theme) => {
820
+ const line = summaryLine(normalizeSummary(entry.data));
821
+ if (!line) return undefined;
822
+ return new Text(theme.fg("muted", line), 0, 0);
823
+ });
824
+
1024
825
  pi.on("session_start", async (_event, ctx) => {
1025
826
  restoreConfigFromBranch(ctx);
1026
827
  captureTheme(ctx);
1027
- applyThinkingLabel(ctx);
828
+ // Drop all per-tool state and component registries from the previous
829
+ // session; stale burst counts otherwise corrupt the rebuilt transcript
830
+ // after resume or /reload.
831
+ resetToolRun();
832
+ state.runStats = newRunStats();
833
+ state.toolComponents = new Set();
834
+ state.assistantComponents = new Set();
1028
835
  ctx.ui.setWorkingMessage();
1029
836
  // Clear any footer status left behind by pre-0.4 versions of this extension.
1030
837
  ctx.ui.setStatus(STATUS_KEY, undefined);
1031
838
  });
1032
839
 
1033
840
  pi.on("session_shutdown", async (_event, ctx) => {
1034
- ctx.ui.setHiddenThinkingLabel();
841
+ stopBlinkTimer();
1035
842
  ctx.ui.setStatus(STATUS_KEY, undefined);
1036
843
  ctx.ui.setWorkingMessage();
1037
844
  });
@@ -1040,38 +847,34 @@ export default function compactTranscript(pi: ExtensionAPI) {
1040
847
  state.agentActive = true;
1041
848
  captureTheme(ctx);
1042
849
  resetToolRun();
1043
- resetThinkingSignals();
1044
- state.consecutiveThinking = 0;
1045
- applyThinkingLabel(ctx);
850
+ state.runStats = newRunStats();
1046
851
  });
1047
852
 
1048
853
  pi.on("agent_end", (_event, _ctx) => {
1049
854
  state.agentActive = false;
1050
- state.currentNoiseBurst = [];
1051
- resetThinkingSignals();
855
+ state.currentBurst = [];
856
+ clearCurrentThought();
857
+ state.runningToolIds.clear();
858
+ stopBlinkTimer();
859
+ appendRunSummary(pi);
1052
860
  });
1053
861
 
1054
862
  pi.on("turn_start", (_event, ctx) => {
1055
- state.consecutiveThinking = 0;
1056
863
  captureTheme(ctx);
1057
- applyThinkingLabel(ctx);
1058
864
  });
1059
865
 
1060
866
  pi.on("message_update", (event, ctx) => {
1061
867
  captureTheme(ctx);
1062
868
  const type = event.assistantMessageEvent?.type;
1063
- if (type === "thinking_start") {
1064
- state.consecutiveThinking++;
1065
- applyThinkingLabel(ctx);
1066
- return;
869
+ if (typeof type === "string" && type.startsWith("thinking_")) {
870
+ updateCurrentThoughtFromMessage(event.message);
1067
871
  }
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
- }
872
+ if (textSignalHasVisibleContent(event.assistantMessageEvent)) {
873
+ clearCurrentThought();
874
+ // Visible assistant text ends the current tool burst. Do not split on
875
+ // text_start alone: some providers create empty text blocks before a
876
+ // tool-only turn, and those blocks are hidden from the transcript.
877
+ state.currentBurst = [];
1075
878
  }
1076
879
  });
1077
880