pi-pretty-tui 0.1.8 → 0.2.1

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,6 +1,7 @@
1
1
  import {
2
2
  AssistantMessageComponent,
3
3
  CustomEditor,
4
+ CustomMessageComponent,
4
5
  InteractiveMode,
5
6
  ToolExecutionComponent,
6
7
  UserMessageComponent,
@@ -13,6 +14,7 @@ import {
13
14
  createWriteTool,
14
15
  copyToClipboard,
15
16
  getAgentDir,
17
+ getMarkdownTheme,
16
18
  type ExtensionAPI,
17
19
  } from "@earendil-works/pi-coding-agent";
18
20
  import {
@@ -27,10 +29,42 @@ import {
27
29
  } from "@earendil-works/pi-tui";
28
30
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
29
31
  import { join } from "node:path";
32
+ import {
33
+ ActivityTimeline,
34
+ assistantMessageKey,
35
+ assistantTerminalState,
36
+ thinkingText,
37
+ visibleAssistantText,
38
+ type ActivityGroup,
39
+ type ActivityMember,
40
+ type ActivitySeverity,
41
+ } from "./activity-timeline.js";
30
42
 
31
43
  type PrettyTuiMode = "full" | "compact" | "clean";
32
44
  const CLEAN_TOOL_ACTIVITY_MIN_MS = 1000;
45
+ const TRANSIENT_UI_STATUS_MS = 10_000;
46
+ const SPECIALIZED_TOOL_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]);
47
+
48
+ const stripAnsiBackgrounds = (value: string): string =>
49
+ value.replace(/\x1b\[([0-9:;]*)m/g, (sequence, raw: string) => {
50
+ const fields = raw === "" ? [0] : raw.split(";").map((field) => Number(field));
51
+ const kept: number[] = [];
52
+ for (let index = 0; index < fields.length; index++) {
53
+ const field = fields[index];
54
+ if ((field >= 40 && field <= 49) || (field >= 100 && field <= 107)) continue;
55
+ if (field === 48) {
56
+ const mode = fields[index + 1];
57
+ if (mode === 5) index += 2;
58
+ else if (mode === 2) index += 4;
59
+ continue;
60
+ }
61
+ kept.push(field);
62
+ }
63
+ if (raw.includes(":") && /(?:^|;)48:/u.test(raw)) return "";
64
+ return kept.length > 0 ? `\x1b[${kept.join(";")}m` : "";
65
+ });
33
66
  type PrettyTuiConfig = {
67
+ enabled?: boolean;
34
68
  mode?: PrettyTuiMode;
35
69
  /** Legacy location used by the first mode implementation. */
36
70
  bash?: {
@@ -52,6 +86,7 @@ export default function prettyTui(pi: ExtensionAPI) {
52
86
  const cwd = process.cwd();
53
87
  const configPath = join(getAgentDir(), "pretty-tui.json");
54
88
  let config = loadConfig(configPath);
89
+ const activeForSession = config.enabled !== false;
55
90
  const configuredMode = config.mode ?? config.bash?.mode;
56
91
  let renderMode: PrettyTuiMode = configuredMode === "full" || configuredMode === "compact" || configuredMode === "clean"
57
92
  ? configuredMode
@@ -62,6 +97,90 @@ export default function prettyTui(pi: ExtensionAPI) {
62
97
  let currentTui: any;
63
98
  let currentExtensionUi: any;
64
99
  let changingAllToolsExpansion = false;
100
+ const latestActivityWidgetKey = "pretty-tui-latest-activity";
101
+ type LatestActivityStatus = {
102
+ title: string;
103
+ detail?: string;
104
+ severity?: TransientStatusSeverity;
105
+ };
106
+ // The editor-adjacent flash reports Pi UI state ("status") and pretty-tui's
107
+ // own UI outcomes, so it has its own severities. Activity members only ever
108
+ // carry info, warning, or error.
109
+ type TransientStatusSeverity = "status" | "success" | "error";
110
+ let latestActivityStatus: LatestActivityStatus | undefined;
111
+ let latestActivityWidgetInstalled = false;
112
+ let latestActivityStatusTimer: ReturnType<typeof setTimeout> | undefined;
113
+ let latestActivityStatusGeneration = 0;
114
+ const renderLatestActivityStatus = (status: LatestActivityStatus, width: number, theme: any): string => {
115
+ const severity = status.severity ?? "status";
116
+ const icon = severity === "error" ? "✕" : severity === "success" ? "✓" : "◇";
117
+ const color = severity === "error"
118
+ ? "error"
119
+ : severity === "success"
120
+ ? "success"
121
+ : "dim";
122
+ const detail = status.detail?.trim() ? ` — ${status.detail.trim()}` : "";
123
+ return truncateToWidth(` ${theme.fg(color, `${icon} ${status.title}`)}${theme.fg("dim", detail)}`, Math.max(1, width), "…");
124
+ };
125
+ const refreshLatestActivityWidget = () => {
126
+ if (!currentExtensionUi?.setWidget) return;
127
+ if (renderMode !== "clean" || !latestActivityStatus) {
128
+ if (latestActivityWidgetInstalled) {
129
+ currentExtensionUi.setWidget(latestActivityWidgetKey, undefined);
130
+ latestActivityWidgetInstalled = false;
131
+ }
132
+ return;
133
+ }
134
+ if (latestActivityWidgetInstalled) {
135
+ currentTui?.requestRender?.();
136
+ return;
137
+ }
138
+ currentExtensionUi.setWidget(
139
+ latestActivityWidgetKey,
140
+ (_tui: any, theme: any) => ({
141
+ render: (width: number) => latestActivityStatus
142
+ ? [renderLatestActivityStatus(latestActivityStatus, width, theme)]
143
+ : [],
144
+ invalidate() {},
145
+ }),
146
+ { placement: "aboveEditor" },
147
+ );
148
+ latestActivityWidgetInstalled = true;
149
+ };
150
+ const clearLatestActivityStatus = () => {
151
+ latestActivityStatusGeneration++;
152
+ if (latestActivityStatusTimer) clearTimeout(latestActivityStatusTimer);
153
+ latestActivityStatusTimer = undefined;
154
+ latestActivityStatus = undefined;
155
+ if (latestActivityWidgetInstalled) {
156
+ currentExtensionUi?.setWidget?.(latestActivityWidgetKey, undefined);
157
+ latestActivityWidgetInstalled = false;
158
+ }
159
+ };
160
+ const setLatestActivityStatus = (status: LatestActivityStatus) => {
161
+ const generation = ++latestActivityStatusGeneration;
162
+ if (latestActivityStatusTimer) clearTimeout(latestActivityStatusTimer);
163
+ latestActivityStatus = status;
164
+ refreshLatestActivityWidget();
165
+ latestActivityStatusTimer = setTimeout(() => {
166
+ if (generation !== latestActivityStatusGeneration) return;
167
+ clearLatestActivityStatus();
168
+ currentTui?.requestRender?.();
169
+ }, TRANSIENT_UI_STATUS_MS);
170
+ };
171
+ const activityTimeline = new ActivityTimeline();
172
+ const revealedActivityGroups = new Set<string>();
173
+ const expandedThinkingMembers = new Set<string>();
174
+ const thinkingComponents = new Map<string, any>();
175
+ const activityFallbackThemes = new Map<string, any>();
176
+ const activityUpdateComponents = new Map<string, Component>();
177
+ const activityUpdateMarkdownComponents = new Map<string, {
178
+ content: string;
179
+ theme: any;
180
+ component: Markdown;
181
+ }>();
182
+ let activityUpdateSequence = 0;
183
+ const assistantBoundaryKeys = new Set<string>();
65
184
  const cleanCompactToolCallIds = new Set<string>();
66
185
  const cleanGroupToolCallIds = new Map<string, string[]>();
67
186
  const cleanToolCallGroupOwners = new Map<string, string>();
@@ -70,6 +189,20 @@ export default function prettyTui(pi: ExtensionAPI) {
70
189
  const cleanToolNames = new Map<string, string>();
71
190
  let renderCleanGroupSummary = (_lastToolCallId: string, _width: number): string[] => [];
72
191
 
192
+ // Markdown enhancements are active only while pi-pretty-tui renders the main
193
+ // conversation transcript. Plugin overlays also use Pi's Markdown class, so
194
+ // a global always-on prototype patch would leak transcript styling into them.
195
+ let transcriptMarkdownDepth = 0;
196
+ const withTranscriptMarkdown = <T>(callback: () => T): T => {
197
+ transcriptMarkdownDepth++;
198
+ try {
199
+ return callback();
200
+ } finally {
201
+ transcriptMarkdownDepth--;
202
+ }
203
+ };
204
+ const transcriptMarkdownActive = () => transcriptMarkdownDepth > 0;
205
+
73
206
  const setCleanGroupMembers = (lastToolCallId: string, toolCallIds: string[]) => {
74
207
  cleanGroupToolCallIds.set(lastToolCallId, toolCallIds);
75
208
  for (const toolCallId of toolCallIds) cleanToolCallGroupOwners.set(toolCallId, lastToolCallId);
@@ -77,31 +210,288 @@ export default function prettyTui(pi: ExtensionAPI) {
77
210
 
78
211
  const isCleanGroupRevealed = (toolCallId: string): boolean => {
79
212
  if (cleanCompactToolCallIds.has(toolCallId)) return true;
80
- const groupOwner = cleanToolCallGroupOwners.get(toolCallId);
81
- const groupToolCallIds = groupOwner ? cleanGroupToolCallIds.get(groupOwner) : undefined;
82
- return groupToolCallIds?.some((id) => cleanCompactToolCallIds.has(id)) ?? false;
213
+ const activityGroup = activityTimeline.groupForTool(toolCallId);
214
+ if (activityGroup && activityGroupRevealed(activityGroup)) return true;
215
+ const legacyOwner = cleanToolCallGroupOwners.get(toolCallId);
216
+ const legacyIds = legacyOwner ? cleanGroupToolCallIds.get(legacyOwner) : undefined;
217
+ return legacyIds?.some((id) => cleanCompactToolCallIds.has(id)) ?? false;
83
218
  };
84
219
 
85
220
  const cleanThemeForToolCall = (toolCallId: string): any => {
86
221
  const directTheme = cleanToolThemes.get(toolCallId);
87
222
  if (directTheme) return directTheme;
88
- const groupOwner = cleanToolCallGroupOwners.get(toolCallId);
89
- const groupToolCallIds = groupOwner ? cleanGroupToolCallIds.get(groupOwner) : undefined;
90
- return groupToolCallIds
91
- ?.map((id) => cleanToolThemes.get(id))
92
- .find(Boolean);
223
+ const activityGroup = activityTimeline.groupForTool(toolCallId);
224
+ // Prefer a real tool renderer theme over the Markdown-derived fallback.
225
+ // A group that starts with Thought installs the fallback first; returning
226
+ // it here made later third-party success dots use the heading color.
227
+ if (activityGroup) {
228
+ for (const id of activityGroup.toolCallIds) {
229
+ const activityTheme = cleanToolThemes.get(id);
230
+ if (activityTheme) {
231
+ activityFallbackThemes.set(activityGroup.id, activityTheme);
232
+ return activityTheme;
233
+ }
234
+ }
235
+ }
236
+ const cachedActivityTheme = activityGroup
237
+ ? activityFallbackThemes.get(activityGroup.id)
238
+ : undefined;
239
+ if (cachedActivityTheme) return cachedActivityTheme;
240
+ const legacyOwner = cleanToolCallGroupOwners.get(toolCallId);
241
+ const legacyIds = legacyOwner ? cleanGroupToolCallIds.get(legacyOwner) : undefined;
242
+ return legacyIds?.map((id) => cleanToolThemes.get(id)).find(Boolean);
243
+ };
244
+
245
+ const activityGroupOwner = (group: ActivityGroup): string | undefined => {
246
+ for (let index = group.toolCallIds.length - 1; index >= 0; index--) {
247
+ const toolCallId = group.toolCallIds[index];
248
+ if (settledSummaries.has(toolCallId)) return toolCallId;
249
+ }
250
+ if (cleanRun.activeToolCallId && group.toolCallIds.includes(cleanRun.activeToolCallId)) {
251
+ return cleanRun.activeToolCallId;
252
+ }
253
+ if (cleanRun.lastCompletedToolCallId && group.toolCallIds.includes(cleanRun.lastCompletedToolCallId)) {
254
+ return cleanRun.lastCompletedToolCallId;
255
+ }
256
+ return group.toolCallIds[group.toolCallIds.length - 1];
93
257
  };
94
258
 
95
- const saveRenderMode = (mode: PrettyTuiMode) => {
96
- config = { ...config, mode };
259
+ const defaultActivityTheme = () => {
260
+ if (currentExtensionUi?.theme?.fg) return currentExtensionUi.theme;
261
+ const markdownTheme = getMarkdownTheme();
262
+ return {
263
+ fg: (color: string, text: string) => {
264
+ if (color === "accent") return markdownTheme.listBullet(text);
265
+ // Session UI normally supplies Pi's active Theme above. Keep portable
266
+ // semantic fallbacks for early startup and isolated renderer tests.
267
+ if (color === "warning") return `\x1b[33m${text}\x1b[39m`;
268
+ if (color === "error") return `\x1b[31m${text}\x1b[39m`;
269
+ if (color === "success") return `\x1b[32m${text}\x1b[39m`;
270
+ if (color === "syntaxComment") return markdownTheme.codeBlock(text);
271
+ if (color === "thinkingLow") return markdownTheme.link(text);
272
+ if (color === "dim" || color === "muted" || color === "thinkingText") {
273
+ return markdownTheme.quote(text);
274
+ }
275
+ return text;
276
+ },
277
+ bold: markdownTheme.bold,
278
+ italic: markdownTheme.italic,
279
+ };
280
+ };
281
+
282
+ const activityGroupTheme = (group: ActivityGroup): any => {
283
+ // A Thought can render before its tools are constructed and install a
284
+ // Markdown fallback. Always promote a real tool theme once available so
285
+ // light mode receives explicit, high-contrast text colors.
286
+ for (const toolCallId of group.toolCallIds) {
287
+ const toolTheme = cleanToolThemes.get(toolCallId);
288
+ if (toolTheme) {
289
+ activityFallbackThemes.set(group.id, toolTheme);
290
+ return toolTheme;
291
+ }
292
+ }
293
+ const cached = activityFallbackThemes.get(group.id);
294
+ if (cached) return cached;
295
+ const fallback = defaultActivityTheme();
296
+ activityFallbackThemes.set(group.id, fallback);
297
+ return fallback;
298
+ };
299
+
300
+ const activityGroupRevealed = (group: ActivityGroup): boolean =>
301
+ cleanToolsExpanded || revealedActivityGroups.has(group.id);
302
+
303
+ // A group is revealable once it owns anything renderable beneath its parent
304
+ // row. Thinking counts on its own: a tool-free turn still gets a parent row
305
+ // with its Thought as the child member.
306
+ const activityGroupCollapsible = (group: ActivityGroup): boolean =>
307
+ activityTimeline.hasWork(group);
308
+
309
+ const activityMemberPosition = (group: ActivityGroup, member: ActivityMember) => ({
310
+ first: group.members[0]?.id === member.id,
311
+ last: group.members[group.members.length - 1]?.id === member.id,
312
+ });
313
+
314
+ const activityTreeStyle = (
315
+ group: ActivityGroup,
316
+ member: ActivityMember,
317
+ width: number,
318
+ memberTheme?: any,
319
+ ) => {
320
+ const { last } = activityMemberPosition(group, member);
321
+ const rawPrefix = last ? " └─ " : " ├─ ";
322
+ const rawContinuation = last ? " " : " │ ";
323
+ const prefixWidth = visibleWidth(rawPrefix);
324
+ if (width <= prefixWidth) {
325
+ return { prefix: "", continuation: "", childWidth: Math.max(1, width), prefixWidth: 0 };
326
+ }
327
+ const resolvedTheme = memberTheme ?? activityGroupTheme(group);
328
+ return {
329
+ prefix: resolvedTheme ? resolvedTheme.fg("dim", rawPrefix) : rawPrefix,
330
+ continuation: resolvedTheme ? resolvedTheme.fg("dim", rawContinuation) : rawContinuation,
331
+ childWidth: Math.max(1, width - prefixWidth),
332
+ prefixWidth,
333
+ };
334
+ };
335
+
336
+ const humanizeCustomType = (value: string): string =>
337
+ value
338
+ .split(/[-_\s]+/u)
339
+ .filter(Boolean)
340
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
341
+ .join(" ") || "Update";
342
+
343
+ const renderActivityUpdateMarkdown = (
344
+ member: ActivityMember,
345
+ width: number,
346
+ theme: any,
347
+ ): string[] => {
348
+ const content = member.updateContent ?? "";
349
+ if (!content) return [];
350
+ let cached = activityUpdateMarkdownComponents.get(member.id);
351
+ if (!cached || cached.content !== content || cached.theme !== theme) {
352
+ cached = {
353
+ content,
354
+ theme,
355
+ component: new Markdown(
356
+ content,
357
+ 0,
358
+ 0,
359
+ getMarkdownTheme(),
360
+ { color: (text: string) => theme.fg("muted", text) },
361
+ ),
362
+ };
363
+ activityUpdateMarkdownComponents.set(member.id, cached);
364
+ }
365
+ const lines = withTranscriptMarkdown(() => cached.component.render(Math.max(1, width)));
366
+ while (lines.length > 0 && stripTerminalSequences(lines[lines.length - 1]).trim() === "") {
367
+ lines.pop();
368
+ }
369
+ return lines;
370
+ };
371
+
372
+ const activityUpdateStyle = (member: ActivityMember, theme: any) => {
373
+ const severity = member.updateSeverity ?? "info";
374
+ const styles: Record<ActivitySeverity, { icon: string; color: string }> = {
375
+ info: { icon: "◇", color: "accent" },
376
+ warning: { icon: "⚠", color: "warning" },
377
+ error: { icon: "✕", color: "error" },
378
+ };
379
+ const style = styles[severity];
380
+ return {
381
+ severity,
382
+ icon: theme.fg(style.color, `${style.icon} `),
383
+ title: (text: string) => theme.fg(style.color, theme.bold(text)),
384
+ };
385
+ };
386
+
387
+ const renderActivityUpdate = (
388
+ group: ActivityGroup,
389
+ member: ActivityMember,
390
+ width: number,
391
+ theme = activityGroupTheme(group),
392
+ ): string[] => {
393
+ if (renderMode !== "clean" || !activityGroupRevealed(group)) return [];
394
+ const { prefix, continuation, childWidth } = activityTreeStyle(group, member, width, theme);
395
+ const title = truncateToWidth(member.updateTitle ?? "Update", Math.max(1, childWidth - 2), "…");
396
+ const style = activityUpdateStyle(member, theme);
397
+ const lines = [style.icon + style.title(title)];
398
+ if (member.updateContent) {
399
+ const detailWidth = Math.max(1, childWidth - visibleWidth(" │ "));
400
+ const detailLines = renderActivityUpdateMarkdown(member, detailWidth, theme);
401
+ lines.push(...detailLines.map((line, index) =>
402
+ theme.fg("dim", index === detailLines.length - 1 ? " └ " : " │ ") + line
403
+ ));
404
+ }
405
+ const decorated = lines.map((line, index) =>
406
+ truncateToWidth((index === 0 ? prefix : continuation) + line, Math.max(1, width), "")
407
+ );
408
+ const position = activityMemberPosition(group, member);
409
+ return position.first
410
+ ? ["", ...renderActivityGroupSummary(group, width), ...decorated]
411
+ : decorated;
412
+ };
413
+
414
+ const renderPendingActivityUpdate = (member: ActivityMember, width: number): string[] => {
415
+ const theme = defaultActivityTheme();
416
+ const title = truncateToWidth(member.updateTitle ?? "Update", Math.max(1, width - 2), "…");
417
+ const style = activityUpdateStyle(member, theme);
418
+ const lines = [style.icon + style.title(title)];
419
+ if (member.updateContent) {
420
+ const detailWidth = Math.max(1, width - visibleWidth(" │ "));
421
+ const details = renderActivityUpdateMarkdown(member, detailWidth, theme);
422
+ lines.push(...details.map((line, index) =>
423
+ theme.fg("dim", index === details.length - 1 ? " └ " : " │ ") + line
424
+ ));
425
+ }
426
+ return ["", ...lines.map((line) => truncateToWidth(line, Math.max(1, width), ""))];
427
+ };
428
+
429
+ const renderActivityUpdateProjection = (
430
+ group: ActivityGroup,
431
+ member: ActivityMember,
432
+ width: number,
433
+ ): string[] => {
434
+ if (!activityGroupCollapsible(group)) return renderPendingActivityUpdate(member, width);
435
+ const position = activityMemberPosition(group, member);
436
+ if (!activityGroupRevealed(group)) {
437
+ return position.first
438
+ ? ["", ...renderActivityGroupSummary(group, width, true)]
439
+ : [];
440
+ }
441
+ return renderActivityUpdate(group, member, width);
442
+ };
443
+
444
+ const handleActivityUpdateMouse = (
445
+ group: ActivityGroup,
446
+ member: ActivityMember,
447
+ event: any,
448
+ ) => {
449
+ const isLeftClick = event.type === "click" && event.button === "left";
450
+ if (!isLeftClick || !activityGroupCollapsible(group)) return undefined;
451
+ const position = activityMemberPosition(group, member);
452
+ if (!activityGroupRevealed(group)) {
453
+ if (!position.first) return undefined;
454
+ revealedActivityGroups.add(group.id);
455
+ for (const toolCallId of group.toolCallIds) cleanCompactToolCallIds.add(toolCallId);
456
+ } else {
457
+ const summaryHeight = position.first ? renderActivityGroupSummary(group, event.width).length : 0;
458
+ if (!position.first || event.y <= 0 || event.y > summaryHeight) return undefined;
459
+ revealedActivityGroups.delete(group.id);
460
+ for (const toolCallId of group.toolCallIds) {
461
+ cleanCompactToolCallIds.delete(toolCallId);
462
+ const component = cleanToolComponents.get(toolCallId);
463
+ if (component?.expanded) component.setExpanded(false);
464
+ }
465
+ for (const child of group.members) expandedThinkingMembers.delete(child.id);
466
+ }
467
+ for (const child of group.members) {
468
+ if (child.kind === "tool" && child.toolCallId) {
469
+ cleanToolComponents.get(child.toolCallId)?.updateDisplay?.();
470
+ } else if (child.kind === "thinking" && child.messageKey) {
471
+ thinkingComponents.get(child.messageKey)?.invalidate?.();
472
+ } else if (child.kind === "update") {
473
+ activityUpdateComponents.get(child.id)?.invalidate?.();
474
+ }
475
+ }
476
+ currentTui?.requestRender?.();
477
+ return { handled: true };
478
+ };
479
+
480
+ const saveConfig = (nextConfig: Record<string, any>) => {
481
+ config = nextConfig;
97
482
  mkdirSync(getAgentDir(), { recursive: true });
98
483
  writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
99
484
  };
100
485
 
486
+ const saveRenderMode = (mode: PrettyTuiMode) => saveConfig({ ...config, mode });
487
+ const saveEnabled = (enabled: boolean) => saveConfig({ ...config, enabled });
488
+
101
489
  pi.registerCommand("pretty-tui", {
102
- description: "Configure pi-pretty-tui rendering mode",
490
+ description: "Configure pi-pretty-tui",
103
491
  getArgumentCompletions: (prefix: string) => {
104
492
  const items = [
493
+ { value: "enable", label: "enable", description: "Enable the extension and reload automatically" },
494
+ { value: "disable", label: "disable", description: "Disable the extension and reload automatically" },
105
495
  { value: "full", label: "full", description: "Full tool details and output" },
106
496
  { value: "compact", label: "compact", description: "Concise summaries for all built-in tools" },
107
497
  { value: "clean", label: "clean", description: "Group supported tools into Running/Done status" },
@@ -115,29 +505,69 @@ export default function prettyTui(pi: ExtensionAPI) {
115
505
 
116
506
  if (!requested) {
117
507
  if (!ctx.hasUI) {
118
- ctx.ui.notify(`pi-pretty-tui mode: ${renderMode}`, "info");
508
+ ctx.ui.notify(
509
+ `pi-pretty-tui: ${config.enabled !== false ? "enabled" : "disabled"} · mode: ${renderMode}`,
510
+ "info",
511
+ );
119
512
  return;
120
513
  }
514
+ const enabled = `Enabled — ${config.enabled !== false ? "on" : "off"}`;
121
515
  const full = `Full — full tool details and output${renderMode === "full" ? " (current)" : ""}`;
122
516
  const compact = `Compact — concise summaries for all built-in tools${renderMode === "compact" ? " (current)" : ""}`;
123
517
  const clean = `Clean — group supported tools into Running/Done status${renderMode === "clean" ? " (current)" : ""}`;
124
- const selected = await ctx.ui.select("pi-pretty-tui rendering mode", [full, compact, clean]);
518
+ const selected = await ctx.ui.select("pi-pretty-tui settings", [enabled, full, compact, clean]);
125
519
  if (!selected) return;
126
- requested = selected === full ? "full" : selected === compact ? "compact" : "clean";
520
+ requested = selected === enabled
521
+ ? config.enabled !== false ? "disable" : "enable"
522
+ : selected === full
523
+ ? "full"
524
+ : selected === compact
525
+ ? "compact"
526
+ : "clean";
127
527
  }
128
528
 
129
529
  if (requested === "status") {
130
- ctx.ui.notify(`pi-pretty-tui mode: ${renderMode}`, "info");
530
+ ctx.ui.notify(
531
+ `pi-pretty-tui: ${config.enabled !== false ? "enabled" : "disabled"} · mode: ${renderMode}`,
532
+ "info",
533
+ );
131
534
  return;
132
535
  }
536
+ if (requested === "enable" || requested === "disable") {
537
+ const enabled = requested === "enable";
538
+ try {
539
+ saveEnabled(enabled);
540
+ } catch (error) {
541
+ const message = error instanceof Error ? error.message : String(error);
542
+ ctx.ui.notify(`Could not save pi-pretty-tui setting: ${message}`, "error");
543
+ return;
544
+ }
545
+ ctx.ui.notify(
546
+ `pi-pretty-tui ${enabled ? "enabled" : "disabled"}; reloading…`,
547
+ "info",
548
+ );
549
+ try {
550
+ await ctx.reload();
551
+ return;
552
+ } catch (error) {
553
+ const message = error instanceof Error ? error.message : String(error);
554
+ ctx.ui.notify(`Setting saved, but automatic reload failed: ${message}`, "warning");
555
+ return;
556
+ }
557
+ }
133
558
  if (requested !== "full" && requested !== "compact" && requested !== "clean") {
134
- ctx.ui.notify("Usage: /pretty-tui [full|compact|clean|status]", "error");
559
+ ctx.ui.notify("Usage: /pretty-tui [enable|disable|full|compact|clean|status]", "error");
135
560
  return;
136
561
  }
137
562
 
138
563
  cleanCompactToolCallIds.clear();
564
+ revealedActivityGroups.clear();
565
+ expandedThinkingMembers.clear();
139
566
  renderMode = requested;
567
+ if (renderMode === "clean") refreshLatestActivityWidget();
568
+ else clearLatestActivityStatus();
140
569
  for (const component of cleanToolComponents.values()) component.updateDisplay?.();
570
+ for (const component of thinkingComponents.values()) component.invalidate?.();
141
571
  try {
142
572
  saveRenderMode(renderMode);
143
573
  ctx.ui.notify(`pi-pretty-tui mode set to: ${renderMode}`, "info");
@@ -148,6 +578,10 @@ export default function prettyTui(pi: ExtensionAPI) {
148
578
  },
149
579
  });
150
580
 
581
+ // Keep the settings command available while disabled, but do not install
582
+ // render patches or override built-in tools until the next enabled reload.
583
+ if (!activeForSession) return;
584
+
151
585
  // Give Pi's main prompt editor a complete rounded frame. Render the native
152
586
  // editor at a two-column narrower width so cursor layout, wrapping, IME, and
153
587
  // autocomplete remain native, then add one themed border column per side.
@@ -258,12 +692,35 @@ export default function prettyTui(pi: ExtensionAPI) {
258
692
  const top = border(topStart + markdownTheme.bold(title) + topTail + "╮");
259
693
  const bottom = border("╰" + "─".repeat(Math.max(0, frameWidth - 2)) + "╯");
260
694
  const contentWidth = Math.max(1, frameWidth - 4);
261
- const body = content.render(contentWidth).map((line: string) => {
695
+ const body = withTranscriptMarkdown(() => content.render(contentWidth)).map((line: string) => {
262
696
  const padding = " ".repeat(Math.max(0, contentWidth - visibleWidth(line)));
263
697
  return border("│ ") + line + padding + border(" │");
264
698
  });
265
699
  return [top, ...body, bottom].map((line: string) => outer + paintBackground(line));
266
700
  },
701
+ handleMouse(event: any) {
702
+ if (!content.handleMouse) return undefined;
703
+ const sidePad = Math.min(outputPad, Math.max(0, Math.floor((event.width - 1) / 2)));
704
+ const frameWidth = Math.max(1, event.width - sidePad * 2);
705
+ if (frameWidth < 4) {
706
+ return withTranscriptMarkdown(() => content.handleMouse({
707
+ ...event,
708
+ x: event.x - sidePad,
709
+ width: frameWidth,
710
+ }));
711
+ }
712
+ const contentWidth = Math.max(1, frameWidth - 4);
713
+ if (event.y <= 0 || event.x < sidePad + 2 || event.x >= sidePad + 2 + contentWidth) {
714
+ return undefined;
715
+ }
716
+ return withTranscriptMarkdown(() => content.handleMouse({
717
+ ...event,
718
+ x: event.x - sidePad - 2,
719
+ y: event.y - 1,
720
+ width: contentWidth,
721
+ height: Math.max(0, event.height - 2),
722
+ }));
723
+ },
267
724
  invalidate() {
268
725
  content.invalidate?.();
269
726
  },
@@ -291,63 +748,437 @@ export default function prettyTui(pi: ExtensionAPI) {
291
748
  });
292
749
  }
293
750
 
294
- // Pi's built-in assistant component turns hidden thinking into a static
295
- // "Thinking..." label. In collapsed clean mode, omit that label entirely;
296
- // the clean-mode Running(...) row owns activity, while Ctrl+O still reveals
297
- // the thinking content. The component is exported by Pi specifically for
298
- // extension-level rendering customizations, so patch its public methods
299
- // rather than Pi's source.
751
+ const customMessageKey = (message: any): string => {
752
+ if (message?.timestamp !== undefined) {
753
+ const parsed = typeof message.timestamp === "string"
754
+ ? Date.parse(message.timestamp)
755
+ : Number(message.timestamp);
756
+ return Number.isFinite(parsed) ? String(parsed) : String(message.timestamp);
757
+ }
758
+ return `${message?.customType ?? "custom"}:${JSON.stringify(message?.content ?? "")}`;
759
+ };
760
+
761
+ const customMessageText = (message: any): string =>
762
+ typeof message?.content === "string"
763
+ ? message.content
764
+ : Array.isArray(message?.content)
765
+ ? message.content
766
+ .filter((item: any) => item?.type === "text" && typeof item.text === "string")
767
+ .map((item: any) => item.text)
768
+ .join("\n")
769
+ : "";
770
+
771
+ // Delegates to the timeline so the precondition is stated once. The `??` is
772
+ // deliberate: the callee may still decline, and a dropped notification must
773
+ // never throw inside a third-party extension's ctx.ui.notify() call.
774
+ const hasWorkActivity = (group: ActivityGroup | undefined): boolean =>
775
+ activityTimeline.hasWork(group);
776
+
777
+ const addPersistentActivityUpdate = (message: any): ActivityMember => {
778
+ const key = customMessageKey(message);
779
+ const title = humanizeCustomType(message?.customType ?? "update");
780
+ const content = customMessageText(message);
781
+ const existingGroup = activityTimeline.currentGroup();
782
+ const joinsWork = hasWorkActivity(existingGroup);
783
+ const member = joinsWork
784
+ ? activityTimeline.addUpdate(key, title, content, true, "info") ??
785
+ activityTimeline.addPendingUpdate(key, title, content, true, "info")
786
+ : activityTimeline.addPendingUpdate(key, title, content, true, "info");
787
+ // A displayed custom message without current model/tool work is an
788
+ // independent durable update, never a pending member of the next run.
789
+ if (!hasWorkActivity(existingGroup)) activityTimeline.boundary();
790
+ return member;
791
+ };
792
+
793
+ const nativeCustomBoundaryKeys = new Set<string>();
794
+ let closeCleanAtNativeCustomBoundary = () => activityTimeline.boundary();
795
+ let closeCleanAtStandaloneAlert = () => activityTimeline.boundary();
796
+ const customMessagePrototype = CustomMessageComponent.prototype as any;
797
+ const customMessagePatchKey = Symbol.for("pretty-tui.clean-custom-message");
798
+ if (!customMessagePrototype[customMessagePatchKey]) {
799
+ const originalCustomRender = customMessagePrototype.render;
800
+ const originalCustomHandleMouse = customMessagePrototype.handleMouse;
801
+ const releaseSemanticCustomMessage = (component: any): boolean => {
802
+ if (!component.customRenderer) return false;
803
+ const key = customMessageKey(component.message);
804
+ if (!nativeCustomBoundaryKeys.has(key)) {
805
+ nativeCustomBoundaryKeys.add(key);
806
+ const member = activityTimeline.memberForUpdate(key);
807
+ if (member) activityUpdateComponents.delete(member.id);
808
+ activityTimeline.removeUpdate(key);
809
+ closeCleanAtNativeCustomBoundary();
810
+ }
811
+ return true;
812
+ };
813
+ const patchedCustomRender = function (this: any, width: number): string[] {
814
+ if (releaseSemanticCustomMessage(this)) {
815
+ return originalCustomRender.call(this, width);
816
+ }
817
+ const message = this.message;
818
+ const key = customMessageKey(message);
819
+ let member = activityTimeline.memberForUpdate(key);
820
+ // Extension-injected custom messages do not always emit Pi's model
821
+ // message_start event. Claim them at the component boundary so live
822
+ // rendering matches transcript restoration instead of failing open to
823
+ // the purple native box until the next reload.
824
+ if (!member && renderMode === "clean") {
825
+ member = addPersistentActivityUpdate(message);
826
+ }
827
+ const group = member ? activityTimeline.groupForMember(member.id) : undefined;
828
+ if (renderMode !== "clean" || !member || !group) {
829
+ return originalCustomRender.call(this, width);
830
+ }
831
+ activityUpdateComponents.set(member.id, this);
832
+ return renderActivityUpdateProjection(group, member, width);
833
+ };
834
+ const patchedCustomHandleMouse = function (this: any, event: any) {
835
+ if (releaseSemanticCustomMessage(this)) {
836
+ return originalCustomHandleMouse?.call(this, event);
837
+ }
838
+ const member = activityTimeline.memberForUpdate(customMessageKey(this.message));
839
+ const group = member ? activityTimeline.groupForMember(member.id) : undefined;
840
+ if (renderMode !== "clean" || !member || !group) {
841
+ return originalCustomHandleMouse?.call(this, event);
842
+ }
843
+ return handleActivityUpdateMouse(group, member, event);
844
+ };
845
+ customMessagePrototype[customMessagePatchKey] = {
846
+ originalRender: originalCustomRender,
847
+ patchedRender: patchedCustomRender,
848
+ originalHandleMouse: originalCustomHandleMouse,
849
+ patchedHandleMouse: patchedCustomHandleMouse,
850
+ };
851
+ customMessagePrototype.render = patchedCustomRender;
852
+ customMessagePrototype.handleMouse = patchedCustomHandleMouse;
853
+ pi.on("session_shutdown", () => {
854
+ const patch = customMessagePrototype[customMessagePatchKey];
855
+ if (!patch) return;
856
+ if (patch.patchedRender === customMessagePrototype.render) {
857
+ customMessagePrototype.render = patch.originalRender;
858
+ }
859
+ if (patch.patchedHandleMouse === customMessagePrototype.handleMouse) {
860
+ customMessagePrototype.handleMouse = patch.originalHandleMouse;
861
+ }
862
+ if (
863
+ customMessagePrototype.render === patch.originalRender &&
864
+ customMessagePrototype.handleMouse === patch.originalHandleMouse
865
+ ) {
866
+ delete customMessagePrototype[customMessagePatchKey];
867
+ }
868
+ });
869
+ }
870
+
871
+ // Thinking and tools are peers in the transcript-first activity timeline.
872
+ // Assistant components keep Pi's native Markdown renderer, while clean mode
873
+ // projects each thinking run as a compact, independently expandable child.
300
874
  const assistantPrototype = AssistantMessageComponent.prototype as any;
301
875
  const thinkingPatchKey = Symbol.for("pretty-tui.clean-thinking");
302
876
  if (!assistantPrototype[thinkingPatchKey]) {
303
877
  const originalUpdateContent = assistantPrototype.updateContent;
304
878
  const originalRender = assistantPrototype.render;
879
+ const originalHandleMouse = assistantPrototype.handleMouse;
305
880
  const originalMessageKey = Symbol("pretty-tui.original-assistant-message");
306
881
  const renderedModeKey = Symbol("pretty-tui.rendered-assistant-mode");
307
- const renderedExpansionKey = Symbol("pretty-tui.rendered-thinking-expansion");
882
+ const renderedExpansionKey = Symbol("pretty-tui.rendered-assistant-expansion");
883
+ const cleanThinkingRenderCacheKey = Symbol("pretty-tui.clean-thinking-render-cache");
884
+ const cleanThinkingDetailKey = Symbol("pretty-tui.clean-thinking-detail");
885
+
886
+ const assistantTerminalUpdate = (message: any): ActivityMember | undefined => {
887
+ if (!assistantTerminalState(message)) return undefined;
888
+ const hasToolCalls = Array.isArray(message?.content) &&
889
+ message.content.some((item: any) => item?.type === "toolCall");
890
+ // Failed tools already preserve the error in their native result details;
891
+ // avoid duplicating that payload as a second durable member.
892
+ if (hasToolCalls) return undefined;
893
+ const key = `assistant-terminal:${assistantMessageKey(message)}`;
894
+ const existing = activityTimeline.memberForUpdate(key);
895
+ if (existing) return existing;
896
+ const stopReason = message?.stopReason;
897
+ const severity: ActivitySeverity = stopReason === "aborted" ? "warning" : "error";
898
+ const title = stopReason === "length"
899
+ ? "Response truncated"
900
+ : stopReason === "aborted"
901
+ ? "Operation aborted"
902
+ : "Error";
903
+ const content = String(message?.errorMessage ?? "").trim() ||
904
+ (stopReason === "length" ? "Response was truncated before completion." : "Unknown error");
905
+ activityTimeline.boundary();
906
+ const member = activityTimeline.addPendingUpdate(key, title, content, true, severity);
907
+ activityTimeline.boundary();
908
+ return member;
909
+ };
910
+
911
+ const eligibleThinking = (message: any) =>
912
+ Array.isArray(message?.content) &&
913
+ message.content.some(
914
+ (item: any) => item?.type === "thinking" &&
915
+ typeof item.thinking === "string" &&
916
+ item.thinking.length > 0,
917
+ );
918
+
919
+ const refreshGroup = (group: ActivityGroup) => {
920
+ for (const member of group.members) {
921
+ if (member.kind === "tool" && member.toolCallId) {
922
+ cleanToolComponents.get(member.toolCallId)?.updateDisplay?.();
923
+ } else if (member.kind === "thinking" && member.messageKey) {
924
+ thinkingComponents.get(member.messageKey)?.invalidate?.();
925
+ } else if (member.kind === "update") {
926
+ activityUpdateComponents.get(member.id)?.invalidate?.();
927
+ }
928
+ }
929
+ currentTui?.requestRender?.();
930
+ };
931
+
932
+ // Shared detail projection for a Thought, used both as a member of a tool
933
+ // group and as a standalone row. The cache records its theme because the
934
+ // same component can switch between the Markdown fallback and a real tool
935
+ // theme once the first tool of the group is constructed.
936
+ const thinkingDetailLines = (
937
+ owner: any,
938
+ text: string,
939
+ theme: any,
940
+ childWidth: number,
941
+ ): string[] => {
942
+ let detail = owner[cleanThinkingDetailKey];
943
+ if (!detail || detail.text !== text || detail.theme !== theme) {
944
+ detail = {
945
+ text,
946
+ theme,
947
+ component: new Markdown(text, 0, 0, owner.markdownTheme, {
948
+ color: (line: string) => theme.fg("thinkingText", line),
949
+ italic: true,
950
+ }),
951
+ };
952
+ owner[cleanThinkingDetailKey] = detail;
953
+ }
954
+ const lines: string[] = detail.component.render(Math.max(1, childWidth));
955
+ return lines.map((line, index) =>
956
+ theme.fg("dim", index === lines.length - 1 ? " └ " : " │ ") + line
957
+ );
958
+ };
308
959
 
309
960
  const patchedUpdateContent = function (this: any, message: any, isStreaming = this.isStreaming) {
961
+ this[originalMessageKey] = message;
962
+ this[renderedModeKey] = renderMode;
963
+ this[renderedExpansionKey] = cleanToolsExpanded;
964
+ this.lastMessage = message;
310
965
  if (renderMode !== "clean") {
311
966
  originalUpdateContent.call(this, message, isStreaming);
312
- this[originalMessageKey] = message;
313
- this[renderedModeKey] = renderMode;
314
- this[renderedExpansionKey] = undefined;
967
+ this.lastMessage = message;
315
968
  return;
316
969
  }
970
+ const groupedThinking = eligibleThinking(message);
971
+ if (groupedThinking) {
972
+ const key = assistantMessageKey(message);
973
+ activityTimeline.addThinking(key, thinkingText(message));
974
+ thinkingComponents.set(key, this);
975
+ }
317
976
 
318
- const showThinking = cleanToolsExpanded;
319
- const content = Array.isArray(message?.content) ? message.content : [];
320
- // In collapsed clean mode, omit Pi's built-in Thinking... placeholder
321
- // entirely. The clean-mode Running(...) row owns the activity label;
322
- // expanded mode still reveals the actual thinking content.
323
- const displayMessage = showThinking
324
- ? message
325
- : { ...message, content: content.filter((item: any) => item.type !== "thinking") };
326
977
  const previousHideThinkingBlock = this.hideThinkingBlock;
327
978
  this.hideThinkingBlock = false;
979
+ // Clean mode owns grouped thinking completely. Removing it from Pi's
980
+ // native content tree also removes the native MouseRegion that would
981
+ // otherwise toggle a second, unrelated `Thinking...` block.
982
+ const displayMessage =
983
+ groupedThinking && Array.isArray(message?.content)
984
+ ? { ...message, content: message.content.filter((item: any) => item.type !== "thinking") }
985
+ : message;
328
986
  try {
329
987
  originalUpdateContent.call(this, displayMessage, isStreaming);
330
988
  } finally {
331
989
  this.hideThinkingBlock = previousHideThinkingBlock;
332
990
  }
333
-
334
- // Pi's invalidate()/setHideThinkingBlock() call updateContent with
335
- // lastMessage, so retain the unfiltered message for later mode changes.
336
- this[originalMessageKey] = message;
337
- this[renderedModeKey] = renderMode;
338
- this[renderedExpansionKey] = showThinking;
339
991
  this.lastMessage = message;
340
992
  };
341
993
 
342
- const patchedRender = function (this: any, width: number): string[] {
343
- const needsRefresh = this[originalMessageKey] && (
344
- this[renderedModeKey] !== renderMode ||
345
- (renderMode === "clean" && this[renderedExpansionKey] !== cleanToolsExpanded)
994
+ const renderAssistantTranscript = function (this: any, width: number): string[] {
995
+ const message = this[originalMessageKey] ?? this.lastMessage;
996
+ if (
997
+ message &&
998
+ (this[renderedModeKey] !== renderMode || this[renderedExpansionKey] !== cleanToolsExpanded)
999
+ ) {
1000
+ patchedUpdateContent.call(this, message, this.isStreaming);
1001
+ }
1002
+ if (renderMode !== "clean") return originalRender.call(this, width);
1003
+ const terminalMember = assistantTerminalUpdate(message);
1004
+ if (!eligibleThinking(message)) {
1005
+ if (!terminalMember) return originalRender.call(this, width);
1006
+ const terminalGroup = activityTimeline.groupForMember(terminalMember.id);
1007
+ if (!terminalGroup) return originalRender.call(this, width);
1008
+ activityUpdateComponents.set(terminalMember.id, this);
1009
+ return renderActivityUpdateProjection(terminalGroup, terminalMember, width);
1010
+ }
1011
+
1012
+ const key = assistantMessageKey(message);
1013
+ const member = activityTimeline.memberForThinking(key);
1014
+ const group = member ? activityTimeline.groupForMember(member.id) : undefined;
1015
+ if (!member || !group) {
1016
+ // Timeline ownership can be briefly unavailable during transcript
1017
+ // rebuilds. Fail open to Pi's filtered native renderer so visible
1018
+ // assistant text is never lost.
1019
+ return originalRender.call(this, width);
1020
+ }
1021
+ thinkingComponents.set(key, this);
1022
+ if (!activityFallbackThemes.has(group.id)) {
1023
+ activityFallbackThemes.set(group.id, defaultActivityTheme());
1024
+ }
1025
+ const position = activityMemberPosition(group, member);
1026
+ const visibleLines = visibleAssistantText(message) ? originalRender.call(this, width) : [];
1027
+ // Pi's native renderer prints its own error line for messages with no
1028
+ // tool calls, so this component only adds the styled alert card when no
1029
+ // visible text can carry it.
1030
+ const terminalLines = terminalMember && visibleLines.length === 0
1031
+ ? renderPendingActivityUpdate(terminalMember, width)
1032
+ : [];
1033
+ const appendTail = (activityLines: string[]) => [
1034
+ ...activityLines,
1035
+ ...terminalLines,
1036
+ ...visibleLines,
1037
+ ];
1038
+ const revealed = activityGroupRevealed(group);
1039
+ if (!revealed) {
1040
+ const activityLines = position.first
1041
+ ? ["", ...renderActivityGroupSummary(group, width, true)]
1042
+ : [];
1043
+ return appendTail(activityLines);
1044
+ }
1045
+
1046
+ const groupTheme = activityGroupTheme(group);
1047
+ const { prefix, continuation, childWidth } = activityTreeStyle(
1048
+ group,
1049
+ member,
1050
+ width,
1051
+ groupTheme,
1052
+ );
1053
+ // Clean mode owns thought disclosure: revealing a group shows the thought
1054
+ // member, while only a member click (or Ctrl+O) reveals its full text.
1055
+ const expanded = cleanToolsExpanded || expandedThinkingMembers.has(member.id);
1056
+ const settledOwner = position.first ? activityGroupOwner(group) : undefined;
1057
+ // A group without tools has no durable summary, so its own boundary is
1058
+ // the signal that it finished.
1059
+ const stableSummary = !position.first || (
1060
+ settledOwner
1061
+ ? Boolean(settledSummaries.has(settledOwner) && !toolActivityHolds.has(settledOwner))
1062
+ : activityTimeline.isSettled(group.id)
346
1063
  );
347
- if (needsRefresh) {
348
- patchedUpdateContent.call(this, this[originalMessageKey], this.isStreaming);
1064
+ const cacheable = !expanded && !this.isStreaming && stableSummary;
1065
+ const cached = cacheable ? this[cleanThinkingRenderCacheKey] : undefined;
1066
+ if (
1067
+ cached?.width === width &&
1068
+ cached?.message === message &&
1069
+ cached?.theme === groupTheme &&
1070
+ cached?.memberCount === group.members.length &&
1071
+ cached?.last === position.last
1072
+ ) {
1073
+ // Only the stable activity projection is cached. Visible assistant
1074
+ // text belongs outside the collapsible group and must be appended on
1075
+ // every render, including cache hits.
1076
+ return appendTail(cached.lines);
1077
+ }
1078
+ const stateLabel = this.isStreaming ? "thinking" : "thought";
1079
+ const label = truncateToWidth(stateLabel, Math.max(1, childWidth - 2), "…");
1080
+ const header = groupTheme.fg("thinkingLow", "● ") +
1081
+ groupTheme.fg("toolTitle", groupTheme.bold(label));
1082
+ let contentLines: string[] = [header];
1083
+ if (expanded) {
1084
+ contentLines.push(...thinkingDetailLines(
1085
+ this,
1086
+ member.thinking ?? thinkingText(message),
1087
+ groupTheme,
1088
+ Math.max(1, childWidth - visibleWidth(" │ ")),
1089
+ ));
1090
+ }
1091
+ const decorated = contentLines.map((line, index) =>
1092
+ truncateToWidth((index === 0 ? prefix : continuation) + line, Math.max(1, width), "")
1093
+ );
1094
+ const output = position.first
1095
+ ? ["", ...renderActivityGroupSummary(group, width), ...decorated]
1096
+ : decorated;
1097
+ if (cacheable) {
1098
+ this[cleanThinkingRenderCacheKey] = {
1099
+ width,
1100
+ message,
1101
+ theme: groupTheme,
1102
+ memberCount: group.members.length,
1103
+ last: position.last,
1104
+ lines: output,
1105
+ };
1106
+ }
1107
+ return appendTail(output);
1108
+ };
1109
+
1110
+ const patchedRender = function (this: any, width: number): string[] {
1111
+ return withTranscriptMarkdown(() => renderAssistantTranscript.call(this, width));
1112
+ };
1113
+
1114
+ const handleAssistantTranscriptMouse = function (this: any, event: any) {
1115
+ const message = this[originalMessageKey] ?? this.lastMessage;
1116
+ if (renderMode !== "clean") return originalHandleMouse.call(this, event);
1117
+ if (!eligibleThinking(message)) {
1118
+ const terminalMember = assistantTerminalUpdate(message);
1119
+ const terminalGroup = terminalMember
1120
+ ? activityTimeline.groupForMember(terminalMember.id)
1121
+ : undefined;
1122
+ return terminalMember && terminalGroup && activityGroupCollapsible(terminalGroup)
1123
+ ? handleActivityUpdateMouse(terminalGroup, terminalMember, event)
1124
+ : originalHandleMouse.call(this, event);
1125
+ }
1126
+ const key = assistantMessageKey(message);
1127
+ const member = activityTimeline.memberForThinking(key);
1128
+ const group = member ? activityTimeline.groupForMember(member.id) : undefined;
1129
+ if (!member || !group) return originalHandleMouse.call(this, event);
1130
+ const position = activityMemberPosition(group, member);
1131
+ const isLeftClick = event.type === "click" && event.button === "left";
1132
+
1133
+ // A completed assistant message can contain both the final Thought and
1134
+ // visible Markdown. The Markdown is rendered after the activity rows;
1135
+ // route clicks in that suffix back to Pi's native component so links,
1136
+ // text selection, and code-block Copy controls keep working.
1137
+ if (visibleAssistantText(message)) {
1138
+ const visibleLines = originalRender.call(this, event.width);
1139
+ const renderedLines = patchedRender.call(this, event.width);
1140
+ const activityHeight = Math.max(0, renderedLines.length - visibleLines.length);
1141
+ if (event.y >= activityHeight) {
1142
+ return originalHandleMouse.call(this, {
1143
+ ...event,
1144
+ y: event.y - activityHeight,
1145
+ height: visibleLines.length,
1146
+ });
1147
+ }
1148
+ }
1149
+
1150
+ if (!activityGroupRevealed(group)) {
1151
+ if (!isLeftClick) return undefined;
1152
+ if (position.first) {
1153
+ revealedActivityGroups.add(group.id);
1154
+ for (const toolCallId of group.toolCallIds) cleanCompactToolCallIds.add(toolCallId);
1155
+ refreshGroup(group);
1156
+ }
1157
+ return { handled: true };
1158
+ }
1159
+ const summaryHeight = position.first ? renderActivityGroupSummary(group, event.width).length : 0;
1160
+ if (position.first && event.y > 0 && event.y <= summaryHeight && isLeftClick) {
1161
+ revealedActivityGroups.delete(group.id);
1162
+ for (const toolCallId of group.toolCallIds) {
1163
+ cleanCompactToolCallIds.delete(toolCallId);
1164
+ const component = cleanToolComponents.get(toolCallId);
1165
+ if (component?.expanded) component.setExpanded(false);
1166
+ }
1167
+ for (const child of group.members) expandedThinkingMembers.delete(child.id);
1168
+ refreshGroup(group);
1169
+ return { handled: true };
349
1170
  }
350
- return originalRender.call(this, width);
1171
+ if (isLeftClick) {
1172
+ if (expandedThinkingMembers.has(member.id)) expandedThinkingMembers.delete(member.id);
1173
+ else expandedThinkingMembers.add(member.id);
1174
+ refreshGroup(group);
1175
+ return { handled: true };
1176
+ }
1177
+ return undefined;
1178
+ };
1179
+
1180
+ const patchedHandleMouse = function (this: any, event: any) {
1181
+ return withTranscriptMarkdown(() => handleAssistantTranscriptMouse.call(this, event));
351
1182
  };
352
1183
 
353
1184
  assistantPrototype[thinkingPatchKey] = {
@@ -355,9 +1186,12 @@ export default function prettyTui(pi: ExtensionAPI) {
355
1186
  patchedUpdateContent,
356
1187
  originalRender,
357
1188
  patchedRender,
1189
+ originalHandleMouse,
1190
+ patchedHandleMouse,
358
1191
  };
359
1192
  assistantPrototype.updateContent = patchedUpdateContent;
360
1193
  assistantPrototype.render = patchedRender;
1194
+ assistantPrototype.handleMouse = patchedHandleMouse;
361
1195
 
362
1196
  pi.on("session_shutdown", () => {
363
1197
  const patch = assistantPrototype[thinkingPatchKey];
@@ -368,9 +1202,13 @@ export default function prettyTui(pi: ExtensionAPI) {
368
1202
  if (patch.patchedRender === assistantPrototype.render) {
369
1203
  assistantPrototype.render = patch.originalRender;
370
1204
  }
1205
+ if (patch.patchedHandleMouse === assistantPrototype.handleMouse) {
1206
+ assistantPrototype.handleMouse = patch.originalHandleMouse;
1207
+ }
371
1208
  if (
372
1209
  assistantPrototype.updateContent === patch.originalUpdateContent &&
373
- assistantPrototype.render === patch.originalRender
1210
+ assistantPrototype.render === patch.originalRender &&
1211
+ assistantPrototype.handleMouse === patch.originalHandleMouse
374
1212
  ) {
375
1213
  delete assistantPrototype[thinkingPatchKey];
376
1214
  }
@@ -403,20 +1241,69 @@ export default function prettyTui(pi: ExtensionAPI) {
403
1241
  ];
404
1242
  };
405
1243
 
1244
+ const appendRuntimeActivityUpdate = (
1245
+ mode: any,
1246
+ message: string,
1247
+ severity: ActivitySeverity,
1248
+ ): boolean => {
1249
+ const [firstLine, ...remainingLines] = String(message).split("\n");
1250
+ const fallbackTitle = severity === "warning"
1251
+ ? "Warning"
1252
+ : severity === "error"
1253
+ ? "Error"
1254
+ : "Info";
1255
+ const title = firstLine || fallbackTitle;
1256
+ const content = remainingLines.join("\n");
1257
+ // Warnings and errors are standalone hard boundaries; only ordinary notices
1258
+ // join the work in progress.
1259
+ const standalone = severity === "warning" || severity === "error";
1260
+ const updateKey = `runtime:${severity}:${++activityUpdateSequence}`;
1261
+ if (standalone) closeCleanAtStandaloneAlert();
1262
+ const existingGroup = activityTimeline.currentGroup();
1263
+ const joinsWork = !standalone && hasWorkActivity(existingGroup);
1264
+ const member = joinsWork
1265
+ ? activityTimeline.addUpdate(updateKey, title, content, false, severity) ??
1266
+ activityTimeline.addPendingUpdate(updateKey, title, content, false, severity)
1267
+ : activityTimeline.addPendingUpdate(updateKey, title, content, false, severity);
1268
+ if (!joinsWork) activityTimeline.boundary();
1269
+ const group = activityTimeline.groupForMember(member.id);
1270
+ if (!group || !mode.chatContainer?.addChild) return false;
1271
+ const component: Component = {
1272
+ render: (width: number) => renderActivityUpdateProjection(group, member, width),
1273
+ invalidate() {},
1274
+ handleMouse: (event: any) => handleActivityUpdateMouse(group, member, event),
1275
+ } as Component;
1276
+ activityUpdateComponents.set(member.id, component);
1277
+ mode.chatContainer.addChild(component);
1278
+ mode.ui?.requestRender?.();
1279
+ return true;
1280
+ };
1281
+
406
1282
  const interactiveModePrototype = InteractiveMode.prototype as any;
407
1283
  const toolsExpansionPatchKey = Symbol.for("pretty-tui.clean-tool-expansion");
408
1284
  if (!interactiveModePrototype[toolsExpansionPatchKey]) {
409
1285
  const originalSetToolsExpanded = interactiveModePrototype.setToolsExpanded;
410
1286
  const originalRenderSessionEntries = interactiveModePrototype.renderSessionEntries;
411
1287
  const originalSwitchTuiMode = interactiveModePrototype.switchTuiMode;
1288
+ const originalShowExtensionNotify = interactiveModePrototype.showExtensionNotify;
1289
+ const originalToggleThinkingBlockVisibility = interactiveModePrototype.toggleThinkingBlockVisibility;
1290
+ const originalShowStatus = interactiveModePrototype.showStatus;
1291
+ const originalShowWarning = interactiveModePrototype.showWarning;
1292
+ const originalShowError = interactiveModePrototype.showError;
412
1293
  const patchedSetToolsExpanded = function (this: any, expanded: boolean) {
413
1294
  cleanToolsExpanded = expanded;
414
1295
  changingAllToolsExpansion = true;
415
- if (!expanded) cleanCompactToolCallIds.clear();
1296
+ if (!expanded) {
1297
+ cleanCompactToolCallIds.clear();
1298
+ revealedActivityGroups.clear();
1299
+ expandedThinkingMembers.clear();
1300
+ }
416
1301
  try {
417
1302
  return originalSetToolsExpanded.call(this, expanded);
418
1303
  } finally {
419
1304
  changingAllToolsExpansion = false;
1305
+ for (const component of thinkingComponents.values()) component.invalidate?.();
1306
+ this.ui?.requestRender?.();
420
1307
  }
421
1308
  };
422
1309
 
@@ -426,11 +1313,8 @@ export default function prettyTui(pi: ExtensionAPI) {
426
1313
  // buildContextEntries() prepends the latest compaction for model context,
427
1314
  // while Pi's live compaction UI appends it chronologically. Keep reloads
428
1315
  // and transcript rebuilds consistent with that live presentation.
429
- return originalRenderSessionEntries.call(
430
- this,
431
- orderContextEntriesForTranscript(entries),
432
- options,
433
- );
1316
+ const orderedEntries = orderContextEntriesForTranscript(entries);
1317
+ return originalRenderSessionEntries.call(this, orderedEntries, options);
434
1318
  };
435
1319
 
436
1320
  const patchedSwitchTuiMode = function (this: any, ...args: any[]) {
@@ -440,6 +1324,80 @@ export default function prettyTui(pi: ExtensionAPI) {
440
1324
  return result;
441
1325
  };
442
1326
 
1327
+ // Pi's own showExtensionNotify only dispatches to showStatus/showWarning/
1328
+ // showError, and all three are replaced above. Delegating back to it would
1329
+ // therefore re-enter these patches instead of restoring native behaviour,
1330
+ // which sent info notifications into the transient UI flash. Route straight
1331
+ // to the original method that matches the severity instead.
1332
+ const nativeExtensionNotify = function (
1333
+ this: any,
1334
+ text: string,
1335
+ severity: ActivitySeverity,
1336
+ ): void {
1337
+ const direct = severity === "error"
1338
+ ? originalShowError
1339
+ : severity === "warning"
1340
+ ? originalShowWarning
1341
+ : originalShowStatus;
1342
+ try {
1343
+ if (typeof direct === "function") direct.call(this, text);
1344
+ else originalShowExtensionNotify.call(this, text, severity === "info" ? undefined : severity);
1345
+ } catch {
1346
+ // This host has no transcript to write to. The notification is already
1347
+ // recorded in the activity timeline, and it does not belong in the
1348
+ // transient UI flash, which only carries Pi UI state messages.
1349
+ }
1350
+ };
1351
+
1352
+ const patchedShowExtensionNotify = function (
1353
+ this: any,
1354
+ message: string,
1355
+ type?: "info" | "warning" | "error",
1356
+ ) {
1357
+ if (renderMode !== "clean") {
1358
+ return originalShowExtensionNotify.call(this, message, type);
1359
+ }
1360
+ const severity: ActivitySeverity = type ?? "info";
1361
+ if (!appendRuntimeActivityUpdate(this, message, severity)) {
1362
+ nativeExtensionNotify.call(this, message, severity);
1363
+ }
1364
+ };
1365
+
1366
+ const patchedToggleThinkingBlockVisibility = function (this: any) {
1367
+ if (renderMode !== "clean") {
1368
+ return originalToggleThinkingBlockVisibility.call(this);
1369
+ }
1370
+ // Do not change Pi's persisted hideThinkingBlock setting in clean mode.
1371
+ // Thought details use the activity hierarchy instead: click one thought,
1372
+ // or use Ctrl+O when every member should expand together.
1373
+ this.showStatus(
1374
+ "Thought details expand individually in clean mode\nClick a thought, or use Ctrl+O to expand all.",
1375
+ );
1376
+ };
1377
+
1378
+ const patchedShowStatus = function (this: any, message: string) {
1379
+ if (renderMode !== "clean") return originalShowStatus.call(this, message);
1380
+ const [firstLine, ...remainingLines] = String(message).split("\n");
1381
+ setLatestActivityStatus({
1382
+ title: firstLine || "Status",
1383
+ detail: remainingLines.join("\n"),
1384
+ severity: "status",
1385
+ });
1386
+ this.ui?.requestRender?.();
1387
+ };
1388
+
1389
+ const patchedShowWarning = function (this: any, message: string) {
1390
+ if (renderMode !== "clean" || !appendRuntimeActivityUpdate(this, message, "warning")) {
1391
+ return originalShowWarning.call(this, message);
1392
+ }
1393
+ };
1394
+
1395
+ const patchedShowError = function (this: any, message: string) {
1396
+ if (renderMode !== "clean" || !appendRuntimeActivityUpdate(this, message, "error")) {
1397
+ return originalShowError.call(this, message);
1398
+ }
1399
+ };
1400
+
443
1401
  interactiveModePrototype[toolsExpansionPatchKey] = {
444
1402
  originalSetToolsExpanded,
445
1403
  patchedSetToolsExpanded,
@@ -447,14 +1405,30 @@ export default function prettyTui(pi: ExtensionAPI) {
447
1405
  patchedRenderSessionEntries,
448
1406
  originalSwitchTuiMode,
449
1407
  patchedSwitchTuiMode,
1408
+ originalShowExtensionNotify,
1409
+ patchedShowExtensionNotify,
1410
+ originalToggleThinkingBlockVisibility,
1411
+ patchedToggleThinkingBlockVisibility,
1412
+ originalShowStatus,
1413
+ patchedShowStatus,
1414
+ originalShowWarning,
1415
+ patchedShowWarning,
1416
+ originalShowError,
1417
+ patchedShowError,
450
1418
  };
451
1419
  interactiveModePrototype.setToolsExpanded = patchedSetToolsExpanded;
452
1420
  interactiveModePrototype.renderSessionEntries = patchedRenderSessionEntries;
453
1421
  interactiveModePrototype.switchTuiMode = patchedSwitchTuiMode;
1422
+ interactiveModePrototype.showExtensionNotify = patchedShowExtensionNotify;
1423
+ interactiveModePrototype.toggleThinkingBlockVisibility = patchedToggleThinkingBlockVisibility;
1424
+ interactiveModePrototype.showStatus = patchedShowStatus;
1425
+ interactiveModePrototype.showWarning = patchedShowWarning;
1426
+ interactiveModePrototype.showError = patchedShowError;
454
1427
 
455
1428
  pi.on("session_shutdown", () => {
456
1429
  fullscreenTui = false;
457
1430
  currentTui = undefined;
1431
+ clearLatestActivityStatus();
458
1432
  currentExtensionUi = undefined;
459
1433
  const patch = interactiveModePrototype[toolsExpansionPatchKey];
460
1434
  if (!patch) return;
@@ -467,10 +1441,30 @@ export default function prettyTui(pi: ExtensionAPI) {
467
1441
  if (patch.patchedSwitchTuiMode === interactiveModePrototype.switchTuiMode) {
468
1442
  interactiveModePrototype.switchTuiMode = patch.originalSwitchTuiMode;
469
1443
  }
1444
+ if (patch.patchedShowExtensionNotify === interactiveModePrototype.showExtensionNotify) {
1445
+ interactiveModePrototype.showExtensionNotify = patch.originalShowExtensionNotify;
1446
+ }
1447
+ if (patch.patchedToggleThinkingBlockVisibility === interactiveModePrototype.toggleThinkingBlockVisibility) {
1448
+ interactiveModePrototype.toggleThinkingBlockVisibility = patch.originalToggleThinkingBlockVisibility;
1449
+ }
1450
+ if (patch.patchedShowStatus === interactiveModePrototype.showStatus) {
1451
+ interactiveModePrototype.showStatus = patch.originalShowStatus;
1452
+ }
1453
+ if (patch.patchedShowWarning === interactiveModePrototype.showWarning) {
1454
+ interactiveModePrototype.showWarning = patch.originalShowWarning;
1455
+ }
1456
+ if (patch.patchedShowError === interactiveModePrototype.showError) {
1457
+ interactiveModePrototype.showError = patch.originalShowError;
1458
+ }
470
1459
  if (
471
1460
  interactiveModePrototype.setToolsExpanded === patch.originalSetToolsExpanded &&
472
1461
  interactiveModePrototype.renderSessionEntries === patch.originalRenderSessionEntries &&
473
- interactiveModePrototype.switchTuiMode === patch.originalSwitchTuiMode
1462
+ interactiveModePrototype.switchTuiMode === patch.originalSwitchTuiMode &&
1463
+ interactiveModePrototype.showExtensionNotify === patch.originalShowExtensionNotify &&
1464
+ interactiveModePrototype.toggleThinkingBlockVisibility === patch.originalToggleThinkingBlockVisibility &&
1465
+ interactiveModePrototype.showStatus === patch.originalShowStatus &&
1466
+ interactiveModePrototype.showWarning === patch.originalShowWarning &&
1467
+ interactiveModePrototype.showError === patch.originalShowError
474
1468
  ) {
475
1469
  delete interactiveModePrototype[toolsExpansionPatchKey];
476
1470
  }
@@ -546,6 +1540,7 @@ export default function prettyTui(pi: ExtensionAPI) {
546
1540
  const codeBlockPatchKey = Symbol.for("pretty-tui.code-blocks");
547
1541
  const codeBlockCollectionKey = Symbol("pretty-tui.code-block-collection");
548
1542
  const codeBlockRegionsKey = Symbol("pretty-tui.code-block-regions");
1543
+ const transcriptMarkdownStateKey = Symbol("pretty-tui.transcript-markdown-state");
549
1544
  if (!markdownPrototype[codeBlockPatchKey]) {
550
1545
  const originalInvalidate = markdownPrototype.invalidate;
551
1546
  const originalRender = markdownPrototype.render;
@@ -561,6 +1556,15 @@ export default function prettyTui(pi: ExtensionAPI) {
561
1556
  };
562
1557
 
563
1558
  const patchedRender = function (this: any, width: number): string[] {
1559
+ const scoped = transcriptMarkdownActive();
1560
+ if (this[transcriptMarkdownStateKey] !== scoped) {
1561
+ delete this[codeBlockCollectionKey];
1562
+ delete this[codeBlockRegionsKey];
1563
+ originalInvalidate.call(this);
1564
+ this[transcriptMarkdownStateKey] = scoped;
1565
+ }
1566
+ if (!scoped) return originalRender.call(this, width);
1567
+
564
1568
  const needsRender =
565
1569
  !this.cachedLines || this.cachedText !== this.text || this.cachedWidth !== width;
566
1570
  if (needsRender) this[codeBlockCollectionKey] = [];
@@ -604,6 +1608,10 @@ export default function prettyTui(pi: ExtensionAPI) {
604
1608
  nextTokenType?: string,
605
1609
  styleContext?: any,
606
1610
  ): string[] {
1611
+ if (!transcriptMarkdownActive()) {
1612
+ return originalRenderToken.call(this, token, width, nextTokenType, styleContext);
1613
+ }
1614
+
607
1615
  if (token?.type === "heading") {
608
1616
  const level = Math.max(1, Math.min(6, Number(token.depth) || 1));
609
1617
  const maxWidth = Math.max(1, width);
@@ -766,6 +1774,15 @@ export default function prettyTui(pi: ExtensionAPI) {
766
1774
  };
767
1775
 
768
1776
  const patchedHandleMouse = function (this: any, event: TuiMouseEvent) {
1777
+ // Fullscreen layout hit-testing may dispatch directly to this Markdown
1778
+ // leaf instead of traversing AssistantMessageComponent.handleMouse. The
1779
+ // most recent render therefore records whether this instance belongs to
1780
+ // the main transcript; dynamic render scope alone is not sufficient.
1781
+ const transcriptOwned = transcriptMarkdownActive() || this[transcriptMarkdownStateKey] === true;
1782
+ if (!transcriptOwned) {
1783
+ delete this[codeBlockRegionsKey];
1784
+ return originalHandleMouse?.call(this, event);
1785
+ }
769
1786
  if (event.button === "left" && (event.type === "press" || event.type === "click")) {
770
1787
  const region = (this[codeBlockRegionsKey] ?? []).find(
771
1788
  (candidate: any) =>
@@ -782,8 +1799,14 @@ export default function prettyTui(pi: ExtensionAPI) {
782
1799
  } else {
783
1800
  // Defensive fallback for older Pi versions without the fullscreen helper.
784
1801
  void copyToClipboard(region.code)
785
- .then(() => currentExtensionUi?.notify("Copied!", "info"))
786
- .catch(() => currentExtensionUi?.notify("Copy failed", "error"));
1802
+ .then(() => setLatestActivityStatus({
1803
+ title: "Copied!",
1804
+ severity: "success",
1805
+ }))
1806
+ .catch(() => setLatestActivityStatus({
1807
+ title: "Copy failed",
1808
+ severity: "error",
1809
+ }));
787
1810
  }
788
1811
  }
789
1812
  return { handled: true, render: false };
@@ -913,10 +1936,24 @@ export default function prettyTui(pi: ExtensionAPI) {
913
1936
  context.state.compactToolStatus = status;
914
1937
  };
915
1938
 
1939
+ const foregroundLuminance = (styled: string): number | undefined => {
1940
+ const trueColor = /\x1b\[38;2;(\d+);(\d+);(\d+)m/.exec(styled);
1941
+ if (!trueColor) return undefined;
1942
+ const [, red, green, blue] = trueColor.map(Number);
1943
+ return red * 0.2126 + green * 0.7152 + blue * 0.0722;
1944
+ };
1945
+
1946
+ const successMarkerColor = (theme: any): string => {
1947
+ const textLuminance = foregroundLuminance(theme.fg("text", "M"));
1948
+ return textLuminance !== undefined && textLuminance < 128
1949
+ ? "success"
1950
+ : "syntaxComment";
1951
+ };
1952
+
916
1953
  const callRow = (theme: any, name: string, detail: string, state: any): DisplayRow => ({
917
1954
  prefix: () => {
918
1955
  const status = (state.compactToolStatus ?? "running") as ToolStatus;
919
- const color = status === "success" ? "success" : status === "error" ? "error" : "dim";
1956
+ const color = status === "success" ? successMarkerColor(theme) : status === "error" ? "error" : "dim";
920
1957
  return theme.fg(color, "● ");
921
1958
  },
922
1959
  continuation: " ",
@@ -930,7 +1967,7 @@ export default function prettyTui(pi: ExtensionAPI) {
930
1967
  const call = (theme: any, name: string, detail: string, state: any) =>
931
1968
  block([callRow(theme, name, detail, state)]);
932
1969
 
933
- const writeCall = (theme: any, path: string, content: string, expanded: boolean, state: any) => {
1970
+ const writeCall = (theme: any, name: string, path: string, content: string, expanded: boolean, state: any) => {
934
1971
  const lines = content.replace(/\t/g, " ").split("\n");
935
1972
  while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
936
1973
 
@@ -938,14 +1975,14 @@ export default function prettyTui(pi: ExtensionAPI) {
938
1975
  const shown = lines.slice(0, expanded ? total : 10);
939
1976
  const remaining = total - shown.length;
940
1977
  const rows: DisplayRow[] = [
941
- callRow(theme, "Write", `${path} · ${total} ${total === 1 ? "line" : "lines"}`, state),
1978
+ callRow(theme, name, `${path} · ${total} ${total === 1 ? "line" : "lines"}`, state),
942
1979
  ];
943
1980
 
944
1981
  for (let index = 0; index < shown.length; index++) {
945
1982
  const isLast = index === shown.length - 1 && remaining === 0;
946
1983
  rows.push({
947
1984
  prefix: theme.fg("dim", isLast ? " └ " : " │ "),
948
- continuation: theme.fg("dim", " │ "),
1985
+ continuation: isLast ? " " : theme.fg("dim", " │ "),
949
1986
  content: theme.fg("toolOutput", shown[index] || " "),
950
1987
  });
951
1988
  }
@@ -978,19 +2015,22 @@ export default function prettyTui(pi: ExtensionAPI) {
978
2015
 
979
2016
  if (expanded && output) {
980
2017
  const lines = output.split("\n");
2018
+ while (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
981
2019
  const shown = lines.slice(0, 40);
982
- for (const line of shown) {
2020
+ const omitted = lines.length - shown.length;
2021
+ for (let index = 0; index < shown.length; index++) {
2022
+ const isLast = index === shown.length - 1 && omitted === 0;
983
2023
  rows.push({
984
- prefix: theme.fg("dim", " │ "),
985
- continuation: theme.fg("dim", " │ "),
986
- content: outputStyle(line || " "),
2024
+ prefix: theme.fg("dim", isLast ? " └ " : " │ "),
2025
+ continuation: isLast ? " " : theme.fg("dim", " │ "),
2026
+ content: outputStyle(shown[index] || " "),
987
2027
  });
988
2028
  }
989
- if (lines.length > shown.length) {
2029
+ if (omitted > 0) {
990
2030
  rows.push({
991
2031
  prefix: theme.fg("muted", " └ "),
992
2032
  continuation: " ",
993
- content: theme.fg("muted", `… ${lines.length - shown.length} more lines`),
2033
+ content: theme.fg("muted", `… ${omitted} more lines`),
994
2034
  });
995
2035
  }
996
2036
  }
@@ -998,8 +2038,11 @@ export default function prettyTui(pi: ExtensionAPI) {
998
2038
  return block(rows);
999
2039
  };
1000
2040
 
1001
- const isError = (renderContext: any, output: string): boolean =>
1002
- Boolean(renderContext?.isError) || /^(error|failed|access denied)\b/i.test(output.trim());
2041
+ // Pi's render context always carries the authoritative failure flag
2042
+ // (tool-execution.js builds it from result.isError). A tool's own output text
2043
+ // must never decide this: `grep -n Error`, `cat error.log`, or a test runner
2044
+ // printing "failed 0" would otherwise mark a successful call as failed.
2045
+ const isError = (renderContext: any): boolean => Boolean(renderContext?.isError);
1003
2046
 
1004
2047
  const partialResult = (context: any, theme: any, label: string): Component => {
1005
2048
  setStatus(context, "running");
@@ -1064,7 +2107,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1064
2107
  const isLast = index === shown.length - 1;
1065
2108
  rows.push({
1066
2109
  prefix: theme.fg("dim", isLast ? " └ " : " │ "),
1067
- continuation: theme.fg("dim", " │ "),
2110
+ continuation: isLast ? " " : theme.fg("dim", " │ "),
1068
2111
  content: theme.fg("toolOutput", shown[index] || " "),
1069
2112
  });
1070
2113
  }
@@ -1072,8 +2115,8 @@ export default function prettyTui(pi: ExtensionAPI) {
1072
2115
  return block(rows);
1073
2116
  };
1074
2117
 
1075
- const completeStatus = (context: any, output: string): boolean => {
1076
- const failed = isError(context, output);
2118
+ const completeStatus = (context: any): boolean => {
2119
+ const failed = isError(context);
1077
2120
  setStatus(context, failed ? "error" : "success");
1078
2121
  return failed;
1079
2122
  };
@@ -1081,6 +2124,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1081
2124
  type ToolSummaryGroup = {
1082
2125
  count: number;
1083
2126
  failed: number;
2127
+ thoughtCount?: number;
1084
2128
  lastToolCallId: string;
1085
2129
  toolCallIds?: string[];
1086
2130
  activity?: string;
@@ -1088,12 +2132,12 @@ export default function prettyTui(pi: ExtensionAPI) {
1088
2132
  type ToolSummaryData = {
1089
2133
  count?: number;
1090
2134
  failed?: number;
2135
+ thoughtCount?: number;
1091
2136
  /** Identifies the last tool component for older single-group entries. */
1092
2137
  lastToolCallId?: string;
1093
2138
  /** All groups from a run, used to restore summaries after reload. */
1094
2139
  groups?: ToolSummaryGroup[];
1095
2140
  };
1096
- const supportedTools = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]);
1097
2141
  type ToolActivityHold = {
1098
2142
  toolCallId: string;
1099
2143
  name: string;
@@ -1102,7 +2146,12 @@ export default function prettyTui(pi: ExtensionAPI) {
1102
2146
  started: boolean;
1103
2147
  timer?: ReturnType<typeof setTimeout>;
1104
2148
  };
1105
- const settledSummaries = new Map<string, { count: number; failed: number; activity: DisplayValue }>();
2149
+ const settledSummaries = new Map<string, {
2150
+ count: number;
2151
+ failed: number;
2152
+ thoughtCount: number;
2153
+ activity: DisplayValue;
2154
+ }>();
1106
2155
  const toolActivityHolds = new Map<string, ToolActivityHold>();
1107
2156
  const legacySummaryLastToolCallIds = new Map<string, string>();
1108
2157
  const knownToolCallIds = new Set<string>();
@@ -1130,8 +2179,64 @@ export default function prettyTui(pi: ExtensionAPI) {
1130
2179
  settled: false,
1131
2180
  };
1132
2181
 
1133
- const toolDisplayName = (name: string): string =>
1134
- name === "ls" ? "List" : name.charAt(0).toUpperCase() + name.slice(1);
2182
+ const toolLabel = (toolName: string, label?: string): string => label || toolName;
2183
+
2184
+ const conciseThirdPartyArgs = (args: any): string => {
2185
+ if (!args || typeof args !== "object" || Array.isArray(args)) return "";
2186
+ const sensitive = /(?:token|secret|password|authorization|credential|api[_-]?key)/iu;
2187
+ const preferred = ["path", "query", "url", "offset", "id"];
2188
+ const entries = Object.entries(args)
2189
+ .filter(([key, value]) => !sensitive.test(key) && ["string", "number", "boolean"].includes(typeof value))
2190
+ .sort(([left], [right]) => {
2191
+ const leftRank = preferred.indexOf(left);
2192
+ const rightRank = preferred.indexOf(right);
2193
+ return (leftRank < 0 ? preferred.length : leftRank) -
2194
+ (rightRank < 0 ? preferred.length : rightRank);
2195
+ })
2196
+ .slice(0, 2)
2197
+ .map(([key, value]) => {
2198
+ const safeValue = String(value)
2199
+ .replace(/(https?:\/\/)[^\s/@:]+:[^\s/@]+@/giu, "$1…:…@")
2200
+ .replace(/([?&](?:token|secret|password|api[_-]?key)=)[^&\s]+/giu, "$1…");
2201
+ const rendered = truncateToWidth(safeValue, 36, "…");
2202
+ return `${key}=${rendered}`;
2203
+ });
2204
+ return entries.join(" · ");
2205
+ };
2206
+
2207
+ const thirdPartyResultSummary = (component: any): string => {
2208
+ const detailsError = component.result?.details?.error;
2209
+ const firstText = component.result?.content?.find(
2210
+ (item: any) => item?.type === "text" && typeof item.text === "string" && item.text.length > 0,
2211
+ )?.text;
2212
+ const text = String(typeof detailsError === "string" ? detailsError : firstText ?? "");
2213
+ const boundedText = text.slice(0, 512);
2214
+ const newline = boundedText.indexOf("\n");
2215
+ const boundedFirstLine = boundedText.slice(0, newline < 0 ? boundedText.length : newline);
2216
+ const firstLine = stripTerminalSequences(boundedFirstLine).trim();
2217
+ if (firstLine) return truncateToWidth(firstLine, 72, "…");
2218
+ if (component.result) return component.result.isError ? "Failed" : "Done";
2219
+ return component.executionStarted ? "Running…" : "Pending";
2220
+ };
2221
+
2222
+ const renderThirdPartyCompact = (component: any, width: number, theme: any): string[] => {
2223
+ const label = toolLabel(component.toolName, component.toolDefinition?.label);
2224
+ const args = conciseThirdPartyArgs(component.args);
2225
+ const dotColor = component.result?.isError
2226
+ ? "error"
2227
+ : component.result && !component.isPartial
2228
+ ? successMarkerColor(theme)
2229
+ : "accent";
2230
+ const title = theme.fg(dotColor, "● ") +
2231
+ theme.fg("accent", theme.bold(label)) +
2232
+ (args ? theme.fg("muted", `(${args})`) : "");
2233
+ const result = thirdPartyResultSummary(component);
2234
+ const resultColor = component.result?.isError ? "error" : "muted";
2235
+ return [
2236
+ truncateToWidth(title, Math.max(1, width), "…"),
2237
+ theme.fg(resultColor, truncateToWidth(` └ ${result}`, Math.max(1, width), "…")),
2238
+ ];
2239
+ };
1135
2240
 
1136
2241
  const clearToolActivityHolds = () => {
1137
2242
  for (const hold of toolActivityHolds.values()) {
@@ -1178,7 +2283,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1178
2283
  toolCallId,
1179
2284
  name,
1180
2285
  until: startedAt + CLEAN_TOOL_ACTIVITY_MIN_MS,
1181
- after: "thinking...",
2286
+ after: "thinking",
1182
2287
  started,
1183
2288
  });
1184
2289
  cleanRun.activeToolName = name;
@@ -1193,14 +2298,14 @@ export default function prettyTui(pi: ExtensionAPI) {
1193
2298
  toolCallId,
1194
2299
  name,
1195
2300
  until: now + CLEAN_TOOL_ACTIVITY_MIN_MS,
1196
- after: "thinking...",
2301
+ after: "thinking",
1197
2302
  started: true,
1198
2303
  };
1199
2304
  toolActivityHolds.set(toolCallId, hold);
1200
2305
  }
1201
2306
  hold.name = name;
1202
2307
  hold.started = true;
1203
- hold.after = "thinking...";
2308
+ hold.after = "thinking";
1204
2309
  scheduleToolActivityRelease(hold);
1205
2310
  };
1206
2311
 
@@ -1214,15 +2319,18 @@ export default function prettyTui(pi: ExtensionAPI) {
1214
2319
  const liveCleanToolCount = (): number =>
1215
2320
  Math.max(cleanRun.count, cleanRun.currentToolCallIds.length);
1216
2321
 
2322
+ const thoughtCountForTool = (toolCallId: string | undefined): number =>
2323
+ toolCallId ? activityTimeline.groupForTool(toolCallId)?.thoughtCount ?? 0 : 0;
2324
+
1217
2325
  const currentCleanActivity = (): string => {
1218
2326
  if (cleanRun.activeToolCallId) {
1219
- return cleanRun.activeToolName ?? cleanRun.activity ?? "thinking...";
2327
+ return cleanRun.activeToolName ?? cleanRun.activity ?? "thinking";
1220
2328
  }
1221
2329
  if (cleanRun.lastCompletedToolCallId) {
1222
2330
  const hold = toolActivityHolds.get(cleanRun.lastCompletedToolCallId);
1223
2331
  if (hold) return heldActivity(hold);
1224
2332
  }
1225
- return cleanRun.activeToolName ?? cleanRun.activity ?? (cleanRun.active ? "thinking..." : "done");
2333
+ return cleanRun.activeToolName ?? cleanRun.activity ?? (cleanRun.active ? "thinking" : "done");
1226
2334
  };
1227
2335
 
1228
2336
  pi.on("session_shutdown", clearToolActivityHolds);
@@ -1238,116 +2346,198 @@ export default function prettyTui(pi: ExtensionAPI) {
1238
2346
  const originalToolRender = toolExecutionPrototype.render;
1239
2347
  const originalToolHandleMouse = toolExecutionPrototype.handleMouse;
1240
2348
  const renderedModeKey = Symbol("pretty-tui.tool-rendered-mode");
1241
- const revealCleanGroup = (groupToolCallIds: string[], ui?: any) => {
1242
- for (const toolCallId of groupToolCallIds) cleanCompactToolCallIds.add(toolCallId);
1243
- for (const toolCallId of groupToolCallIds) cleanToolComponents.get(toolCallId)?.updateDisplay();
2349
+ const cleanChildRenderCacheKey = Symbol("pretty-tui.clean-child-render-cache");
2350
+ const refreshActivityGroup = (group: ActivityGroup) => {
2351
+ for (const member of group.members) {
2352
+ if (member.kind === "tool" && member.toolCallId) {
2353
+ cleanToolComponents.get(member.toolCallId)?.updateDisplay();
2354
+ } else if (member.kind === "thinking" && member.messageKey) {
2355
+ thinkingComponents.get(member.messageKey)?.invalidate?.();
2356
+ }
2357
+ }
2358
+ };
2359
+ const revealCleanGroup = (group: ActivityGroup, ui?: any) => {
2360
+ revealedActivityGroups.add(group.id);
2361
+ for (const toolCallId of group.toolCallIds) cleanCompactToolCallIds.add(toolCallId);
2362
+ refreshActivityGroup(group);
2363
+ ui?.requestRender?.();
2364
+ };
2365
+ const collapseCleanGroup = (group: ActivityGroup, ui?: any) => {
2366
+ revealedActivityGroups.delete(group.id);
2367
+ for (const toolCallId of group.toolCallIds) {
2368
+ const component = cleanToolComponents.get(toolCallId);
2369
+ if (component?.expanded) originalSetExpanded.call(component, false);
2370
+ cleanCompactToolCallIds.delete(toolCallId);
2371
+ }
2372
+ for (const member of group.members) expandedThinkingMembers.delete(member.id);
2373
+ refreshActivityGroup(group);
1244
2374
  ui?.requestRender?.();
1245
2375
  };
1246
2376
  const patchedMarkExecutionStarted = function (this: any) {
1247
- if (supportedTools.has(this.toolName)) {
1248
- if (!cleanRun.currentToolCallIds.includes(this.toolCallId)) {
1249
- cleanRun.currentToolCallIds.push(this.toolCallId);
1250
- }
1251
- cleanRun.activeToolCallIds.add(this.toolCallId);
1252
- cleanToolNames.set(this.toolCallId, toolDisplayName(this.toolName));
1253
- setCleanGroupMembers(this.toolCallId, cleanRun.currentToolCallIds.slice());
1254
- if (typeof this.ui?.requestRender === "function") {
1255
- cleanRun.requestRender = () => this.ui.requestRender();
1256
- }
1257
- cleanRun.active = true;
1258
- cleanRun.activeToolCallId = this.toolCallId;
1259
- beginToolActivity(this.toolCallId, toolDisplayName(this.toolName), true);
1260
- }
2377
+ activityTimeline.addTool(this.toolCallId, this.toolName);
2378
+ if (!cleanRun.currentToolCallIds.includes(this.toolCallId)) {
2379
+ cleanRun.currentToolCallIds.push(this.toolCallId);
2380
+ }
2381
+ cleanRun.activeToolCallIds.add(this.toolCallId);
2382
+ const displayName = toolLabel(this.toolName, this.toolDefinition?.label);
2383
+ cleanToolNames.set(this.toolCallId, displayName);
2384
+ setCleanGroupMembers(this.toolCallId, cleanRun.currentToolCallIds.slice());
2385
+ if (typeof this.ui?.requestRender === "function") {
2386
+ cleanRun.requestRender = () => this.ui.requestRender();
2387
+ }
2388
+ cleanRun.active = true;
2389
+ cleanRun.activeToolCallId = this.toolCallId;
2390
+ beginToolActivity(this.toolCallId, displayName, true);
1261
2391
  return originalMarkExecutionStarted.call(this);
1262
2392
  };
1263
2393
  const patchedSetExpanded = function (this: any, expanded: boolean) {
1264
- const groupToolCallIds = cleanGroupToolCallIds.get(this.toolCallId);
2394
+ const group = activityTimeline.groupForTool(this.toolCallId);
1265
2395
  if (
1266
2396
  renderMode === "clean" &&
1267
2397
  !changingAllToolsExpansion &&
1268
2398
  expanded &&
1269
2399
  !this.expanded &&
1270
- !isCleanGroupRevealed(this.toolCallId) &&
1271
- groupToolCallIds?.length
2400
+ group &&
2401
+ !activityGroupRevealed(group)
1272
2402
  ) {
1273
- revealCleanGroup(groupToolCallIds, this.ui);
2403
+ revealCleanGroup(group, this.ui);
1274
2404
  return;
1275
2405
  }
1276
2406
  return originalSetExpanded.call(this, expanded);
1277
2407
  };
1278
2408
  const patchedToolRender = function (this: any, width: number): string[] {
1279
- if (supportedTools.has(this.toolName)) {
1280
- cleanToolComponents.set(this.toolCallId, this);
1281
- knownToolCallIds.add(this.toolCallId);
1282
- }
2409
+ cleanToolComponents.set(this.toolCallId, this);
2410
+ knownToolCallIds.add(this.toolCallId);
2411
+ const member = activityTimeline.addTool(this.toolCallId, this.toolName);
2412
+ cleanToolNames.set(
2413
+ this.toolCallId,
2414
+ toolLabel(this.toolName, this.toolDefinition?.label),
2415
+ );
1283
2416
  if (this[renderedModeKey] !== renderMode) {
1284
2417
  this[renderedModeKey] = renderMode;
1285
2418
  this.updateDisplay();
1286
2419
  }
1287
2420
 
1288
- const groupOwner = cleanToolCallGroupOwners.get(this.toolCallId);
1289
- const groupToolCallIds = groupOwner ? cleanGroupToolCallIds.get(groupOwner) : undefined;
1290
- const childIndex = groupToolCallIds?.indexOf(this.toolCallId) ?? -1;
1291
- const showAsChild = renderMode === "clean" && childIndex >= 0 && isCleanGroupRevealed(this.toolCallId);
1292
- if (!showAsChild || !groupOwner || !groupToolCallIds) {
1293
- return originalToolRender.call(this, width);
2421
+ const group = activityTimeline.groupForMember(member.id);
2422
+ if (renderMode !== "clean" || !group) return originalToolRender.call(this, width);
2423
+ const position = activityMemberPosition(group, member);
2424
+ if (!activityGroupRevealed(group)) {
2425
+ return position.first
2426
+ ? ["", ...renderActivityGroupSummary(group, width, true)]
2427
+ : [];
2428
+ }
2429
+
2430
+ const childTheme = cleanThemeForToolCall(this.toolCallId) ?? activityGroupTheme(group);
2431
+ const {
2432
+ prefix: childPrefix,
2433
+ continuation,
2434
+ childWidth,
2435
+ } = activityTreeStyle(group, member, width, childTheme);
2436
+ const thirdParty = !SPECIALIZED_TOOL_NAMES.has(this.toolName);
2437
+ const cacheable = !this.expanded && Boolean(this.result) && !this.isPartial;
2438
+ const cached = cacheable ? this[cleanChildRenderCacheKey] : undefined;
2439
+ let decoratedContent: string[];
2440
+ if (
2441
+ cached?.width === width &&
2442
+ cached?.childWidth === childWidth &&
2443
+ cached?.result === this.result &&
2444
+ cached?.args === this.args &&
2445
+ cached?.callRenderer === this.callRendererComponent &&
2446
+ cached?.resultRenderer === this.resultRendererComponent &&
2447
+ cached?.theme === childTheme &&
2448
+ cached?.last === position.last &&
2449
+ cached?.memberCount === group.members.length
2450
+ ) {
2451
+ decoratedContent = cached.lines;
2452
+ } else {
2453
+ let contentLines: string[];
2454
+ if (thirdParty) {
2455
+ const summaryLines = renderThirdPartyCompact(this, childWidth, childTheme);
2456
+ if (this.expanded) {
2457
+ const detailPrefix = " │ ";
2458
+ const nativeLines = originalToolRender.call(
2459
+ this,
2460
+ Math.max(1, childWidth - visibleWidth(detailPrefix)),
2461
+ );
2462
+ const nativeContentLines = nativeLines[0] === "" ? nativeLines.slice(1) : nativeLines;
2463
+ contentLines = [
2464
+ summaryLines[0],
2465
+ ...nativeContentLines.map((line: string, index: number) =>
2466
+ childTheme.fg(
2467
+ "dim",
2468
+ index === nativeContentLines.length - 1 ? " └ " : detailPrefix,
2469
+ ) + stripAnsiBackgrounds(line)
2470
+ ),
2471
+ ];
2472
+ } else {
2473
+ contentLines = summaryLines;
2474
+ }
2475
+ } else {
2476
+ const lines = originalToolRender.call(this, childWidth);
2477
+ if (lines.length === 0) return lines;
2478
+ contentLines = lines[0] === "" ? lines.slice(1) : lines;
2479
+ }
2480
+ decoratedContent = contentLines.map((line: string, index: number) =>
2481
+ truncateToWidth(
2482
+ (index === 0 ? childPrefix : continuation) + line,
2483
+ Math.max(1, width),
2484
+ "",
2485
+ )
2486
+ );
2487
+ if (cacheable) {
2488
+ this[cleanChildRenderCacheKey] = {
2489
+ width,
2490
+ childWidth,
2491
+ result: this.result,
2492
+ args: this.args,
2493
+ callRenderer: this.callRendererComponent,
2494
+ resultRenderer: this.resultRendererComponent,
2495
+ theme: childTheme,
2496
+ last: position.last,
2497
+ memberCount: group.members.length,
2498
+ lines: decoratedContent,
2499
+ };
2500
+ }
1294
2501
  }
1295
-
1296
- const rawChildPrefix = childIndex === groupToolCallIds.length - 1 ? " └─ " : " ├─ ";
1297
- const rawContinuation = childIndex === groupToolCallIds.length - 1 ? " " : " │ ";
1298
- const childTheme = cleanThemeForToolCall(this.toolCallId);
1299
- const childPrefix = childTheme ? childTheme.fg("dim", rawChildPrefix) : rawChildPrefix;
1300
- const continuation = childTheme ? childTheme.fg("dim", rawContinuation) : rawContinuation;
1301
- const childWidth = Math.max(1, width - visibleWidth(rawChildPrefix));
1302
- const lines = originalToolRender.call(this, childWidth);
1303
- if (lines.length === 0) return lines;
1304
-
1305
- const decoratedContent = lines.slice(1).map((line: string, index: number) =>
1306
- (index === 0 ? childPrefix : continuation) + line
1307
- );
1308
- if (childIndex !== 0) return decoratedContent;
1309
- return [lines[0], ...renderCleanGroupSummary(groupOwner, width), ...decoratedContent];
2502
+ if (!position.first) return decoratedContent;
2503
+ return ["", ...renderActivityGroupSummary(group, width), ...decoratedContent];
1310
2504
  };
1311
2505
  const patchedToolHandleMouse = function (this: any, event: any) {
1312
- const groupOwner = cleanToolCallGroupOwners.get(this.toolCallId);
1313
- const groupToolCallIds = groupOwner ? cleanGroupToolCallIds.get(groupOwner) : undefined;
1314
- const childIndex = groupToolCallIds?.indexOf(this.toolCallId) ?? -1;
1315
- const showAsChild = renderMode === "clean" && childIndex >= 0 && isCleanGroupRevealed(this.toolCallId);
2506
+ const member = activityTimeline.memberForTool(this.toolCallId);
2507
+ const group = member ? activityTimeline.groupForMember(member.id) : undefined;
1316
2508
  const isLeftClick = event.type === "click" && event.button === "left";
2509
+ if (renderMode !== "clean" || !member || !group) {
2510
+ return originalToolHandleMouse.call(this, event);
2511
+ }
2512
+ const position = activityMemberPosition(group, member);
2513
+ const revealed = activityGroupRevealed(group);
1317
2514
 
1318
- // Pi normally ignores tool clicks until a result exists. Clean mode can
1319
- // still reveal the active call safely because this changes presentation
1320
- // only; it does not affect or delay execution.
1321
- if (
1322
- renderMode === "clean" &&
1323
- !showAsChild &&
1324
- isLeftClick &&
1325
- this.toolCallId === groupOwner &&
1326
- groupToolCallIds?.length
1327
- ) {
1328
- revealCleanGroup(groupToolCallIds, this.ui);
2515
+ if (!revealed && position.first && isLeftClick) {
2516
+ revealCleanGroup(group, this.ui);
1329
2517
  return { handled: true };
1330
2518
  }
1331
- if (!showAsChild || !groupToolCallIds) return originalToolHandleMouse.call(this, event);
2519
+ if (!revealed) return isLeftClick ? { handled: true } : undefined;
1332
2520
 
1333
- const summaryHeight = childIndex === 0 ? renderCleanGroupSummary(groupOwner!, event.width).length : 0;
1334
- if (
1335
- childIndex === 0 &&
1336
- event.y > 0 &&
1337
- event.y <= summaryHeight &&
1338
- isLeftClick
1339
- ) {
2521
+ const summaryHeight = position.first ? renderActivityGroupSummary(group, event.width).length : 0;
2522
+ if (position.first && event.y > 0 && event.y <= summaryHeight && isLeftClick) {
1340
2523
  changingAllToolsExpansion = true;
1341
2524
  try {
1342
- for (const toolCallId of groupToolCallIds) {
1343
- const component = cleanToolComponents.get(toolCallId);
1344
- if (component?.expanded) originalSetExpanded.call(component, false);
1345
- cleanCompactToolCallIds.delete(toolCallId);
1346
- }
1347
- for (const toolCallId of groupToolCallIds) cleanToolComponents.get(toolCallId)?.updateDisplay();
2525
+ collapseCleanGroup(group, this.ui);
1348
2526
  } finally {
1349
2527
  changingAllToolsExpansion = false;
1350
2528
  }
2529
+ return { handled: true };
2530
+ }
2531
+
2532
+ // Self-rendering tools reserve y=0 for Pi's leading spacer. Clean mode
2533
+ // replaces that spacer with the compact header for non-first members.
2534
+ const thirdPartyHeaderY = position.first ? summaryHeight + 1 : 0;
2535
+ if (
2536
+ !SPECIALIZED_TOOL_NAMES.has(this.toolName) &&
2537
+ isLeftClick &&
2538
+ event.y === thirdPartyHeaderY
2539
+ ) {
2540
+ originalSetExpanded.call(this, !this.expanded);
1351
2541
  this.ui?.requestRender?.();
1352
2542
  return { handled: true };
1353
2543
  }
@@ -1358,12 +2548,17 @@ export default function prettyTui(pi: ExtensionAPI) {
1358
2548
  return { handled: true };
1359
2549
  }
1360
2550
 
1361
- const childPrefixWidth = visibleWidth(" ├─ ");
2551
+ const { prefixWidth } = activityTreeStyle(
2552
+ group,
2553
+ member,
2554
+ event.width,
2555
+ cleanThemeForToolCall(this.toolCallId),
2556
+ );
1362
2557
  return originalToolHandleMouse.call(this, {
1363
2558
  ...event,
1364
- x: Math.max(0, event.x - childPrefixWidth),
1365
- y: event.y - summaryHeight + (childIndex === 0 ? 0 : 1),
1366
- width: Math.max(1, event.width - childPrefixWidth),
2559
+ x: Math.max(0, event.x - prefixWidth),
2560
+ y: event.y - summaryHeight + (position.first ? 0 : 1),
2561
+ width: Math.max(1, event.width - prefixWidth),
1367
2562
  });
1368
2563
  };
1369
2564
 
@@ -1413,6 +2608,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1413
2608
  const group: ToolSummaryGroup = {
1414
2609
  count: cleanRun.count,
1415
2610
  failed: cleanRun.failed,
2611
+ thoughtCount: thoughtCountForTool(cleanRun.lastCompletedToolCallId),
1416
2612
  lastToolCallId: cleanRun.lastCompletedToolCallId,
1417
2613
  toolCallIds: cleanRun.currentToolCallIds.slice(),
1418
2614
  activity,
@@ -1421,6 +2617,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1421
2617
  settledSummaries.set(group.lastToolCallId, {
1422
2618
  count: group.count,
1423
2619
  failed: group.failed,
2620
+ thoughtCount: group.thoughtCount ?? 0,
1424
2621
  activity: activityValueForHold(group.lastToolCallId, activity),
1425
2622
  });
1426
2623
  setCleanGroupMembers(group.lastToolCallId, group.toolCallIds ?? []);
@@ -1432,6 +2629,16 @@ export default function prettyTui(pi: ExtensionAPI) {
1432
2629
  cleanRun.lastCompletedToolCallId = undefined;
1433
2630
  cleanRun.activeToolCallId = undefined;
1434
2631
  cleanRun.activeToolName = undefined;
2632
+ activityTimeline.boundary();
2633
+ };
2634
+
2635
+ closeCleanAtNativeCustomBoundary = () => {
2636
+ finishCleanGroup("done");
2637
+ settleLastCleanGroup();
2638
+ };
2639
+ closeCleanAtStandaloneAlert = () => {
2640
+ finishCleanGroup("done");
2641
+ settleLastCleanGroup();
1435
2642
  };
1436
2643
 
1437
2644
  const settleLastCleanGroup = () => {
@@ -1441,30 +2648,46 @@ export default function prettyTui(pi: ExtensionAPI) {
1441
2648
  settledSummaries.set(group.lastToolCallId, {
1442
2649
  count: group.count,
1443
2650
  failed: group.failed,
2651
+ thoughtCount: group.thoughtCount ?? 0,
1444
2652
  activity: activityValueForHold(group.lastToolCallId, "done"),
1445
2653
  });
1446
2654
  };
1447
2655
 
1448
- const summaryText = (count: number, _failed: number, activity = "done"): string => {
1449
- const activityText = typeof activity === "string" ? activity : "done";
1450
- const countLabel = `${count} tool ${count === 1 ? "call" : "calls"}`;
1451
- const activityLabel = activityText !== "done" && activityText.trim()
1452
- ? ` · ${activityText}`
2656
+ const summaryText = (
2657
+ count: number,
2658
+ _failed: number,
2659
+ thoughtCount: number,
2660
+ activity = "done",
2661
+ ): string => {
2662
+ const rawActivityText = typeof activity === "string" ? activity : "done";
2663
+ const normalizedActivityText = rawActivityText.trim();
2664
+ const activityText = /^thinking(?:\.\.\.)?$/iu.test(normalizedActivityText)
2665
+ ? "thinking"
2666
+ : rawActivityText;
2667
+ const countLabel = count > 0 ? `${count} tool ${count === 1 ? "call" : "calls"}` : "";
2668
+ const thoughtLabel = thoughtCount > 0
2669
+ ? `${thoughtCount} ${thoughtCount === 1 ? "thought" : "thoughts"}`
1453
2670
  : "";
1454
- return `${countLabel}${activityLabel}`;
2671
+ // Without tools there is no current tool name to report, and the generic
2672
+ // "thinking" placeholder would only repeat the thought count.
2673
+ const activityLabel = count > 0 && activityText !== "done" && activityText.trim()
2674
+ ? activityText
2675
+ : "";
2676
+ return [countLabel, thoughtLabel, activityLabel].filter(Boolean).join(" · ");
1455
2677
  };
1456
2678
 
1457
2679
  const summaryRow = (
1458
2680
  theme: any,
1459
2681
  count: number,
1460
2682
  failed: number,
2683
+ thoughtCount: number,
1461
2684
  activity: DisplayValue = "done",
1462
2685
  collapsedDone = false,
1463
2686
  ): DisplayRow => ({
1464
2687
  prefix: () => {
1465
2688
  const currentActivity = typeof activity === "function" ? activity() : activity;
1466
2689
  const color = currentActivity === "done"
1467
- ? collapsedDone ? "thinkingText" : "success"
2690
+ ? collapsedDone ? "thinkingText" : successMarkerColor(theme)
1468
2691
  : "accent";
1469
2692
  return theme.fg(color, "● ");
1470
2693
  },
@@ -1473,12 +2696,13 @@ export default function prettyTui(pi: ExtensionAPI) {
1473
2696
  const currentActivity = typeof activity === "function" ? activity() : activity;
1474
2697
  const label = currentActivity === "done" ? "Done" : "Running";
1475
2698
  const color = label === "Done"
1476
- ? collapsedDone ? "thinkingText" : "success"
2699
+ ? collapsedDone ? "thinkingText" : successMarkerColor(theme)
1477
2700
  : "accent";
1478
2701
  const detailColor = label === "Done" && collapsedDone ? "thinkingText" : "text";
1479
2702
  return theme.fg(color, theme.bold(label)) +
1480
2703
  theme.fg("dim", "(") +
1481
- theme.fg(detailColor, summaryText(count, failed, currentActivity)) + theme.fg("dim", ")");
2704
+ theme.fg(detailColor, summaryText(count, failed, thoughtCount, currentActivity)) +
2705
+ theme.fg("dim", ")");
1482
2706
  },
1483
2707
  });
1484
2708
 
@@ -1487,22 +2711,91 @@ export default function prettyTui(pi: ExtensionAPI) {
1487
2711
  if (!summaryTheme) return [];
1488
2712
  const settled = settledSummaries.get(lastToolCallId);
1489
2713
  if (settled) {
1490
- return block([summaryRow(summaryTheme, settled.count, settled.failed, settled.activity)]).render(width);
2714
+ return block([summaryRow(
2715
+ summaryTheme,
2716
+ settled.count,
2717
+ settled.failed,
2718
+ settled.thoughtCount,
2719
+ settled.activity,
2720
+ )]).render(width);
1491
2721
  }
1492
2722
  if (cleanRun.activeToolCallId === lastToolCallId) {
1493
2723
  return block([summaryRow(
1494
2724
  summaryTheme,
1495
2725
  liveCleanToolCount(),
1496
2726
  cleanRun.failed,
2727
+ thoughtCountForTool(lastToolCallId),
1497
2728
  () => cleanRun.activeToolName ?? currentCleanActivity(),
1498
2729
  )]).render(width);
1499
2730
  }
1500
2731
  if (cleanRun.lastCompletedToolCallId === lastToolCallId && cleanRun.count > 0) {
1501
- return block([summaryRow(summaryTheme, cleanRun.count, cleanRun.failed, currentCleanActivity)]).render(width);
2732
+ return block([summaryRow(
2733
+ summaryTheme,
2734
+ cleanRun.count,
2735
+ cleanRun.failed,
2736
+ thoughtCountForTool(lastToolCallId),
2737
+ currentCleanActivity,
2738
+ )]).render(width);
1502
2739
  }
1503
2740
  return [];
1504
2741
  };
1505
2742
 
2743
+ const renderActivityGroupSummary = (
2744
+ group: ActivityGroup,
2745
+ width: number,
2746
+ collapsedDone = false,
2747
+ ): string[] => {
2748
+ const fit = (lines: string[]) => lines.map((line) => truncateToWidth(line, Math.max(1, width), ""));
2749
+ const summaryTheme = activityGroupTheme(group);
2750
+ if (!summaryTheme || !activityTimeline.hasWork(group)) return [];
2751
+ const owner = activityGroupOwner(group);
2752
+ const settled = owner ? settledSummaries.get(owner) : undefined;
2753
+ const toolCount = group.toolCallIds.length;
2754
+ const live = () => cleanRun.activeToolName ?? currentCleanActivity();
2755
+ if (settled) {
2756
+ return fit(block([summaryRow(
2757
+ summaryTheme,
2758
+ group.hardBoundarySplit ? toolCount : Math.max(settled.count, toolCount),
2759
+ group.hardBoundarySplit ? 0 : settled.failed,
2760
+ Math.max(settled.thoughtCount, group.thoughtCount),
2761
+ settled.activity,
2762
+ collapsedDone,
2763
+ )]).render(width));
2764
+ }
2765
+ if (owner && !cleanRun.settled) {
2766
+ // Live tool group: keep the current tool name and the running count.
2767
+ return fit(block([summaryRow(
2768
+ summaryTheme,
2769
+ Math.max(toolCount, liveCleanToolCount()),
2770
+ cleanRun.failed,
2771
+ group.thoughtCount,
2772
+ live,
2773
+ collapsedDone,
2774
+ )]).render(width));
2775
+ }
2776
+ if (!owner && !activityTimeline.isSettled(group.id)) {
2777
+ // Thought-only group while the model is still working.
2778
+ return fit(block([summaryRow(
2779
+ summaryTheme,
2780
+ toolCount,
2781
+ cleanRun.failed,
2782
+ group.thoughtCount,
2783
+ "thinking",
2784
+ collapsedDone,
2785
+ )]).render(width));
2786
+ }
2787
+ // Finished group with no durable summary: a completed Thought-only turn, or
2788
+ // a group split apart by a native custom renderer.
2789
+ return fit(block([summaryRow(
2790
+ summaryTheme,
2791
+ toolCount,
2792
+ 0,
2793
+ group.thoughtCount,
2794
+ "done",
2795
+ collapsedDone,
2796
+ )]).render(width));
2797
+ };
2798
+
1506
2799
  /**
1507
2800
  * Clean mode keeps Running/Done rows in the existing tool components. This
1508
2801
  * avoids waiting for agent_end (which can be followed by retry/compaction),
@@ -1511,6 +2804,8 @@ export default function prettyTui(pi: ExtensionAPI) {
1511
2804
  const cleanToolCall = (theme: any, name: string, toolCallId: string): Component => {
1512
2805
  knownToolCallIds.add(toolCallId);
1513
2806
  cleanToolThemes.set(toolCallId, theme);
2807
+ const activityGroup = activityTimeline.groupForTool(toolCallId);
2808
+ if (activityGroup) activityFallbackThemes.set(activityGroup.id, theme);
1514
2809
  return {
1515
2810
  render(width: number): string[] {
1516
2811
  if (renderMode !== "clean") return [];
@@ -1521,6 +2816,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1521
2816
  theme,
1522
2817
  settledSummary.count,
1523
2818
  settledSummary.failed,
2819
+ settledSummary.thoughtCount,
1524
2820
  settledSummary.activity,
1525
2821
  true,
1526
2822
  )]).render(width);
@@ -1532,6 +2828,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1532
2828
  theme,
1533
2829
  liveCleanToolCount(),
1534
2830
  cleanRun.failed,
2831
+ thoughtCountForTool(toolCallId),
1535
2832
  () => cleanRun.activeToolName ?? name,
1536
2833
  )]).render(width);
1537
2834
  }
@@ -1543,7 +2840,13 @@ export default function prettyTui(pi: ExtensionAPI) {
1543
2840
  cleanRun.count > 0 &&
1544
2841
  !cleanRun.activeToolCallId
1545
2842
  ) {
1546
- return block([summaryRow(theme, cleanRun.count, cleanRun.failed, currentCleanActivity)]).render(width);
2843
+ return block([summaryRow(
2844
+ theme,
2845
+ cleanRun.count,
2846
+ cleanRun.failed,
2847
+ thoughtCountForTool(toolCallId),
2848
+ currentCleanActivity,
2849
+ )]).render(width);
1547
2850
  }
1548
2851
 
1549
2852
  // Do not fall back to the per-component pending state here: Pi may
@@ -1558,12 +2861,12 @@ export default function prettyTui(pi: ExtensionAPI) {
1558
2861
 
1559
2862
  const hiddenToolResult = (context: any, options: any, output: string): Component => {
1560
2863
  if (options.isPartial) setStatus(context, "running");
1561
- else completeStatus(context, output);
2864
+ else completeStatus(context);
1562
2865
  return block([]);
1563
2866
  };
1564
2867
 
1565
- // In collapsed clean mode, every supported tool call is represented by its
1566
- // Running row. Ctrl+O bypasses this and restores the normal full renderer.
2868
+ // Built-ins use specialized compact renderers, while the activity timeline
2869
+ // controls grouping for every tool, including third-party definitions.
1567
2870
  const hideCleanTool = (
1568
2871
  toolCallId: string,
1569
2872
  expanded: boolean,
@@ -1586,6 +2889,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1586
2889
  ? [{
1587
2890
  count: data.count ?? 0,
1588
2891
  failed: data.failed ?? 0,
2892
+ thoughtCount: data.thoughtCount ?? 0,
1589
2893
  lastToolCallId: data.lastToolCallId,
1590
2894
  }]
1591
2895
  : (() => {
@@ -1594,6 +2898,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1594
2898
  ? [{
1595
2899
  count: data?.count ?? 0,
1596
2900
  failed: data?.failed ?? 0,
2901
+ thoughtCount: data?.thoughtCount ?? 0,
1597
2902
  lastToolCallId: summaryCallId,
1598
2903
  }]
1599
2904
  : [];
@@ -1603,11 +2908,10 @@ export default function prettyTui(pi: ExtensionAPI) {
1603
2908
  // absent from an uncompacted branch.
1604
2909
  const missingGroups = groups.filter((group) => !knownToolCallIds.has(group.lastToolCallId));
1605
2910
  if (missingGroups.length === 0) return [];
1606
- // Persisted entries represent settled groups. Older versions could
1607
- // accidentally store the transient responding... activity, so always
1608
- // normalize their display to Done.
2911
+ // Persisted entries represent settled groups, so always normalize their
2912
+ // display to Done even when an older entry stored a transient activity.
1609
2913
  return block(missingGroups.map((group) =>
1610
- summaryRow(theme, group.count, group.failed, "done", true)
2914
+ summaryRow(theme, group.count, group.failed, group.thoughtCount ?? 0, "done", true)
1611
2915
  )).render(width);
1612
2916
  },
1613
2917
  invalidate() {},
@@ -1625,9 +2929,20 @@ export default function prettyTui(pi: ExtensionAPI) {
1625
2929
 
1626
2930
  const restoreCleanSession = (ctx: any) => {
1627
2931
  currentExtensionUi = ctx.ui;
2932
+ clearLatestActivityStatus();
1628
2933
  cleanToolsExpanded = ctx.ui.getToolsExpanded();
1629
2934
  settledSummaries.clear();
1630
2935
  legacySummaryLastToolCallIds.clear();
2936
+ activityTimeline.clear();
2937
+ nativeCustomBoundaryKeys.clear();
2938
+ revealedActivityGroups.clear();
2939
+ expandedThinkingMembers.clear();
2940
+ thinkingComponents.clear();
2941
+ activityFallbackThemes.clear();
2942
+ activityUpdateComponents.clear();
2943
+ activityUpdateMarkdownComponents.clear();
2944
+ activityUpdateSequence = 0;
2945
+ assistantBoundaryKeys.clear();
1631
2946
  knownToolCallIds.clear();
1632
2947
  cleanCompactToolCallIds.clear();
1633
2948
  cleanGroupToolCallIds.clear();
@@ -1659,9 +2974,15 @@ export default function prettyTui(pi: ExtensionAPI) {
1659
2974
  const explicitSummaryIds = new Set<string>();
1660
2975
 
1661
2976
  const rememberGroup = (group: ToolSummaryGroup, entryId?: string) => {
2977
+ const thoughtCount = Math.max(
2978
+ group.thoughtCount ?? 0,
2979
+ thoughtCountForTool(group.lastToolCallId),
2980
+ );
2981
+ group.thoughtCount = thoughtCount;
1662
2982
  settledSummaries.set(group.lastToolCallId, {
1663
2983
  count: group.count,
1664
2984
  failed: group.failed,
2985
+ thoughtCount,
1665
2986
  activity: "done",
1666
2987
  });
1667
2988
  if (group.toolCallIds?.length) {
@@ -1673,11 +2994,22 @@ export default function prettyTui(pi: ExtensionAPI) {
1673
2994
 
1674
2995
  const finishGroup = () => {
1675
2996
  if (lastToolCallId && count > 0) {
1676
- lastFinishedGroup = { count, failed, lastToolCallId, toolCallIds: [...toolCalls] };
2997
+ lastFinishedGroup = {
2998
+ count,
2999
+ failed,
3000
+ thoughtCount: thoughtCountForTool(lastToolCallId),
3001
+ lastToolCallId,
3002
+ toolCallIds: [...toolCalls],
3003
+ };
1677
3004
  inferredGroups.push(lastFinishedGroup);
1678
3005
  setCleanGroupMembers(lastToolCallId, [...toolCalls]);
1679
3006
  if (!explicitSummaryIds.has(lastToolCallId)) {
1680
- settledSummaries.set(lastToolCallId, { count, failed, activity: "done" });
3007
+ settledSummaries.set(lastToolCallId, {
3008
+ count,
3009
+ failed,
3010
+ thoughtCount: lastFinishedGroup.thoughtCount ?? 0,
3011
+ activity: "done",
3012
+ });
1681
3013
  }
1682
3014
  }
1683
3015
  count = 0;
@@ -1685,13 +3017,20 @@ export default function prettyTui(pi: ExtensionAPI) {
1685
3017
  lastToolCallId = undefined;
1686
3018
  toolCalls.clear();
1687
3019
  completedToolCalls.clear();
3020
+ activityTimeline.boundary();
1688
3021
  };
1689
3022
 
1690
3023
  const inferredGroupsOverlapping = (group: ToolSummaryGroup): ToolSummaryGroup[] => {
1691
3024
  if (!group.toolCallIds?.length) return [];
1692
3025
  const persistedIds = new Set(group.toolCallIds);
1693
3026
  const currentGroup = lastToolCallId && count > 0
1694
- ? [{ count, failed, lastToolCallId, toolCallIds: [...toolCalls] }]
3027
+ ? [{
3028
+ count,
3029
+ failed,
3030
+ thoughtCount: thoughtCountForTool(lastToolCallId),
3031
+ lastToolCallId,
3032
+ toolCallIds: [...toolCalls],
3033
+ }]
1695
3034
  : [];
1696
3035
  return [...inferredGroups, ...currentGroup].filter((inferred) =>
1697
3036
  inferred.toolCallIds?.some((toolCallId) => persistedIds.has(toolCallId)),
@@ -1770,6 +3109,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1770
3109
  const group = {
1771
3110
  count: data.count ?? 0,
1772
3111
  failed: data.failed ?? 0,
3112
+ thoughtCount: data.thoughtCount ?? 0,
1773
3113
  lastToolCallId: data.lastToolCallId,
1774
3114
  };
1775
3115
  rememberGroup(group);
@@ -1781,6 +3121,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1781
3121
  rememberGroup({
1782
3122
  count: data?.count ?? lastFinishedGroup.count,
1783
3123
  failed: data?.failed ?? lastFinishedGroup.failed,
3124
+ thoughtCount: data?.thoughtCount ?? lastFinishedGroup.thoughtCount ?? 0,
1784
3125
  lastToolCallId: lastFinishedGroup.lastToolCallId,
1785
3126
  }, entry.id);
1786
3127
  } else if (lastToolCallId && count > 0) {
@@ -1788,9 +3129,16 @@ export default function prettyTui(pi: ExtensionAPI) {
1788
3129
  rememberGroup({
1789
3130
  count: data?.count ?? count,
1790
3131
  failed: data?.failed ?? failed,
3132
+ thoughtCount: data?.thoughtCount ?? thoughtCountForTool(lastToolCallId),
1791
3133
  lastToolCallId,
1792
3134
  }, entry.id);
1793
3135
  }
3136
+ activityTimeline.boundary();
3137
+ continue;
3138
+ }
3139
+
3140
+ if (entry.type === "custom_message") {
3141
+ if (entry.display !== false) addPersistentActivityUpdate(entry);
1794
3142
  continue;
1795
3143
  }
1796
3144
 
@@ -1803,12 +3151,23 @@ export default function prettyTui(pi: ExtensionAPI) {
1803
3151
  continue;
1804
3152
  }
1805
3153
 
3154
+ if (message.role === "custom") {
3155
+ if (message.display !== false) addPersistentActivityUpdate(message);
3156
+ continue;
3157
+ }
3158
+
1806
3159
  if (message.role === "assistant") {
3160
+ const thinking = thinkingText(message);
3161
+ if (thinking) {
3162
+ activityTimeline.addThinking(assistantMessageKey(message), thinking);
3163
+ }
1807
3164
  if (messageHasVisibleText(message)) finishGroup();
1808
3165
  for (const item of messageContentItems(message)) {
1809
- if (item.type !== "toolCall" || !supportedTools.has(item.name)) continue;
3166
+ if (item.type !== "toolCall" || !item.id) continue;
1810
3167
  toolCalls.add(item.id);
3168
+ activityTimeline.addTool(item.id, item.name ?? "tool");
1811
3169
  }
3170
+ if (assistantTerminalState(message)) finishGroup();
1812
3171
  continue;
1813
3172
  }
1814
3173
 
@@ -1829,6 +3188,17 @@ export default function prettyTui(pi: ExtensionAPI) {
1829
3188
 
1830
3189
  pi.on("session_start", (_event, ctx) => restoreCleanSession(ctx));
1831
3190
  pi.on("session_tree", (_event, ctx) => restoreCleanSession(ctx));
3191
+ pi.on("session_before_compact", () => {
3192
+ // Pi emits this for manual, threshold, and overflow-recovery compaction.
3193
+ // Seal the activity group before the compaction indicator is rendered so
3194
+ // completed work never remains labelled Running(... · thinking). A
3195
+ // lifecycle boundary overrides the minimum per-tool activity hold.
3196
+ clearToolActivityHolds();
3197
+ finishCleanGroup("done");
3198
+ settleLastCleanGroup();
3199
+ cleanRun.activity = "done";
3200
+ cleanRun.requestRender?.();
3201
+ });
1832
3202
  pi.on("session_compact", () => {
1833
3203
  // Compaction is a hard chronological boundary. Keep tools completed before
1834
3204
  // it in their own group so expanded hierarchy lines never cross the summary.
@@ -1837,31 +3207,63 @@ export default function prettyTui(pi: ExtensionAPI) {
1837
3207
  // Pre-compaction tools have been summarized intentionally. Their durable
1838
3208
  // fallback rows should not be replayed beside the compacted transcript.
1839
3209
  cleanContextCompacted = true;
3210
+ cleanRun.requestRender?.();
3211
+ });
3212
+ pi.on("session_compact_failed", () => {
3213
+ // The pre-compaction work is still complete even when compaction is
3214
+ // cancelled or fails. Keep it settled and let a retry start a new group.
3215
+ settleLastCleanGroup();
3216
+ cleanRun.activity = "done";
3217
+ cleanRun.requestRender?.();
1840
3218
  });
1841
3219
 
1842
3220
  const hasVisibleAssistantText = (message: any): boolean =>
1843
3221
  message?.role === "assistant" && messageHasVisibleText(message);
1844
3222
 
3223
+ const closeAtAssistantBoundary = (message: any, activity: string): boolean => {
3224
+ const key = assistantMessageKey(message);
3225
+ if (assistantBoundaryKeys.has(key)) return false;
3226
+ assistantBoundaryKeys.add(key);
3227
+ cleanRun.activity = activity;
3228
+ finishCleanGroup(activity);
3229
+ return true;
3230
+ };
3231
+
1845
3232
  const pendingToolCalls = (message: any): any[] =>
1846
3233
  messageContentItems(message).filter(
1847
- (item: any) => item.type === "toolCall" && supportedTools.has(item.name) && item.id,
3234
+ (item: any) => item.type === "toolCall" && item.id,
1848
3235
  );
1849
3236
 
3237
+ const conciseActivityText = (value: string): string =>
3238
+ value
3239
+ .split("\n")
3240
+ .map((line) => line.trim())
3241
+ .find(Boolean)
3242
+ ?.replace(/^#{1,6}\s+/u, "")
3243
+ .replace(/^\*\*(.*?)\*\*$/u, "$1") ?? "";
3244
+
3245
+ const trackThinkingActivity = (message: any): void => {
3246
+ if (message?.role !== "assistant") return;
3247
+ const thinking = thinkingText(message);
3248
+ if (thinking) activityTimeline.addThinking(assistantMessageKey(message), thinking);
3249
+ };
3250
+
1850
3251
  const trackPendingToolActivity = (message: any): boolean => {
1851
3252
  const toolCalls = pendingToolCalls(message);
1852
3253
  if (toolCalls.length === 0) return false;
1853
3254
  for (const toolCall of toolCalls) {
3255
+ activityTimeline.addTool(toolCall.id, toolCall.name ?? "tool");
1854
3256
  if (!cleanRun.currentToolCallIds.includes(toolCall.id)) {
1855
3257
  cleanRun.currentToolCallIds.push(toolCall.id);
1856
3258
  }
1857
- cleanToolNames.set(toolCall.id, toolDisplayName(toolCall.name));
3259
+ cleanToolNames.set(toolCall.id, cleanToolNames.get(toolCall.id) ?? toolCall.name);
1858
3260
  }
1859
3261
  const toolCall = toolCalls[toolCalls.length - 1];
1860
3262
  if (cleanRun.lastCompletedToolCallId === toolCall.id) return false;
1861
3263
  setCleanGroupMembers(toolCall.id, cleanRun.currentToolCallIds.slice());
1862
3264
  cleanRun.active = true;
1863
3265
  cleanRun.activeToolCallId = toolCall.id;
1864
- beginToolActivity(toolCall.id, toolDisplayName(toolCall.name));
3266
+ beginToolActivity(toolCall.id, cleanToolNames.get(toolCall.id) ?? toolCall.name);
1865
3267
  return true;
1866
3268
  };
1867
3269
 
@@ -1881,30 +3283,44 @@ export default function prettyTui(pi: ExtensionAPI) {
1881
3283
  // message_start runs before Pi adds the user component to the transcript,
1882
3284
  // so settling here keeps the previous parent row above that message.
1883
3285
  pi.on("message_start", (event) => {
3286
+ if (event.message.role === "custom" && event.message.display !== false) {
3287
+ addPersistentActivityUpdate(event.message);
3288
+ return;
3289
+ }
1884
3290
  if (event.message.role !== "user") return;
1885
3291
  finishCleanGroup("done");
1886
3292
  settleLastCleanGroup();
1887
3293
  });
1888
3294
 
1889
- // A visible assistant response is the boundary between tool groups. Do
1890
- // this during streaming so a following tool call cannot inherit the prior
1891
- // summary, while message_end keeps the rule correct for non-streaming paths.
3295
+ // A visible assistant response is the boundary between tool groups. Seal
3296
+ // the prior group directly as Done; the response text already communicates
3297
+ // current activity and does not need a duplicate response status.
1892
3298
  pi.on("message_update", (event) => {
1893
3299
  if (hasVisibleAssistantText(event.message)) {
1894
- cleanRun.activity = "responding...";
1895
- finishCleanGroup("responding...");
3300
+ trackThinkingActivity(event.message);
3301
+ clearToolActivityHolds();
3302
+ closeAtAssistantBoundary(event.message, "done");
1896
3303
  return;
1897
3304
  }
3305
+ trackThinkingActivity(event.message);
1898
3306
  trackPendingToolActivity(event.message);
1899
3307
  });
1900
3308
  pi.on("message_end", (event) => {
1901
3309
  if (hasVisibleAssistantText(event.message)) {
3310
+ trackThinkingActivity(event.message);
3311
+ closeAtAssistantBoundary(event.message, "done");
1902
3312
  cleanRun.activity = "done";
1903
- finishCleanGroup("done");
1904
3313
  settleLastCleanGroup();
1905
3314
  return;
1906
3315
  }
3316
+ trackThinkingActivity(event.message);
1907
3317
  trackPendingToolActivity(event.message);
3318
+ if (assistantTerminalState(event.message)) {
3319
+ clearToolActivityHolds();
3320
+ finishCleanGroup("done");
3321
+ settleLastCleanGroup();
3322
+ cleanRun.activity = "done";
3323
+ }
1908
3324
  });
1909
3325
 
1910
3326
  pi.on("agent_start", () => {
@@ -1924,25 +3340,25 @@ export default function prettyTui(pi: ExtensionAPI) {
1924
3340
  cleanRun.activeToolCallIds.clear();
1925
3341
  cleanRun.activeToolCallId = undefined;
1926
3342
  cleanRun.activeToolName = undefined;
1927
- cleanRun.activity = "thinking...";
3343
+ cleanRun.activity = "thinking";
1928
3344
  });
1929
3345
  pi.on("tool_execution_start", (event) => {
1930
- if (!supportedTools.has(event.toolName)) return;
3346
+ activityTimeline.addTool(event.toolCallId, event.toolName);
1931
3347
  if (!cleanRun.currentToolCallIds.includes(event.toolCallId)) {
1932
3348
  cleanRun.currentToolCallIds.push(event.toolCallId);
1933
3349
  }
1934
3350
  cleanRun.activeToolCallIds.add(event.toolCallId);
1935
- cleanToolNames.set(event.toolCallId, toolDisplayName(event.toolName));
3351
+ const displayName = cleanToolNames.get(event.toolCallId) ?? event.toolName;
3352
+ cleanToolNames.set(event.toolCallId, displayName);
1936
3353
  setCleanGroupMembers(event.toolCallId, cleanRun.currentToolCallIds.slice());
1937
3354
  cleanRun.active = true;
1938
3355
  cleanRun.activeToolCallId = event.toolCallId;
1939
- beginToolActivity(event.toolCallId, toolDisplayName(event.toolName), true);
3356
+ beginToolActivity(event.toolCallId, displayName, true);
1940
3357
  });
1941
3358
  pi.on("tool_execution_end", (event) => {
1942
- if (!supportedTools.has(event.toolName)) return;
1943
3359
  const activityName = cleanRun.activeToolCallId === event.toolCallId
1944
- ? cleanRun.activeToolName ?? toolDisplayName(event.toolName)
1945
- : toolDisplayName(event.toolName);
3360
+ ? cleanRun.activeToolName ?? cleanToolNames.get(event.toolCallId) ?? event.toolName
3361
+ : cleanToolNames.get(event.toolCallId) ?? event.toolName;
1946
3362
  // Keep the tool activity visible until at least one second after start;
1947
3363
  // this only delays the status transition, never the tool result itself.
1948
3364
  holdToolActivity(event.toolCallId, activityName);
@@ -1957,10 +3373,12 @@ export default function prettyTui(pi: ExtensionAPI) {
1957
3373
  cleanRun.activeToolName = cleanRun.activeToolCallId
1958
3374
  ? cleanToolNames.get(cleanRun.activeToolCallId)
1959
3375
  : undefined;
1960
- cleanRun.activity = cleanRun.activeToolName ?? "thinking...";
3376
+ cleanRun.activity = cleanRun.activeToolName ?? "thinking";
1961
3377
  }
1962
3378
  const visibleSummaryToolCallId = cleanRun.activeToolCallId ?? cleanRun.lastCompletedToolCallId;
1963
3379
  setCleanGroupMembers(visibleSummaryToolCallId, cleanRun.currentToolCallIds.slice());
3380
+ // Tool success and failure remain owned by the activity group. The bottom
3381
+ // line is reserved for transient UI status messages.
1964
3382
  });
1965
3383
  pi.on("agent_end", () => {
1966
3384
  // No summary is appended here: this event may be followed by an automatic
@@ -1969,7 +3387,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1969
3387
  cleanRun.activeToolCallIds.clear();
1970
3388
  cleanRun.activeToolCallId = undefined;
1971
3389
  cleanRun.activeToolName = undefined;
1972
- cleanRun.activity = "thinking...";
3390
+ cleanRun.activity = "thinking";
1973
3391
  });
1974
3392
  pi.on("agent_settled", () => {
1975
3393
  if (cleanRun.settled) return;
@@ -1982,6 +3400,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1982
3400
  settledSummaries.set(group.lastToolCallId, {
1983
3401
  count: group.count,
1984
3402
  failed: group.failed,
3403
+ thoughtCount: group.thoughtCount ?? 0,
1985
3404
  activity: activityValueForHold(group.lastToolCallId, "done"),
1986
3405
  });
1987
3406
  }
@@ -1997,14 +3416,17 @@ export default function prettyTui(pi: ExtensionAPI) {
1997
3416
 
1998
3417
  const count = groups.reduce((total, group) => total + group.count, 0);
1999
3418
  const failed = groups.reduce((total, group) => total + group.failed, 0);
3419
+ const thoughtCount = groups.reduce((total, group) => total + (group.thoughtCount ?? 0), 0);
2000
3420
  const lastToolCallId = groups[groups.length - 1]?.lastToolCallId;
2001
3421
  pi.appendEntry<ToolSummaryData>("pretty-tui-tool-summary", {
2002
3422
  count,
2003
3423
  failed,
3424
+ thoughtCount,
2004
3425
  lastToolCallId,
2005
- groups: groups.map(({ count, failed, lastToolCallId, toolCallIds }) => ({
3426
+ groups: groups.map(({ count, failed, thoughtCount, lastToolCallId, toolCallIds }) => ({
2006
3427
  count,
2007
3428
  failed,
3429
+ thoughtCount,
2008
3430
  lastToolCallId,
2009
3431
  toolCallIds,
2010
3432
  })),
@@ -2012,22 +3434,23 @@ export default function prettyTui(pi: ExtensionAPI) {
2012
3434
  });
2013
3435
 
2014
3436
  const read = createReadTool(cwd);
3437
+ const readLabel = read.label || read.name;
2015
3438
  pi.registerTool({
2016
3439
  ...read,
2017
3440
  renderShell: "self",
2018
3441
  renderCall(args: any, theme: any, context: any) {
2019
- if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, "Read", context.toolCallId);
3442
+ if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, readLabel, context.toolCallId);
2020
3443
  const range = args.offset || args.limit
2021
3444
  ? ` · lines ${args.offset ?? 1}${args.limit ? `–${(args.offset ?? 1) + args.limit - 1}` : "+"}`
2022
3445
  : "";
2023
- return call(theme, "Read", `${args.path}${range}`, context.state);
3446
+ return call(theme, readLabel, `${args.path}${range}`, context.state);
2024
3447
  },
2025
3448
  renderResult(toolResult: any, options: any, theme: any, context: any) {
2026
3449
  const output = textContent(toolResult);
2027
3450
  if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
2028
3451
  if (options.isPartial) return partialResult(context, theme, "Reading…");
2029
3452
  const image = toolResult.content?.find((item: any) => item.type === "image");
2030
- const failed = completeStatus(context, output);
3453
+ const failed = completeStatus(context);
2031
3454
  if (image) return result(theme, "Read image", "", false, failed);
2032
3455
  const lines = nonEmptyLines(output);
2033
3456
  const truncated = toolResult.details?.truncation?.truncated ? " · truncated" : "";
@@ -2042,11 +3465,12 @@ export default function prettyTui(pi: ExtensionAPI) {
2042
3465
  } as any);
2043
3466
 
2044
3467
  const bash = createBashTool(cwd);
3468
+ const bashLabel = bash.label || bash.name;
2045
3469
  pi.registerTool({
2046
3470
  ...bash,
2047
3471
  renderShell: "self",
2048
3472
  renderCall(args: any, theme: any, context: any) {
2049
- if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, "Bash", context.toolCallId);
3473
+ if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, bashLabel, context.toolCallId);
2050
3474
  const command = typeof args.command === "string" ? args.command : "";
2051
3475
  if (useCompactToolView(context.toolCallId, context.expanded)) {
2052
3476
  const commandLines = command.split(/\r\n|\r|\n/);
@@ -2055,16 +3479,16 @@ export default function prettyTui(pi: ExtensionAPI) {
2055
3479
  const detail = omitted > 0
2056
3480
  ? `${firstLine} … (${omitted} more ${omitted === 1 ? "line" : "lines"})`
2057
3481
  : firstLine;
2058
- return call(theme, "Bash", detail, context.state);
3482
+ return call(theme, bashLabel, detail, context.state);
2059
3483
  }
2060
- return call(theme, "Bash", command, context.state);
3484
+ return call(theme, bashLabel, command, context.state);
2061
3485
  },
2062
3486
  renderResult(toolResult: any, options: any, theme: any, context: any) {
2063
3487
  const output = textContent(toolResult);
2064
3488
  if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
2065
3489
  if (useCompactToolView(context.toolCallId, options.expanded)) {
2066
3490
  if (options.isPartial) return partialResult(context, theme, "Running…");
2067
- const failed = completeStatus(context, output);
3491
+ const failed = completeStatus(context);
2068
3492
  return result(theme, failed ? "Command failed" : "Done", "", false, failed);
2069
3493
  }
2070
3494
 
@@ -2072,7 +3496,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2072
3496
  return bashResult(context, theme, "Running…", output, options.expanded, "running");
2073
3497
  }
2074
3498
 
2075
- const failed = isError(context, output);
3499
+ const failed = isError(context);
2076
3500
  const outputLines = terminalOutputLines(output);
2077
3501
  const lineCount = outputLines.filter((line) => line.length > 0).length;
2078
3502
  const summary = failed
@@ -2092,19 +3516,20 @@ export default function prettyTui(pi: ExtensionAPI) {
2092
3516
  } as any);
2093
3517
 
2094
3518
  const edit = createEditTool(cwd);
3519
+ const editLabel = edit.label || edit.name;
2095
3520
  pi.registerTool({
2096
3521
  ...edit,
2097
3522
  renderShell: "self",
2098
3523
  renderCall(args: any, theme: any, context: any) {
2099
- if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, "Edit", context.toolCallId);
3524
+ if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, editLabel, context.toolCallId);
2100
3525
  const count = Array.isArray(args.edits) ? ` · ${args.edits.length} changes` : "";
2101
- return call(theme, "Edit", `${args.path}${count}`, context.state);
3526
+ return call(theme, editLabel, `${args.path}${count}`, context.state);
2102
3527
  },
2103
3528
  renderResult(toolResult: any, options: any, theme: any, context: any) {
2104
3529
  const output = textContent(toolResult);
2105
3530
  if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
2106
3531
  if (options.isPartial) return partialResult(context, theme, "Editing…");
2107
- const failed = completeStatus(context, output);
3532
+ const failed = completeStatus(context);
2108
3533
  const diff = toolResult.details?.diff ?? "";
2109
3534
  const additions = diff.split("\n").filter((line: string) => line.startsWith("+") && !line.startsWith("+++")).length;
2110
3535
  const removals = diff.split("\n").filter((line: string) => line.startsWith("-") && !line.startsWith("---")).length;
@@ -2133,26 +3558,27 @@ export default function prettyTui(pi: ExtensionAPI) {
2133
3558
  } as any);
2134
3559
 
2135
3560
  const write = createWriteTool(cwd);
3561
+ const writeLabel = write.label || write.name;
2136
3562
  pi.registerTool({
2137
3563
  ...write,
2138
3564
  renderShell: "self",
2139
3565
  renderCall(args: any, theme: any, context: any) {
2140
- if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, "Write", context.toolCallId);
3566
+ if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, writeLabel, context.toolCallId);
2141
3567
  const content = typeof args.content === "string" ? args.content : "";
2142
3568
  const path = String(args.path ?? "");
2143
3569
  if (useCompactToolView(context.toolCallId, context.expanded)) {
2144
3570
  const lines = content.replace(/\t/g, " ").split("\n");
2145
3571
  while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
2146
3572
  const total = lines.length;
2147
- return call(theme, "Write", `${path} · ${total} ${total === 1 ? "line" : "lines"}`, context.state);
3573
+ return call(theme, writeLabel, `${path} · ${total} ${total === 1 ? "line" : "lines"}`, context.state);
2148
3574
  }
2149
- return writeCall(theme, path, content, context.expanded, context.state);
3575
+ return writeCall(theme, writeLabel, path, content, context.expanded, context.state);
2150
3576
  },
2151
3577
  renderResult(toolResult: any, options: any, theme: any, context: any) {
2152
3578
  const output = textContent(toolResult);
2153
3579
  if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
2154
3580
  if (options.isPartial) return partialResult(context, theme, "Writing…");
2155
- const failed = completeStatus(context, output);
3581
+ const failed = completeStatus(context);
2156
3582
  return result(
2157
3583
  theme,
2158
3584
  failed ? output.split("\n")[0] || "Write failed" : "Written",
@@ -2164,56 +3590,59 @@ export default function prettyTui(pi: ExtensionAPI) {
2164
3590
  } as any);
2165
3591
 
2166
3592
  const grep = createGrepTool(cwd);
3593
+ const grepLabel = grep.label || grep.name;
2167
3594
  pi.registerTool({
2168
3595
  ...grep,
2169
3596
  renderShell: "self",
2170
3597
  renderCall(args: any, theme: any, context: any) {
2171
- if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, "Grep", context.toolCallId);
3598
+ if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, grepLabel, context.toolCallId);
2172
3599
  const where = args.path ? ` · ${args.path}` : "";
2173
3600
  const glob = args.glob ? ` · ${args.glob}` : "";
2174
- return call(theme, "Grep", `${args.pattern}${where}${glob}`, context.state);
3601
+ return call(theme, grepLabel, `${args.pattern}${where}${glob}`, context.state);
2175
3602
  },
2176
3603
  renderResult(toolResult: any, options: any, theme: any, context: any) {
2177
3604
  const output = textContent(toolResult);
2178
3605
  if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
2179
3606
  if (options.isPartial) return partialResult(context, theme, "Searching…");
2180
- const failed = completeStatus(context, output);
3607
+ const failed = completeStatus(context);
2181
3608
  const matches = nonEmptyLines(output);
2182
3609
  return result(theme, failed ? output.split("\n")[0] : `Found ${matches} matches`, output, options.expanded, failed);
2183
3610
  },
2184
3611
  } as any);
2185
3612
 
2186
3613
  const find = createFindTool(cwd);
3614
+ const findLabel = find.label || find.name;
2187
3615
  pi.registerTool({
2188
3616
  ...find,
2189
3617
  renderShell: "self",
2190
3618
  renderCall(args: any, theme: any, context: any) {
2191
- if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, "Find", context.toolCallId);
2192
- return call(theme, "Find", `${args.pattern}${args.path ? ` · ${args.path}` : ""}`, context.state);
3619
+ if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, findLabel, context.toolCallId);
3620
+ return call(theme, findLabel, `${args.pattern}${args.path ? ` · ${args.path}` : ""}`, context.state);
2193
3621
  },
2194
3622
  renderResult(toolResult: any, options: any, theme: any, context: any) {
2195
3623
  const output = textContent(toolResult);
2196
3624
  if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
2197
3625
  if (options.isPartial) return partialResult(context, theme, "Searching…");
2198
- const failed = completeStatus(context, output);
3626
+ const failed = completeStatus(context);
2199
3627
  const matches = nonEmptyLines(output);
2200
3628
  return result(theme, failed ? output.split("\n")[0] : `Found ${matches} paths`, output, options.expanded, failed);
2201
3629
  },
2202
3630
  } as any);
2203
3631
 
2204
3632
  const ls = createLsTool(cwd);
3633
+ const lsLabel = ls.label || ls.name;
2205
3634
  pi.registerTool({
2206
3635
  ...ls,
2207
3636
  renderShell: "self",
2208
3637
  renderCall(args: any, theme: any, context: any) {
2209
- if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, "List", context.toolCallId);
2210
- return call(theme, "List", args.path ?? ".", context.state);
3638
+ if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, lsLabel, context.toolCallId);
3639
+ return call(theme, lsLabel, args.path ?? ".", context.state);
2211
3640
  },
2212
3641
  renderResult(toolResult: any, options: any, theme: any, context: any) {
2213
3642
  const output = textContent(toolResult);
2214
3643
  if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
2215
3644
  if (options.isPartial) return partialResult(context, theme, "Listing…");
2216
- const failed = completeStatus(context, output);
3645
+ const failed = completeStatus(context);
2217
3646
  const entries = nonEmptyLines(output);
2218
3647
  return result(theme, failed ? output.split("\n")[0] : `Listed ${entries} entries`, output, options.expanded, failed);
2219
3648
  },