pi-pretty-tui 0.2.0 → 0.2.2

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.
@@ -32,15 +32,18 @@ import { join } from "node:path";
32
32
  import {
33
33
  ActivityTimeline,
34
34
  assistantMessageKey,
35
- assistantSystemBoundary,
35
+ assistantTerminalState,
36
36
  thinkingText,
37
37
  visibleAssistantText,
38
38
  type ActivityGroup,
39
39
  type ActivityMember,
40
+ type ActivitySeverity,
40
41
  } from "./activity-timeline.js";
41
42
 
42
43
  type PrettyTuiMode = "full" | "compact" | "clean";
43
44
  const CLEAN_TOOL_ACTIVITY_MIN_MS = 1000;
45
+ const TRANSIENT_UI_STATUS_MS = 10_000;
46
+ const RESPONSE_FOOTER_ENTRY_TYPE = "pretty-tui-response-footer";
44
47
  const SPECIALIZED_TOOL_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]);
45
48
 
46
49
  const stripAnsiBackgrounds = (value: string): string =>
@@ -95,14 +98,131 @@ export default function prettyTui(pi: ExtensionAPI) {
95
98
  let currentTui: any;
96
99
  let currentExtensionUi: any;
97
100
  let changingAllToolsExpansion = false;
101
+ const latestActivityWidgetKey = "pretty-tui-latest-activity";
102
+ type LatestActivityStatus = {
103
+ title: string;
104
+ detail?: string;
105
+ severity?: TransientStatusSeverity;
106
+ };
107
+ // The editor-adjacent flash reports Pi UI state ("status") and pretty-tui's
108
+ // own UI outcomes, so it has its own severities. Activity members only ever
109
+ // carry info, warning, or error.
110
+ type TransientStatusSeverity = "status" | "success" | "error";
111
+ let latestActivityStatus: LatestActivityStatus | undefined;
112
+ let latestActivityWidgetInstalled = false;
113
+ let latestActivityStatusTimer: ReturnType<typeof setTimeout> | undefined;
114
+ let latestActivityStatusGeneration = 0;
115
+ const renderLatestActivityStatus = (status: LatestActivityStatus, width: number, theme: any): string => {
116
+ const severity = status.severity ?? "status";
117
+ const icon = severity === "error" ? "✕" : severity === "success" ? "✓" : "◇";
118
+ const color = severity === "error"
119
+ ? "error"
120
+ : severity === "success"
121
+ ? "success"
122
+ : "dim";
123
+ const detail = status.detail?.trim() ? ` — ${status.detail.trim()}` : "";
124
+ return truncateToWidth(` ${theme.fg(color, `${icon} ${status.title}`)}${theme.fg("dim", detail)}`, Math.max(1, width), "…");
125
+ };
126
+ const refreshLatestActivityWidget = () => {
127
+ if (!currentExtensionUi?.setWidget) return;
128
+ if (renderMode !== "clean" || !latestActivityStatus) {
129
+ if (latestActivityWidgetInstalled) {
130
+ currentExtensionUi.setWidget(latestActivityWidgetKey, undefined);
131
+ latestActivityWidgetInstalled = false;
132
+ }
133
+ return;
134
+ }
135
+ if (latestActivityWidgetInstalled) {
136
+ currentTui?.requestRender?.();
137
+ return;
138
+ }
139
+ currentExtensionUi.setWidget(
140
+ latestActivityWidgetKey,
141
+ (_tui: any, theme: any) => ({
142
+ render: (width: number) => latestActivityStatus
143
+ ? [renderLatestActivityStatus(latestActivityStatus, width, theme)]
144
+ : [],
145
+ invalidate() {},
146
+ }),
147
+ { placement: "aboveEditor" },
148
+ );
149
+ latestActivityWidgetInstalled = true;
150
+ };
151
+ const clearLatestActivityStatus = () => {
152
+ latestActivityStatusGeneration++;
153
+ if (latestActivityStatusTimer) clearTimeout(latestActivityStatusTimer);
154
+ latestActivityStatusTimer = undefined;
155
+ latestActivityStatus = undefined;
156
+ if (latestActivityWidgetInstalled) {
157
+ currentExtensionUi?.setWidget?.(latestActivityWidgetKey, undefined);
158
+ latestActivityWidgetInstalled = false;
159
+ }
160
+ };
161
+ const setLatestActivityStatus = (status: LatestActivityStatus) => {
162
+ const generation = ++latestActivityStatusGeneration;
163
+ if (latestActivityStatusTimer) clearTimeout(latestActivityStatusTimer);
164
+ latestActivityStatus = status;
165
+ refreshLatestActivityWidget();
166
+ latestActivityStatusTimer = setTimeout(() => {
167
+ if (generation !== latestActivityStatusGeneration) return;
168
+ clearLatestActivityStatus();
169
+ currentTui?.requestRender?.();
170
+ }, TRANSIENT_UI_STATUS_MS);
171
+ };
172
+ const copyTranscriptText = (text: string) => {
173
+ // Code-block copy must preserve leading/trailing whitespace and indentation.
174
+ // Answers are normalized separately by assistantAnswerText().
175
+ if (!text.trim()) return;
176
+ const copyText = text;
177
+ if (typeof currentTui?.copyTextToClipboard === "function") {
178
+ // Fullscreen uses Pi's native clipboard path and native flash feedback.
179
+ void Promise.resolve(currentTui.copyTextToClipboard(copyText)).catch(() => {
180
+ currentTui?.flash?.("Copy failed");
181
+ });
182
+ return;
183
+ }
184
+ void copyToClipboard(copyText)
185
+ .then(() => setLatestActivityStatus({ title: "Copied!", severity: "success" }))
186
+ .catch(() => setLatestActivityStatus({ title: "Copy failed", severity: "error" }));
187
+ };
188
+ type ResponseFooterData = {
189
+ answerEntryId?: string;
190
+ durationMs: number;
191
+ outcome: "completed" | "stopped";
192
+ };
193
+ const responseAnswerTexts = new Map<string, string>();
194
+ let responseRunStartedAt: number | undefined;
195
+ let responseHasAssistantMessage = false;
196
+ const assistantAnswerText = (message: any): string => {
197
+ if (typeof message?.content === "string") return message.content.trim();
198
+ if (!Array.isArray(message?.content)) return "";
199
+ return message.content
200
+ .filter((item: any) => item?.type === "text" && typeof item.text === "string" && item.text.trim())
201
+ .map((item: any) => item.text.trim())
202
+ .join("\n\n")
203
+ .trim();
204
+ };
205
+ const formatResponseDuration = (durationMs: number): string => {
206
+ const totalSeconds = Math.max(1, Math.round(Math.max(0, durationMs) / 1000));
207
+ if (totalSeconds < 60) return `${totalSeconds}s`;
208
+ const totalMinutes = Math.floor(totalSeconds / 60);
209
+ const seconds = totalSeconds % 60;
210
+ if (totalMinutes < 60) return `${totalMinutes}m ${seconds}s`;
211
+ const hours = Math.floor(totalMinutes / 60);
212
+ const minutes = totalMinutes % 60;
213
+ return `${hours}h ${String(minutes).padStart(2, "0")}m`;
214
+ };
98
215
  const activityTimeline = new ActivityTimeline();
99
216
  const revealedActivityGroups = new Set<string>();
100
217
  const expandedThinkingMembers = new Set<string>();
101
218
  const thinkingComponents = new Map<string, any>();
102
219
  const activityFallbackThemes = new Map<string, any>();
103
220
  const activityUpdateComponents = new Map<string, Component>();
104
- const customUpdateToolHints = new Map<string, string>();
105
- const customUpdateBeforeToolHints = new Set<string>();
221
+ const activityUpdateMarkdownComponents = new Map<string, {
222
+ content: string;
223
+ theme: any;
224
+ component: Markdown;
225
+ }>();
106
226
  let activityUpdateSequence = 0;
107
227
  const assistantBoundaryKeys = new Set<string>();
108
228
  const cleanCompactToolCallIds = new Set<string>();
@@ -113,6 +233,20 @@ export default function prettyTui(pi: ExtensionAPI) {
113
233
  const cleanToolNames = new Map<string, string>();
114
234
  let renderCleanGroupSummary = (_lastToolCallId: string, _width: number): string[] => [];
115
235
 
236
+ // Markdown enhancements are active only while pi-pretty-tui renders the main
237
+ // conversation transcript. Plugin overlays also use Pi's Markdown class, so
238
+ // a global always-on prototype patch would leak transcript styling into them.
239
+ let transcriptMarkdownDepth = 0;
240
+ const withTranscriptMarkdown = <T>(callback: () => T): T => {
241
+ transcriptMarkdownDepth++;
242
+ try {
243
+ return callback();
244
+ } finally {
245
+ transcriptMarkdownDepth--;
246
+ }
247
+ };
248
+ const transcriptMarkdownActive = () => transcriptMarkdownDepth > 0;
249
+
116
250
  const setCleanGroupMembers = (lastToolCallId: string, toolCallIds: string[]) => {
117
251
  cleanGroupToolCallIds.set(lastToolCallId, toolCallIds);
118
252
  for (const toolCallId of toolCallIds) cleanToolCallGroupOwners.set(toolCallId, lastToolCallId);
@@ -167,11 +301,17 @@ export default function prettyTui(pi: ExtensionAPI) {
167
301
  };
168
302
 
169
303
  const defaultActivityTheme = () => {
304
+ if (currentExtensionUi?.theme?.fg) return currentExtensionUi.theme;
170
305
  const markdownTheme = getMarkdownTheme();
171
306
  return {
172
307
  fg: (color: string, text: string) => {
173
308
  if (color === "accent") return markdownTheme.listBullet(text);
174
- if (color === "success" || color === "syntaxComment") return markdownTheme.codeBlock(text);
309
+ // Session UI normally supplies Pi's active Theme above. Keep portable
310
+ // semantic fallbacks for early startup and isolated renderer tests.
311
+ if (color === "warning") return `\x1b[33m${text}\x1b[39m`;
312
+ if (color === "error") return `\x1b[31m${text}\x1b[39m`;
313
+ if (color === "success") return `\x1b[32m${text}\x1b[39m`;
314
+ if (color === "syntaxComment") return markdownTheme.codeBlock(text);
175
315
  if (color === "thinkingLow") return markdownTheme.link(text);
176
316
  if (color === "dim" || color === "muted" || color === "thinkingText") {
177
317
  return markdownTheme.quote(text);
@@ -204,6 +344,12 @@ export default function prettyTui(pi: ExtensionAPI) {
204
344
  const activityGroupRevealed = (group: ActivityGroup): boolean =>
205
345
  cleanToolsExpanded || revealedActivityGroups.has(group.id);
206
346
 
347
+ // A group is revealable once it owns anything renderable beneath its parent
348
+ // row. Thinking counts on its own: a tool-free turn still gets a parent row
349
+ // with its Thought as the child member.
350
+ const activityGroupCollapsible = (group: ActivityGroup): boolean =>
351
+ activityTimeline.hasWork(group);
352
+
207
353
  const activityMemberPosition = (group: ActivityGroup, member: ActivityMember) => ({
208
354
  first: group.members[0]?.id === member.id,
209
355
  last: group.members[group.members.length - 1]?.id === member.id,
@@ -238,6 +384,50 @@ export default function prettyTui(pi: ExtensionAPI) {
238
384
  .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
239
385
  .join(" ") || "Update";
240
386
 
387
+ const renderActivityUpdateMarkdown = (
388
+ member: ActivityMember,
389
+ width: number,
390
+ theme: any,
391
+ ): string[] => {
392
+ const content = member.updateContent ?? "";
393
+ if (!content) return [];
394
+ let cached = activityUpdateMarkdownComponents.get(member.id);
395
+ if (!cached || cached.content !== content || cached.theme !== theme) {
396
+ cached = {
397
+ content,
398
+ theme,
399
+ component: new Markdown(
400
+ content,
401
+ 0,
402
+ 0,
403
+ getMarkdownTheme(),
404
+ { color: (text: string) => theme.fg("muted", text) },
405
+ ),
406
+ };
407
+ activityUpdateMarkdownComponents.set(member.id, cached);
408
+ }
409
+ const lines = withTranscriptMarkdown(() => cached.component.render(Math.max(1, width)));
410
+ while (lines.length > 0 && stripTerminalSequences(lines[lines.length - 1]).trim() === "") {
411
+ lines.pop();
412
+ }
413
+ return lines;
414
+ };
415
+
416
+ const activityUpdateStyle = (member: ActivityMember, theme: any) => {
417
+ const severity = member.updateSeverity ?? "info";
418
+ const styles: Record<ActivitySeverity, { icon: string; color: string }> = {
419
+ info: { icon: "◇", color: "accent" },
420
+ warning: { icon: "⚠", color: "warning" },
421
+ error: { icon: "✕", color: "error" },
422
+ };
423
+ const style = styles[severity];
424
+ return {
425
+ severity,
426
+ icon: theme.fg(style.color, `${style.icon} `),
427
+ title: (text: string) => theme.fg(style.color, theme.bold(text)),
428
+ };
429
+ };
430
+
241
431
  const renderActivityUpdate = (
242
432
  group: ActivityGroup,
243
433
  member: ActivityMember,
@@ -247,10 +437,11 @@ export default function prettyTui(pi: ExtensionAPI) {
247
437
  if (renderMode !== "clean" || !activityGroupRevealed(group)) return [];
248
438
  const { prefix, continuation, childWidth } = activityTreeStyle(group, member, width, theme);
249
439
  const title = truncateToWidth(member.updateTitle ?? "Update", Math.max(1, childWidth - 2), "…");
250
- const lines = [theme.fg("accent", "◇ ") + theme.fg("toolTitle", theme.bold(title))];
440
+ const style = activityUpdateStyle(member, theme);
441
+ const lines = [style.icon + style.title(title)];
251
442
  if (member.updateContent) {
252
443
  const detailWidth = Math.max(1, childWidth - visibleWidth(" │ "));
253
- const detailLines = wrapTextWithAnsi(theme.fg("muted", member.updateContent), detailWidth);
444
+ const detailLines = renderActivityUpdateMarkdown(member, detailWidth, theme);
254
445
  lines.push(...detailLines.map((line, index) =>
255
446
  theme.fg("dim", index === detailLines.length - 1 ? " └ " : " │ ") + line
256
447
  ));
@@ -267,10 +458,11 @@ export default function prettyTui(pi: ExtensionAPI) {
267
458
  const renderPendingActivityUpdate = (member: ActivityMember, width: number): string[] => {
268
459
  const theme = defaultActivityTheme();
269
460
  const title = truncateToWidth(member.updateTitle ?? "Update", Math.max(1, width - 2), "…");
270
- const lines = [theme.fg("accent", "◇ ") + theme.fg("toolTitle", theme.bold(title))];
461
+ const style = activityUpdateStyle(member, theme);
462
+ const lines = [style.icon + style.title(title)];
271
463
  if (member.updateContent) {
272
464
  const detailWidth = Math.max(1, width - visibleWidth(" │ "));
273
- const details = wrapTextWithAnsi(theme.fg("muted", member.updateContent), detailWidth);
465
+ const details = renderActivityUpdateMarkdown(member, detailWidth, theme);
274
466
  lines.push(...details.map((line, index) =>
275
467
  theme.fg("dim", index === details.length - 1 ? " └ " : " │ ") + line
276
468
  ));
@@ -278,13 +470,28 @@ export default function prettyTui(pi: ExtensionAPI) {
278
470
  return ["", ...lines.map((line) => truncateToWidth(line, Math.max(1, width), ""))];
279
471
  };
280
472
 
473
+ const renderActivityUpdateProjection = (
474
+ group: ActivityGroup,
475
+ member: ActivityMember,
476
+ width: number,
477
+ ): string[] => {
478
+ if (!activityGroupCollapsible(group)) return renderPendingActivityUpdate(member, width);
479
+ const position = activityMemberPosition(group, member);
480
+ if (!activityGroupRevealed(group)) {
481
+ return position.first
482
+ ? ["", ...renderActivityGroupSummary(group, width, true)]
483
+ : [];
484
+ }
485
+ return renderActivityUpdate(group, member, width);
486
+ };
487
+
281
488
  const handleActivityUpdateMouse = (
282
489
  group: ActivityGroup,
283
490
  member: ActivityMember,
284
491
  event: any,
285
492
  ) => {
286
493
  const isLeftClick = event.type === "click" && event.button === "left";
287
- if (!isLeftClick || group.toolCallIds.length === 0) return undefined;
494
+ if (!isLeftClick || !activityGroupCollapsible(group)) return undefined;
288
495
  const position = activityMemberPosition(group, member);
289
496
  if (!activityGroupRevealed(group)) {
290
497
  if (!position.first) return undefined;
@@ -306,6 +513,8 @@ export default function prettyTui(pi: ExtensionAPI) {
306
513
  cleanToolComponents.get(child.toolCallId)?.updateDisplay?.();
307
514
  } else if (child.kind === "thinking" && child.messageKey) {
308
515
  thinkingComponents.get(child.messageKey)?.invalidate?.();
516
+ } else if (child.kind === "update") {
517
+ activityUpdateComponents.get(child.id)?.invalidate?.();
309
518
  }
310
519
  }
311
520
  currentTui?.requestRender?.();
@@ -399,6 +608,8 @@ export default function prettyTui(pi: ExtensionAPI) {
399
608
  revealedActivityGroups.clear();
400
609
  expandedThinkingMembers.clear();
401
610
  renderMode = requested;
611
+ if (renderMode === "clean") refreshLatestActivityWidget();
612
+ else clearLatestActivityStatus();
402
613
  for (const component of cleanToolComponents.values()) component.updateDisplay?.();
403
614
  for (const component of thinkingComponents.values()) component.invalidate?.();
404
615
  try {
@@ -415,20 +626,6 @@ export default function prettyTui(pi: ExtensionAPI) {
415
626
  // render patches or override built-in tools until the next enabled reload.
416
627
  if (!activeForSession) return;
417
628
 
418
- // Markdown enhancements are active only while pi-pretty-tui renders the main
419
- // conversation transcript. Plugin overlays also use Pi's Markdown class, so
420
- // a global always-on prototype patch would leak transcript styling into them.
421
- let transcriptMarkdownDepth = 0;
422
- const withTranscriptMarkdown = <T>(callback: () => T): T => {
423
- transcriptMarkdownDepth++;
424
- try {
425
- return callback();
426
- } finally {
427
- transcriptMarkdownDepth--;
428
- }
429
- };
430
- const transcriptMarkdownActive = () => transcriptMarkdownDepth > 0;
431
-
432
629
  // Give Pi's main prompt editor a complete rounded frame. Render the native
433
630
  // editor at a two-column narrower width so cursor layout, wrapping, IME, and
434
631
  // autocomplete remain native, then add one themed border column per side.
@@ -499,8 +696,10 @@ export default function prettyTui(pi: ExtensionAPI) {
499
696
  });
500
697
  }
501
698
 
502
- // Render user prompts as a titled, rounded frame while preserving Pi's
503
- // original Markdown component, wrapping, output padding, and OSC 133 zones.
699
+ // Render user prompts as right-aligned, content-sized chat bubbles while
700
+ // preserving Pi's Markdown component, wrapping, output padding, and OSC 133
701
+ // zones. Long messages stop at 75% of the terminal width; narrow terminals
702
+ // trade away the left gap before sacrificing readability.
504
703
  const userMessagePrototype = UserMessageComponent.prototype as any;
505
704
  const userMessagePatchKey = Symbol.for("pretty-tui.user-message-frame");
506
705
  if (!userMessagePrototype[userMessagePatchKey]) {
@@ -510,66 +709,118 @@ export default function prettyTui(pi: ExtensionAPI) {
510
709
  const content = this.children?.[0] as any;
511
710
  if (!content) return;
512
711
 
513
- // Reuse Pi's theme-aware user background across the complete frame.
514
- const userMessageBg = content.bgFn as ((text: string) => string) | undefined;
712
+ // Remove Pi's full-width user-message background. The Markdown keeps its
713
+ // native foreground styling, while the surrounding bubble stays transparent.
515
714
  content.paddingX = 0;
516
715
  content.paddingY = 0;
517
716
  content.setBgFn?.(undefined);
518
717
  content.invalidate?.();
718
+ // Pi wraps the Markdown in a full-width Box. Render the Markdown child
719
+ // directly so short messages expose their natural visible width instead
720
+ // of inheriting the Box's width-filling padding.
721
+ const markdown = content.children?.[0] ?? content;
519
722
 
520
723
  const outputPad = Math.max(0, Number(this.outputPad) || 0);
521
724
  const markdownTheme = this.markdownTheme;
522
- const border = (text: string) => markdownTheme.quoteBorder(text);
725
+ // Markdown deliberately pads every rendered line to the requested width.
726
+ // Remove only that trailing layout padding before measuring or framing;
727
+ // ANSI styling and the visible content remain untouched.
728
+ const renderMarkdown = (width: number): string[] =>
729
+ withTranscriptMarkdown(() => markdown.render(width))
730
+ .map((line: string) => line.replace(/ +$/u, ""));
731
+ const layout = (width: number) => {
732
+ const terminalWidth = Math.max(1, width);
733
+ const rightPad = Math.min(outputPad, Math.max(0, terminalWidth - 1));
734
+ const availableWidth = Math.max(1, terminalWidth - rightPad);
735
+ if (availableWidth < 4) {
736
+ return {
737
+ framed: false,
738
+ rightPad,
739
+ leftPad: 0,
740
+ frameWidth: availableWidth,
741
+ contentWidth: availableWidth,
742
+ body: renderMarkdown(availableWidth),
743
+ };
744
+ }
745
+
746
+ const preferredMaximum = Math.max(4, Math.floor(terminalWidth * 0.75));
747
+ // Keep at least one blank column on the left whenever a framed bubble
748
+ // fits, even after a narrow terminal relaxes the usual 75% maximum.
749
+ const bubbleAvailable = availableWidth >= 5 ? availableWidth - 1 : availableWidth;
750
+ const narrowReadableMinimum = Math.min(bubbleAvailable, 24);
751
+ const frameMaximum = Math.min(
752
+ bubbleAvailable,
753
+ Math.max(preferredMaximum, narrowReadableMinimum),
754
+ );
755
+ const measuringWidth = Math.max(1, frameMaximum - 4);
756
+ const measuredBody = renderMarkdown(measuringWidth);
757
+ const naturalContentWidth = Math.max(
758
+ 1,
759
+ ...measuredBody.map((line: string) => visibleWidth(line)),
760
+ );
761
+ const frameWidth = Math.min(frameMaximum, naturalContentWidth + 4);
762
+ const contentWidth = Math.max(1, frameWidth - 4);
763
+ const body = contentWidth === measuringWidth
764
+ ? measuredBody
765
+ : renderMarkdown(contentWidth);
766
+ return {
767
+ framed: true,
768
+ rightPad,
769
+ leftPad: Math.max(0, terminalWidth - rightPad - frameWidth),
770
+ frameWidth,
771
+ contentWidth,
772
+ body,
773
+ };
774
+ };
775
+ const border = (text: string) => currentExtensionUi?.theme?.fg
776
+ ? currentExtensionUi.theme.fg("accent", text)
777
+ : markdownTheme.quoteBorder(text);
523
778
  const frame: Component = {
524
779
  render(width: number): string[] {
525
- const sidePad = Math.min(outputPad, Math.max(0, Math.floor((width - 1) / 2)));
526
- const outer = " ".repeat(sidePad);
527
- const frameWidth = Math.max(1, width - sidePad * 2);
528
- const paintBackground = (line: string) => {
529
- const padding = " ".repeat(Math.max(0, frameWidth - visibleWidth(line)));
530
- return userMessageBg ? userMessageBg(line + padding) : line + padding;
531
- };
532
- if (frameWidth < 4) {
533
- return content.render(frameWidth).map((line: string) => outer + paintBackground(line));
780
+ const measured = layout(width);
781
+ const left = " ".repeat(measured.leftPad);
782
+ const right = " ".repeat(measured.rightPad);
783
+ if (!measured.framed) {
784
+ return measured.body.map((line: string) => left + line + right);
534
785
  }
535
786
 
536
- const title = " User ";
537
- const topStart = "╭─";
538
- const topTail = "─".repeat(Math.max(0, frameWidth - visibleWidth(topStart) - visibleWidth(title) - 1));
539
- const top = border(topStart + markdownTheme.bold(title) + topTail + "╮");
540
- const bottom = border("╰" + "─".repeat(Math.max(0, frameWidth - 2)) + "╯");
541
- const contentWidth = Math.max(1, frameWidth - 4);
542
- const body = withTranscriptMarkdown(() => content.render(contentWidth)).map((line: string) => {
543
- const padding = " ".repeat(Math.max(0, contentWidth - visibleWidth(line)));
787
+ const top = border("╭" + "─".repeat(Math.max(0, measured.frameWidth - 2)) + "╮");
788
+ const bottom = border("╰" + "─".repeat(Math.max(0, measured.frameWidth - 2)) + "╯");
789
+ const body = measured.body.map((line: string) => {
790
+ const padding = " ".repeat(Math.max(0, measured.contentWidth - visibleWidth(line)));
544
791
  return border("│ ") + line + padding + border(" │");
545
792
  });
546
- return [top, ...body, bottom].map((line: string) => outer + paintBackground(line));
793
+ return [top, ...body, bottom].map((line: string) => left + line + right);
547
794
  },
548
795
  handleMouse(event: any) {
549
- if (!content.handleMouse) return undefined;
550
- const sidePad = Math.min(outputPad, Math.max(0, Math.floor((event.width - 1) / 2)));
551
- const frameWidth = Math.max(1, event.width - sidePad * 2);
552
- if (frameWidth < 4) {
553
- return withTranscriptMarkdown(() => content.handleMouse({
796
+ if (!markdown.handleMouse) return undefined;
797
+ const measured = layout(event.width);
798
+ if (!measured.framed) {
799
+ return withTranscriptMarkdown(() => markdown.handleMouse({
554
800
  ...event,
555
- x: event.x - sidePad,
556
- width: frameWidth,
801
+ x: event.x - measured.leftPad,
802
+ width: measured.contentWidth,
557
803
  }));
558
804
  }
559
- const contentWidth = Math.max(1, frameWidth - 4);
560
- if (event.y <= 0 || event.x < sidePad + 2 || event.x >= sidePad + 2 + contentWidth) {
805
+ const contentX = measured.leftPad + 2;
806
+ if (
807
+ event.y <= 0 ||
808
+ event.y > measured.body.length ||
809
+ event.x < contentX ||
810
+ event.x >= contentX + measured.contentWidth
811
+ ) {
561
812
  return undefined;
562
813
  }
563
- return withTranscriptMarkdown(() => content.handleMouse({
814
+ return withTranscriptMarkdown(() => markdown.handleMouse({
564
815
  ...event,
565
- x: event.x - sidePad - 2,
816
+ x: event.x - contentX,
566
817
  y: event.y - 1,
567
- width: contentWidth,
568
- height: Math.max(0, event.height - 2),
818
+ width: measured.contentWidth,
819
+ height: measured.body.length,
569
820
  }));
570
821
  },
571
822
  invalidate() {
572
- content.invalidate?.();
823
+ markdown.invalidate?.();
573
824
  },
574
825
  };
575
826
 
@@ -615,14 +866,31 @@ export default function prettyTui(pi: ExtensionAPI) {
615
866
  .join("\n")
616
867
  : "";
617
868
 
869
+ // Delegates to the timeline so the precondition is stated once. The `??` is
870
+ // deliberate: the callee may still decline, and a dropped notification must
871
+ // never throw inside a third-party extension's ctx.ui.notify() call.
872
+ const hasWorkActivity = (group: ActivityGroup | undefined): boolean =>
873
+ activityTimeline.hasWork(group);
874
+
618
875
  const addPersistentActivityUpdate = (message: any): ActivityMember => {
619
876
  const key = customMessageKey(message);
620
877
  const title = humanizeCustomType(message?.customType ?? "update");
621
878
  const content = customMessageText(message);
622
- return activityTimeline.addUpdate(key, title, content, true) ??
623
- activityTimeline.addPendingUpdate(key, title, content, true);
879
+ const existingGroup = activityTimeline.currentGroup();
880
+ const joinsWork = hasWorkActivity(existingGroup);
881
+ const member = joinsWork
882
+ ? activityTimeline.addUpdate(key, title, content, true, "info") ??
883
+ activityTimeline.addPendingUpdate(key, title, content, true, "info")
884
+ : activityTimeline.addPendingUpdate(key, title, content, true, "info");
885
+ // A displayed custom message without current model/tool work is an
886
+ // independent durable update, never a pending member of the next run.
887
+ if (!hasWorkActivity(existingGroup)) activityTimeline.boundary();
888
+ return member;
624
889
  };
625
890
 
891
+ const nativeCustomBoundaryKeys = new Set<string>();
892
+ let closeCleanAtNativeCustomBoundary = () => activityTimeline.boundary();
893
+ let closeCleanAtStandaloneAlert = () => activityTimeline.boundary();
626
894
  const customMessagePrototype = CustomMessageComponent.prototype as any;
627
895
  const customMessagePatchKey = Symbol.for("pretty-tui.clean-custom-message");
628
896
  if (!customMessagePrototype[customMessagePatchKey]) {
@@ -631,9 +899,13 @@ export default function prettyTui(pi: ExtensionAPI) {
631
899
  const releaseSemanticCustomMessage = (component: any): boolean => {
632
900
  if (!component.customRenderer) return false;
633
901
  const key = customMessageKey(component.message);
634
- const member = activityTimeline.memberForUpdate(key);
635
- if (member) activityUpdateComponents.delete(member.id);
636
- activityTimeline.removeUpdate(key);
902
+ if (!nativeCustomBoundaryKeys.has(key)) {
903
+ nativeCustomBoundaryKeys.add(key);
904
+ const member = activityTimeline.memberForUpdate(key);
905
+ if (member) activityUpdateComponents.delete(member.id);
906
+ activityTimeline.removeUpdate(key);
907
+ closeCleanAtNativeCustomBoundary();
908
+ }
637
909
  return true;
638
910
  };
639
911
  const patchedCustomRender = function (this: any, width: number): string[] {
@@ -643,37 +915,19 @@ export default function prettyTui(pi: ExtensionAPI) {
643
915
  const message = this.message;
644
916
  const key = customMessageKey(message);
645
917
  let member = activityTimeline.memberForUpdate(key);
646
- if (!member) {
647
- const hintedToolCallId = customUpdateToolHints.get(key);
648
- const hintedGroup = hintedToolCallId
649
- ? activityTimeline.groupForTool(hintedToolCallId)
650
- : undefined;
651
- if (hintedGroup) {
652
- const attach = customUpdateBeforeToolHints.has(key)
653
- ? activityTimeline.addUpdateToGroupStart.bind(activityTimeline)
654
- : activityTimeline.addUpdateToGroup.bind(activityTimeline);
655
- member = attach(
656
- hintedGroup.id,
657
- key,
658
- humanizeCustomType(message?.customType ?? "update"),
659
- customMessageText(message),
660
- true,
661
- );
662
- }
918
+ // Extension-injected custom messages do not always emit Pi's model
919
+ // message_start event. Claim them at the component boundary so live
920
+ // rendering matches transcript restoration instead of failing open to
921
+ // the purple native box until the next reload.
922
+ if (!member && renderMode === "clean") {
923
+ member = addPersistentActivityUpdate(message);
663
924
  }
664
925
  const group = member ? activityTimeline.groupForMember(member.id) : undefined;
665
926
  if (renderMode !== "clean" || !member || !group) {
666
927
  return originalCustomRender.call(this, width);
667
928
  }
668
929
  activityUpdateComponents.set(member.id, this);
669
- if (group.toolCallIds.length === 0) return renderPendingActivityUpdate(member, width);
670
- const position = activityMemberPosition(group, member);
671
- if (!activityGroupRevealed(group)) {
672
- return position.first
673
- ? ["", ...renderActivityGroupSummary(group, width, true)]
674
- : [];
675
- }
676
- return renderActivityUpdate(group, member, width);
930
+ return renderActivityUpdateProjection(group, member, width);
677
931
  };
678
932
  const patchedCustomHandleMouse = function (this: any, event: any) {
679
933
  if (releaseSemanticCustomMessage(this)) {
@@ -684,7 +938,6 @@ export default function prettyTui(pi: ExtensionAPI) {
684
938
  if (renderMode !== "clean" || !member || !group) {
685
939
  return originalCustomHandleMouse?.call(this, event);
686
940
  }
687
- if (group.toolCallIds.length === 0) return undefined;
688
941
  return handleActivityUpdateMouse(group, member, event);
689
942
  };
690
943
  customMessagePrototype[customMessagePatchKey] = {
@@ -728,14 +981,38 @@ export default function prettyTui(pi: ExtensionAPI) {
728
981
  const cleanThinkingRenderCacheKey = Symbol("pretty-tui.clean-thinking-render-cache");
729
982
  const cleanThinkingDetailKey = Symbol("pretty-tui.clean-thinking-detail");
730
983
 
984
+ const assistantTerminalUpdate = (message: any): ActivityMember | undefined => {
985
+ if (!assistantTerminalState(message)) return undefined;
986
+ const hasToolCalls = Array.isArray(message?.content) &&
987
+ message.content.some((item: any) => item?.type === "toolCall");
988
+ // Failed tools already preserve the error in their native result details;
989
+ // avoid duplicating that payload as a second durable member.
990
+ if (hasToolCalls) return undefined;
991
+ const key = `assistant-terminal:${assistantMessageKey(message)}`;
992
+ const existing = activityTimeline.memberForUpdate(key);
993
+ if (existing) return existing;
994
+ const stopReason = message?.stopReason;
995
+ const severity: ActivitySeverity = stopReason === "aborted" ? "warning" : "error";
996
+ const title = stopReason === "length"
997
+ ? "Response truncated"
998
+ : stopReason === "aborted"
999
+ ? "Operation aborted"
1000
+ : "Error";
1001
+ const content = String(message?.errorMessage ?? "").trim() ||
1002
+ (stopReason === "length" ? "Response was truncated before completion." : "Unknown error");
1003
+ activityTimeline.boundary();
1004
+ const member = activityTimeline.addPendingUpdate(key, title, content, true, severity);
1005
+ activityTimeline.boundary();
1006
+ return member;
1007
+ };
1008
+
731
1009
  const eligibleThinking = (message: any) =>
732
1010
  Array.isArray(message?.content) &&
733
1011
  message.content.some(
734
1012
  (item: any) => item?.type === "thinking" &&
735
1013
  typeof item.thinking === "string" &&
736
1014
  item.thinking.length > 0,
737
- ) &&
738
- !assistantSystemBoundary(message);
1015
+ );
739
1016
 
740
1017
  const refreshGroup = (group: ActivityGroup) => {
741
1018
  for (const member of group.members) {
@@ -743,11 +1020,41 @@ export default function prettyTui(pi: ExtensionAPI) {
743
1020
  cleanToolComponents.get(member.toolCallId)?.updateDisplay?.();
744
1021
  } else if (member.kind === "thinking" && member.messageKey) {
745
1022
  thinkingComponents.get(member.messageKey)?.invalidate?.();
1023
+ } else if (member.kind === "update") {
1024
+ activityUpdateComponents.get(member.id)?.invalidate?.();
746
1025
  }
747
1026
  }
748
1027
  currentTui?.requestRender?.();
749
1028
  };
750
1029
 
1030
+ // Shared detail projection for a Thought, used both as a member of a tool
1031
+ // group and as a standalone row. The cache records its theme because the
1032
+ // same component can switch between the Markdown fallback and a real tool
1033
+ // theme once the first tool of the group is constructed.
1034
+ const thinkingDetailLines = (
1035
+ owner: any,
1036
+ text: string,
1037
+ theme: any,
1038
+ childWidth: number,
1039
+ ): string[] => {
1040
+ let detail = owner[cleanThinkingDetailKey];
1041
+ if (!detail || detail.text !== text || detail.theme !== theme) {
1042
+ detail = {
1043
+ text,
1044
+ theme,
1045
+ component: new Markdown(text, 0, 0, owner.markdownTheme, {
1046
+ color: (line: string) => theme.fg("thinkingText", line),
1047
+ italic: true,
1048
+ }),
1049
+ };
1050
+ owner[cleanThinkingDetailKey] = detail;
1051
+ }
1052
+ const lines: string[] = detail.component.render(Math.max(1, childWidth));
1053
+ return lines.map((line, index) =>
1054
+ theme.fg("dim", index === lines.length - 1 ? " └ " : " │ ") + line
1055
+ );
1056
+ };
1057
+
751
1058
  const patchedUpdateContent = function (this: any, message: any, isStreaming = this.isStreaming) {
752
1059
  this[originalMessageKey] = message;
753
1060
  this[renderedModeKey] = renderMode;
@@ -790,8 +1097,14 @@ export default function prettyTui(pi: ExtensionAPI) {
790
1097
  ) {
791
1098
  patchedUpdateContent.call(this, message, this.isStreaming);
792
1099
  }
793
- if (renderMode !== "clean" || !eligibleThinking(message)) {
794
- return originalRender.call(this, width);
1100
+ if (renderMode !== "clean") return originalRender.call(this, width);
1101
+ const terminalMember = assistantTerminalUpdate(message);
1102
+ if (!eligibleThinking(message)) {
1103
+ if (!terminalMember) return originalRender.call(this, width);
1104
+ const terminalGroup = activityTimeline.groupForMember(terminalMember.id);
1105
+ if (!terminalGroup) return originalRender.call(this, width);
1106
+ activityUpdateComponents.set(terminalMember.id, this);
1107
+ return renderActivityUpdateProjection(terminalGroup, terminalMember, width);
795
1108
  }
796
1109
 
797
1110
  const key = assistantMessageKey(message);
@@ -803,25 +1116,29 @@ export default function prettyTui(pi: ExtensionAPI) {
803
1116
  // assistant text is never lost.
804
1117
  return originalRender.call(this, width);
805
1118
  }
806
- if (group.toolCallIds.length === 0) {
807
- // Activity groups are tool-oriented. A model may finish with Thinking
808
- // and visible text without calling a tool; only the Thinking portion
809
- // is filtered from Pi's native component, so always preserve the
810
- // visible response here instead of returning an empty projection.
811
- return visibleAssistantText(message) ? originalRender.call(this, width) : [];
812
- }
813
1119
  thinkingComponents.set(key, this);
814
1120
  if (!activityFallbackThemes.has(group.id)) {
815
1121
  activityFallbackThemes.set(group.id, defaultActivityTheme());
816
1122
  }
817
1123
  const position = activityMemberPosition(group, member);
818
1124
  const visibleLines = visibleAssistantText(message) ? originalRender.call(this, width) : [];
1125
+ // Pi's native renderer prints its own error line for messages with no
1126
+ // tool calls, so this component only adds the styled alert card when no
1127
+ // visible text can carry it.
1128
+ const terminalLines = terminalMember && visibleLines.length === 0
1129
+ ? renderPendingActivityUpdate(terminalMember, width)
1130
+ : [];
1131
+ const appendTail = (activityLines: string[]) => [
1132
+ ...activityLines,
1133
+ ...terminalLines,
1134
+ ...visibleLines,
1135
+ ];
819
1136
  const revealed = activityGroupRevealed(group);
820
1137
  if (!revealed) {
821
1138
  const activityLines = position.first
822
1139
  ? ["", ...renderActivityGroupSummary(group, width, true)]
823
1140
  : [];
824
- return [...activityLines, ...visibleLines];
1141
+ return appendTail(activityLines);
825
1142
  }
826
1143
 
827
1144
  const groupTheme = activityGroupTheme(group);
@@ -831,10 +1148,16 @@ export default function prettyTui(pi: ExtensionAPI) {
831
1148
  width,
832
1149
  groupTheme,
833
1150
  );
1151
+ // Clean mode owns thought disclosure: revealing a group shows the thought
1152
+ // member, while only a member click (or Ctrl+O) reveals its full text.
834
1153
  const expanded = cleanToolsExpanded || expandedThinkingMembers.has(member.id);
835
1154
  const settledOwner = position.first ? activityGroupOwner(group) : undefined;
836
- const stableSummary = !position.first || Boolean(
837
- settledOwner && settledSummaries.has(settledOwner) && !toolActivityHolds.has(settledOwner),
1155
+ // A group without tools has no durable summary, so its own boundary is
1156
+ // the signal that it finished.
1157
+ const stableSummary = !position.first || (
1158
+ settledOwner
1159
+ ? Boolean(settledSummaries.has(settledOwner) && !toolActivityHolds.has(settledOwner))
1160
+ : activityTimeline.isSettled(group.id)
838
1161
  );
839
1162
  const cacheable = !expanded && !this.isStreaming && stableSummary;
840
1163
  const cached = cacheable ? this[cleanThinkingRenderCacheKey] : undefined;
@@ -848,7 +1171,7 @@ export default function prettyTui(pi: ExtensionAPI) {
848
1171
  // Only the stable activity projection is cached. Visible assistant
849
1172
  // text belongs outside the collapsible group and must be appended on
850
1173
  // every render, including cache hits.
851
- return [...cached.lines, ...visibleLines];
1174
+ return appendTail(cached.lines);
852
1175
  }
853
1176
  const stateLabel = this.isStreaming ? "thinking" : "thought";
854
1177
  const label = truncateToWidth(stateLabel, Math.max(1, childWidth - 2), "…");
@@ -856,28 +1179,11 @@ export default function prettyTui(pi: ExtensionAPI) {
856
1179
  groupTheme.fg("toolTitle", groupTheme.bold(label));
857
1180
  let contentLines: string[] = [header];
858
1181
  if (expanded) {
859
- const detailWidth = Math.max(1, childWidth - visibleWidth(" │ "));
860
- const detailText = member.thinking ?? thinkingText(message);
861
- let detail = this[cleanThinkingDetailKey];
862
- if (!detail || detail.text !== detailText) {
863
- detail = {
864
- text: detailText,
865
- component: new Markdown(
866
- detailText,
867
- 0,
868
- 0,
869
- this.markdownTheme,
870
- {
871
- color: (text: string) => groupTheme.fg("thinkingText", text),
872
- italic: true,
873
- },
874
- ),
875
- };
876
- this[cleanThinkingDetailKey] = detail;
877
- }
878
- const detailLines = detail.component.render(detailWidth);
879
- contentLines.push(...detailLines.map((line: string, index: number) =>
880
- groupTheme.fg("dim", index === detailLines.length - 1 ? " └ " : " │ ") + line
1182
+ contentLines.push(...thinkingDetailLines(
1183
+ this,
1184
+ member.thinking ?? thinkingText(message),
1185
+ groupTheme,
1186
+ Math.max(1, childWidth - visibleWidth(" │ ")),
881
1187
  ));
882
1188
  }
883
1189
  const decorated = contentLines.map((line, index) =>
@@ -896,7 +1202,7 @@ export default function prettyTui(pi: ExtensionAPI) {
896
1202
  lines: output,
897
1203
  };
898
1204
  }
899
- return [...output, ...visibleLines];
1205
+ return appendTail(output);
900
1206
  };
901
1207
 
902
1208
  const patchedRender = function (this: any, width: number): string[] {
@@ -905,14 +1211,20 @@ export default function prettyTui(pi: ExtensionAPI) {
905
1211
 
906
1212
  const handleAssistantTranscriptMouse = function (this: any, event: any) {
907
1213
  const message = this[originalMessageKey] ?? this.lastMessage;
908
- if (renderMode !== "clean" || !eligibleThinking(message)) {
909
- return originalHandleMouse.call(this, event);
1214
+ if (renderMode !== "clean") return originalHandleMouse.call(this, event);
1215
+ if (!eligibleThinking(message)) {
1216
+ const terminalMember = assistantTerminalUpdate(message);
1217
+ const terminalGroup = terminalMember
1218
+ ? activityTimeline.groupForMember(terminalMember.id)
1219
+ : undefined;
1220
+ return terminalMember && terminalGroup && activityGroupCollapsible(terminalGroup)
1221
+ ? handleActivityUpdateMouse(terminalGroup, terminalMember, event)
1222
+ : originalHandleMouse.call(this, event);
910
1223
  }
911
1224
  const key = assistantMessageKey(message);
912
1225
  const member = activityTimeline.memberForThinking(key);
913
1226
  const group = member ? activityTimeline.groupForMember(member.id) : undefined;
914
1227
  if (!member || !group) return originalHandleMouse.call(this, event);
915
- if (group.toolCallIds.length === 0) return originalHandleMouse.call(this, event);
916
1228
  const position = activityMemberPosition(group, member);
917
1229
  const isLeftClick = event.type === "click" && event.button === "left";
918
1230
 
@@ -1027,53 +1339,42 @@ export default function prettyTui(pi: ExtensionAPI) {
1027
1339
  ];
1028
1340
  };
1029
1341
 
1030
- const indexDisplayedCustomUpdateHints = (entries: any[]) => {
1031
- customUpdateToolHints.clear();
1032
- customUpdateBeforeToolHints.clear();
1033
- let lastToolCallId: string | undefined;
1034
- let pendingCustomKeys: string[] = [];
1035
- for (const entry of entries) {
1036
- if (entry?.type === "compaction") {
1037
- lastToolCallId = undefined;
1038
- pendingCustomKeys = [];
1039
- continue;
1040
- }
1041
- if (entry?.type === "custom_message") {
1042
- if (entry.display !== false) {
1043
- const key = customMessageKey(entry);
1044
- if (lastToolCallId) customUpdateToolHints.set(key, lastToolCallId);
1045
- else pendingCustomKeys.push(key);
1046
- }
1047
- continue;
1048
- }
1049
- if (entry?.type !== "message") continue;
1050
- const message = entry.message;
1051
- if (message?.role === "user") {
1052
- lastToolCallId = undefined;
1053
- pendingCustomKeys = [];
1054
- continue;
1055
- }
1056
- if (message?.role === "assistant") {
1057
- const toolCalls = (Array.isArray(message.content) ? message.content : [])
1058
- .filter((item: any) => item?.type === "toolCall" && item.id);
1059
- if (toolCalls.length > 0 && pendingCustomKeys.length > 0) {
1060
- for (const key of pendingCustomKeys) {
1061
- customUpdateToolHints.set(key, toolCalls[0].id);
1062
- customUpdateBeforeToolHints.add(key);
1063
- }
1064
- pendingCustomKeys = [];
1065
- }
1066
- if (visibleAssistantText(message) || assistantSystemBoundary(message)) {
1067
- lastToolCallId = undefined;
1068
- pendingCustomKeys = [];
1069
- }
1070
- for (const item of toolCalls) lastToolCallId = item.id;
1071
- continue;
1072
- }
1073
- if (message?.role === "toolResult" && message.toolCallId) {
1074
- lastToolCallId = message.toolCallId;
1075
- }
1076
- }
1342
+ const appendRuntimeActivityUpdate = (
1343
+ mode: any,
1344
+ message: string,
1345
+ severity: ActivitySeverity,
1346
+ ): boolean => {
1347
+ const [firstLine, ...remainingLines] = String(message).split("\n");
1348
+ const fallbackTitle = severity === "warning"
1349
+ ? "Warning"
1350
+ : severity === "error"
1351
+ ? "Error"
1352
+ : "Info";
1353
+ const title = firstLine || fallbackTitle;
1354
+ const content = remainingLines.join("\n");
1355
+ // Warnings and errors are standalone hard boundaries; only ordinary notices
1356
+ // join the work in progress.
1357
+ const standalone = severity === "warning" || severity === "error";
1358
+ const updateKey = `runtime:${severity}:${++activityUpdateSequence}`;
1359
+ if (standalone) closeCleanAtStandaloneAlert();
1360
+ const existingGroup = activityTimeline.currentGroup();
1361
+ const joinsWork = !standalone && hasWorkActivity(existingGroup);
1362
+ const member = joinsWork
1363
+ ? activityTimeline.addUpdate(updateKey, title, content, false, severity) ??
1364
+ activityTimeline.addPendingUpdate(updateKey, title, content, false, severity)
1365
+ : activityTimeline.addPendingUpdate(updateKey, title, content, false, severity);
1366
+ if (!joinsWork) activityTimeline.boundary();
1367
+ const group = activityTimeline.groupForMember(member.id);
1368
+ if (!group || !mode.chatContainer?.addChild) return false;
1369
+ const component: Component = {
1370
+ render: (width: number) => renderActivityUpdateProjection(group, member, width),
1371
+ invalidate() {},
1372
+ handleMouse: (event: any) => handleActivityUpdateMouse(group, member, event),
1373
+ } as Component;
1374
+ activityUpdateComponents.set(member.id, component);
1375
+ mode.chatContainer.addChild(component);
1376
+ mode.ui?.requestRender?.();
1377
+ return true;
1077
1378
  };
1078
1379
 
1079
1380
  const interactiveModePrototype = InteractiveMode.prototype as any;
@@ -1083,6 +1384,10 @@ export default function prettyTui(pi: ExtensionAPI) {
1083
1384
  const originalRenderSessionEntries = interactiveModePrototype.renderSessionEntries;
1084
1385
  const originalSwitchTuiMode = interactiveModePrototype.switchTuiMode;
1085
1386
  const originalShowExtensionNotify = interactiveModePrototype.showExtensionNotify;
1387
+ const originalToggleThinkingBlockVisibility = interactiveModePrototype.toggleThinkingBlockVisibility;
1388
+ const originalShowStatus = interactiveModePrototype.showStatus;
1389
+ const originalShowWarning = interactiveModePrototype.showWarning;
1390
+ const originalShowError = interactiveModePrototype.showError;
1086
1391
  const patchedSetToolsExpanded = function (this: any, expanded: boolean) {
1087
1392
  cleanToolsExpanded = expanded;
1088
1393
  changingAllToolsExpansion = true;
@@ -1107,7 +1412,6 @@ export default function prettyTui(pi: ExtensionAPI) {
1107
1412
  // while Pi's live compaction UI appends it chronologically. Keep reloads
1108
1413
  // and transcript rebuilds consistent with that live presentation.
1109
1414
  const orderedEntries = orderContextEntriesForTranscript(entries);
1110
- indexDisplayedCustomUpdateHints(orderedEntries);
1111
1415
  return originalRenderSessionEntries.call(this, orderedEntries, options);
1112
1416
  };
1113
1417
 
@@ -1118,43 +1422,80 @@ export default function prettyTui(pi: ExtensionAPI) {
1118
1422
  return result;
1119
1423
  };
1120
1424
 
1425
+ // Pi's own showExtensionNotify only dispatches to showStatus/showWarning/
1426
+ // showError, and all three are replaced above. Delegating back to it would
1427
+ // therefore re-enter these patches instead of restoring native behaviour,
1428
+ // which sent info notifications into the transient UI flash. Route straight
1429
+ // to the original method that matches the severity instead.
1430
+ const nativeExtensionNotify = function (
1431
+ this: any,
1432
+ text: string,
1433
+ severity: ActivitySeverity,
1434
+ ): void {
1435
+ const direct = severity === "error"
1436
+ ? originalShowError
1437
+ : severity === "warning"
1438
+ ? originalShowWarning
1439
+ : originalShowStatus;
1440
+ try {
1441
+ if (typeof direct === "function") direct.call(this, text);
1442
+ else originalShowExtensionNotify.call(this, text, severity === "info" ? undefined : severity);
1443
+ } catch {
1444
+ // This host has no transcript to write to. The notification is already
1445
+ // recorded in the activity timeline, and it does not belong in the
1446
+ // transient UI flash, which only carries Pi UI state messages.
1447
+ }
1448
+ };
1449
+
1121
1450
  const patchedShowExtensionNotify = function (
1122
1451
  this: any,
1123
1452
  message: string,
1124
1453
  type?: "info" | "warning" | "error",
1125
1454
  ) {
1126
- if (renderMode !== "clean" || (type !== undefined && type !== "info")) {
1455
+ if (renderMode !== "clean") {
1127
1456
  return originalShowExtensionNotify.call(this, message, type);
1128
1457
  }
1129
- const [firstLine, ...remainingLines] = String(message).split("\n");
1130
- const updateKey = `info:${++activityUpdateSequence}`;
1131
- const title = firstLine || "Info";
1132
- const content = remainingLines.join("\n");
1133
- const member = activityTimeline.addUpdate(updateKey, title, content, false) ??
1134
- activityTimeline.addPendingUpdate(updateKey, title, content, false);
1135
- const group = activityTimeline.groupForMember(member.id);
1136
- if (!group || !this.chatContainer?.addChild) {
1137
- return originalShowExtensionNotify.call(this, message, type);
1458
+ const severity: ActivitySeverity = type ?? "info";
1459
+ if (!appendRuntimeActivityUpdate(this, message, severity)) {
1460
+ nativeExtensionNotify.call(this, message, severity);
1138
1461
  }
1139
- const component: Component = {
1140
- render: (width: number) => {
1141
- if (group.toolCallIds.length === 0) return renderPendingActivityUpdate(member, width);
1142
- const position = activityMemberPosition(group, member);
1143
- if (!activityGroupRevealed(group)) {
1144
- return position.first
1145
- ? ["", ...renderActivityGroupSummary(group, width, true)]
1146
- : [];
1147
- }
1148
- return renderActivityUpdate(group, member, width);
1149
- },
1150
- invalidate() {},
1151
- handleMouse: (event: any) => handleActivityUpdateMouse(group, member, event),
1152
- } as Component;
1153
- activityUpdateComponents.set(member.id, component);
1154
- this.chatContainer.addChild(component);
1462
+ };
1463
+
1464
+ const patchedToggleThinkingBlockVisibility = function (this: any) {
1465
+ if (renderMode !== "clean") {
1466
+ return originalToggleThinkingBlockVisibility.call(this);
1467
+ }
1468
+ // Do not change Pi's persisted hideThinkingBlock setting in clean mode.
1469
+ // Thought details use the activity hierarchy instead: click one thought,
1470
+ // or use Ctrl+O when every member should expand together.
1471
+ this.showStatus(
1472
+ "Thought details expand individually in clean mode\nClick a thought, or use Ctrl+O to expand all.",
1473
+ );
1474
+ };
1475
+
1476
+ const patchedShowStatus = function (this: any, message: string) {
1477
+ if (renderMode !== "clean") return originalShowStatus.call(this, message);
1478
+ const [firstLine, ...remainingLines] = String(message).split("\n");
1479
+ setLatestActivityStatus({
1480
+ title: firstLine || "Status",
1481
+ detail: remainingLines.join("\n"),
1482
+ severity: "status",
1483
+ });
1155
1484
  this.ui?.requestRender?.();
1156
1485
  };
1157
1486
 
1487
+ const patchedShowWarning = function (this: any, message: string) {
1488
+ if (renderMode !== "clean" || !appendRuntimeActivityUpdate(this, message, "warning")) {
1489
+ return originalShowWarning.call(this, message);
1490
+ }
1491
+ };
1492
+
1493
+ const patchedShowError = function (this: any, message: string) {
1494
+ if (renderMode !== "clean" || !appendRuntimeActivityUpdate(this, message, "error")) {
1495
+ return originalShowError.call(this, message);
1496
+ }
1497
+ };
1498
+
1158
1499
  interactiveModePrototype[toolsExpansionPatchKey] = {
1159
1500
  originalSetToolsExpanded,
1160
1501
  patchedSetToolsExpanded,
@@ -1164,15 +1505,28 @@ export default function prettyTui(pi: ExtensionAPI) {
1164
1505
  patchedSwitchTuiMode,
1165
1506
  originalShowExtensionNotify,
1166
1507
  patchedShowExtensionNotify,
1508
+ originalToggleThinkingBlockVisibility,
1509
+ patchedToggleThinkingBlockVisibility,
1510
+ originalShowStatus,
1511
+ patchedShowStatus,
1512
+ originalShowWarning,
1513
+ patchedShowWarning,
1514
+ originalShowError,
1515
+ patchedShowError,
1167
1516
  };
1168
1517
  interactiveModePrototype.setToolsExpanded = patchedSetToolsExpanded;
1169
1518
  interactiveModePrototype.renderSessionEntries = patchedRenderSessionEntries;
1170
1519
  interactiveModePrototype.switchTuiMode = patchedSwitchTuiMode;
1171
1520
  interactiveModePrototype.showExtensionNotify = patchedShowExtensionNotify;
1521
+ interactiveModePrototype.toggleThinkingBlockVisibility = patchedToggleThinkingBlockVisibility;
1522
+ interactiveModePrototype.showStatus = patchedShowStatus;
1523
+ interactiveModePrototype.showWarning = patchedShowWarning;
1524
+ interactiveModePrototype.showError = patchedShowError;
1172
1525
 
1173
1526
  pi.on("session_shutdown", () => {
1174
1527
  fullscreenTui = false;
1175
1528
  currentTui = undefined;
1529
+ clearLatestActivityStatus();
1176
1530
  currentExtensionUi = undefined;
1177
1531
  const patch = interactiveModePrototype[toolsExpansionPatchKey];
1178
1532
  if (!patch) return;
@@ -1188,11 +1542,27 @@ export default function prettyTui(pi: ExtensionAPI) {
1188
1542
  if (patch.patchedShowExtensionNotify === interactiveModePrototype.showExtensionNotify) {
1189
1543
  interactiveModePrototype.showExtensionNotify = patch.originalShowExtensionNotify;
1190
1544
  }
1545
+ if (patch.patchedToggleThinkingBlockVisibility === interactiveModePrototype.toggleThinkingBlockVisibility) {
1546
+ interactiveModePrototype.toggleThinkingBlockVisibility = patch.originalToggleThinkingBlockVisibility;
1547
+ }
1548
+ if (patch.patchedShowStatus === interactiveModePrototype.showStatus) {
1549
+ interactiveModePrototype.showStatus = patch.originalShowStatus;
1550
+ }
1551
+ if (patch.patchedShowWarning === interactiveModePrototype.showWarning) {
1552
+ interactiveModePrototype.showWarning = patch.originalShowWarning;
1553
+ }
1554
+ if (patch.patchedShowError === interactiveModePrototype.showError) {
1555
+ interactiveModePrototype.showError = patch.originalShowError;
1556
+ }
1191
1557
  if (
1192
1558
  interactiveModePrototype.setToolsExpanded === patch.originalSetToolsExpanded &&
1193
1559
  interactiveModePrototype.renderSessionEntries === patch.originalRenderSessionEntries &&
1194
1560
  interactiveModePrototype.switchTuiMode === patch.originalSwitchTuiMode &&
1195
- interactiveModePrototype.showExtensionNotify === patch.originalShowExtensionNotify
1561
+ interactiveModePrototype.showExtensionNotify === patch.originalShowExtensionNotify &&
1562
+ interactiveModePrototype.toggleThinkingBlockVisibility === patch.originalToggleThinkingBlockVisibility &&
1563
+ interactiveModePrototype.showStatus === patch.originalShowStatus &&
1564
+ interactiveModePrototype.showWarning === patch.originalShowWarning &&
1565
+ interactiveModePrototype.showError === patch.originalShowError
1196
1566
  ) {
1197
1567
  delete interactiveModePrototype[toolsExpansionPatchKey];
1198
1568
  }
@@ -1517,20 +1887,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1517
1887
  event.y === candidate.y && event.x >= candidate.xStart && event.x < candidate.xEnd,
1518
1888
  );
1519
1889
  if (region) {
1520
- if (event.type === "click") {
1521
- if (typeof currentTui?.copyTextToClipboard === "function") {
1522
- // Match fullscreen selection exactly: use Pi TUI's clipboard path
1523
- // and its transient "Copied!" / "Copy failed" flash feedback.
1524
- void Promise.resolve(currentTui.copyTextToClipboard(region.code)).catch(() => {
1525
- currentTui?.flash?.("Copy failed");
1526
- });
1527
- } else {
1528
- // Defensive fallback for older Pi versions without the fullscreen helper.
1529
- void copyToClipboard(region.code)
1530
- .then(() => currentExtensionUi?.notify("Copied!", "info"))
1531
- .catch(() => currentExtensionUi?.notify("Copy failed", "error"));
1532
- }
1533
- }
1890
+ if (event.type === "click") copyTranscriptText(region.code);
1534
1891
  return { handled: true, render: false };
1535
1892
  }
1536
1893
  }
@@ -1760,8 +2117,11 @@ export default function prettyTui(pi: ExtensionAPI) {
1760
2117
  return block(rows);
1761
2118
  };
1762
2119
 
1763
- const isError = (renderContext: any, output: string): boolean =>
1764
- Boolean(renderContext?.isError) || /^(error|failed|access denied)\b/i.test(output.trim());
2120
+ // Pi's render context always carries the authoritative failure flag
2121
+ // (tool-execution.js builds it from result.isError). A tool's own output text
2122
+ // must never decide this: `grep -n Error`, `cat error.log`, or a test runner
2123
+ // printing "failed 0" would otherwise mark a successful call as failed.
2124
+ const isError = (renderContext: any): boolean => Boolean(renderContext?.isError);
1765
2125
 
1766
2126
  const partialResult = (context: any, theme: any, label: string): Component => {
1767
2127
  setStatus(context, "running");
@@ -1834,8 +2194,8 @@ export default function prettyTui(pi: ExtensionAPI) {
1834
2194
  return block(rows);
1835
2195
  };
1836
2196
 
1837
- const completeStatus = (context: any, output: string): boolean => {
1838
- const failed = isError(context, output);
2197
+ const completeStatus = (context: any): boolean => {
2198
+ const failed = isError(context);
1839
2199
  setStatus(context, failed ? "error" : "success");
1840
2200
  return failed;
1841
2201
  };
@@ -2351,6 +2711,15 @@ export default function prettyTui(pi: ExtensionAPI) {
2351
2711
  activityTimeline.boundary();
2352
2712
  };
2353
2713
 
2714
+ closeCleanAtNativeCustomBoundary = () => {
2715
+ finishCleanGroup("done");
2716
+ settleLastCleanGroup();
2717
+ };
2718
+ closeCleanAtStandaloneAlert = () => {
2719
+ finishCleanGroup("done");
2720
+ settleLastCleanGroup();
2721
+ };
2722
+
2354
2723
  const settleLastCleanGroup = () => {
2355
2724
  const group = cleanRun.groups[cleanRun.groups.length - 1];
2356
2725
  if (!group) return;
@@ -2374,14 +2743,16 @@ export default function prettyTui(pi: ExtensionAPI) {
2374
2743
  const activityText = /^thinking(?:\.\.\.)?$/iu.test(normalizedActivityText)
2375
2744
  ? "thinking"
2376
2745
  : rawActivityText;
2377
- const countLabel = `${count} tool ${count === 1 ? "call" : "calls"}`;
2746
+ const countLabel = count > 0 ? `${count} tool ${count === 1 ? "call" : "calls"}` : "";
2378
2747
  const thoughtLabel = thoughtCount > 0
2379
- ? ` · ${thoughtCount} ${thoughtCount === 1 ? "thought" : "thoughts"}`
2748
+ ? `${thoughtCount} ${thoughtCount === 1 ? "thought" : "thoughts"}`
2380
2749
  : "";
2381
- const activityLabel = activityText !== "done" && activityText.trim()
2382
- ? ` · ${activityText}`
2750
+ // Without tools there is no current tool name to report, and the generic
2751
+ // "thinking" placeholder would only repeat the thought count.
2752
+ const activityLabel = count > 0 && activityText !== "done" && activityText.trim()
2753
+ ? activityText
2383
2754
  : "";
2384
- return `${countLabel}${thoughtLabel}${activityLabel}`;
2755
+ return [countLabel, thoughtLabel, activityLabel].filter(Boolean).join(" · ");
2385
2756
  };
2386
2757
 
2387
2758
  const summaryRow = (
@@ -2455,36 +2826,53 @@ export default function prettyTui(pi: ExtensionAPI) {
2455
2826
  ): string[] => {
2456
2827
  const fit = (lines: string[]) => lines.map((line) => truncateToWidth(line, Math.max(1, width), ""));
2457
2828
  const summaryTheme = activityGroupTheme(group);
2829
+ if (!summaryTheme || !activityTimeline.hasWork(group)) return [];
2458
2830
  const owner = activityGroupOwner(group);
2459
- if (!summaryTheme || !owner || group.toolCallIds.length === 0) return [];
2460
- const settled = settledSummaries.get(owner);
2831
+ const settled = owner ? settledSummaries.get(owner) : undefined;
2832
+ const toolCount = group.toolCallIds.length;
2833
+ const live = () => cleanRun.activeToolName ?? currentCleanActivity();
2461
2834
  if (settled) {
2462
2835
  return fit(block([summaryRow(
2463
2836
  summaryTheme,
2464
- Math.max(settled.count, group.toolCallIds.length),
2465
- settled.failed,
2837
+ group.hardBoundarySplit ? toolCount : Math.max(settled.count, toolCount),
2838
+ group.hardBoundarySplit ? 0 : settled.failed,
2466
2839
  Math.max(settled.thoughtCount, group.thoughtCount),
2467
2840
  settled.activity,
2468
2841
  collapsedDone,
2469
2842
  )]).render(width));
2470
2843
  }
2471
- const currentBelongs = cleanRun.activeToolCallId
2472
- ? group.toolCallIds.includes(cleanRun.activeToolCallId)
2473
- : false;
2474
- const lastBelongs = cleanRun.lastCompletedToolCallId
2475
- ? group.toolCallIds.includes(cleanRun.lastCompletedToolCallId)
2476
- : false;
2477
- if (currentBelongs || lastBelongs || !cleanRun.settled) {
2844
+ if (owner && !cleanRun.settled) {
2845
+ // Live tool group: keep the current tool name and the running count.
2478
2846
  return fit(block([summaryRow(
2479
2847
  summaryTheme,
2480
- Math.max(group.toolCallIds.length, liveCleanToolCount()),
2848
+ Math.max(toolCount, liveCleanToolCount()),
2481
2849
  cleanRun.failed,
2482
2850
  group.thoughtCount,
2483
- () => cleanRun.activeToolName ?? currentCleanActivity(),
2851
+ live,
2484
2852
  collapsedDone,
2485
2853
  )]).render(width));
2486
2854
  }
2487
- return [];
2855
+ if (!owner && !activityTimeline.isSettled(group.id)) {
2856
+ // Thought-only group while the model is still working.
2857
+ return fit(block([summaryRow(
2858
+ summaryTheme,
2859
+ toolCount,
2860
+ cleanRun.failed,
2861
+ group.thoughtCount,
2862
+ "thinking",
2863
+ collapsedDone,
2864
+ )]).render(width));
2865
+ }
2866
+ // Finished group with no durable summary: a completed Thought-only turn, or
2867
+ // a group split apart by a native custom renderer.
2868
+ return fit(block([summaryRow(
2869
+ summaryTheme,
2870
+ toolCount,
2871
+ 0,
2872
+ group.thoughtCount,
2873
+ "done",
2874
+ collapsedDone,
2875
+ )]).render(width));
2488
2876
  };
2489
2877
 
2490
2878
  /**
@@ -2552,7 +2940,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2552
2940
 
2553
2941
  const hiddenToolResult = (context: any, options: any, output: string): Component => {
2554
2942
  if (options.isPartial) setStatus(context, "running");
2555
- else completeStatus(context, output);
2943
+ else completeStatus(context);
2556
2944
  return block([]);
2557
2945
  };
2558
2946
 
@@ -2570,6 +2958,62 @@ export default function prettyTui(pi: ExtensionAPI) {
2570
2958
  (renderMode === "clean" && isCleanGroupRevealed(toolCallId))
2571
2959
  );
2572
2960
 
2961
+ pi.registerEntryRenderer<ResponseFooterData>(RESPONSE_FOOTER_ENTRY_TYPE, (entry, _options, theme) => {
2962
+ let copyStart = -1;
2963
+ let copyWidth = 0;
2964
+ return {
2965
+ render(width: number): string[] {
2966
+ const data = entry.data;
2967
+ if (!data || width <= 0) return [];
2968
+ copyStart = -1;
2969
+ copyWidth = 0;
2970
+ const answer = data.answerEntryId ? responseAnswerTexts.get(data.answerEntryId) ?? "" : "";
2971
+ const copyLabel = "[Copy]";
2972
+ const showCopy = fullscreenTui && Boolean(answer.trim()) && width >= 30;
2973
+ const duration = formatResponseDuration(data.durationMs);
2974
+ const statusText = data.outcome === "stopped"
2975
+ ? `⚠ Stopped after ${duration}`
2976
+ : `✓ Completed in ${duration}`;
2977
+ const leading = "── ";
2978
+ const trailing = showCopy ? ` ${copyLabel} ──` : "──";
2979
+ const fixedWidth = visibleWidth(leading) + visibleWidth(trailing) + 1;
2980
+ if (width <= fixedWidth) return [theme.fg("dim", "─".repeat(width))];
2981
+
2982
+ const statusWidth = Math.max(1, width - fixedWidth);
2983
+ const visibleStatus = truncateToWidth(statusText, statusWidth, "…");
2984
+ const prefix = leading + visibleStatus + " ";
2985
+ const fill = "─".repeat(Math.max(0, width - visibleWidth(prefix) - visibleWidth(trailing)));
2986
+ const line = prefix + fill + trailing;
2987
+ if (showCopy) {
2988
+ const copyIndex = line.indexOf(copyLabel);
2989
+ copyStart = visibleWidth(line.slice(0, copyIndex));
2990
+ copyWidth = visibleWidth(copyLabel);
2991
+ }
2992
+ // The divider, completion text, and Copy control intentionally share
2993
+ // one quiet gray treatment; position alone communicates the hierarchy.
2994
+ return [theme.fg("dim", line)];
2995
+ },
2996
+ handleMouse(event: TuiMouseEvent) {
2997
+ if (
2998
+ copyStart < 0 ||
2999
+ event.button !== "left" ||
3000
+ (event.type !== "press" && event.type !== "click") ||
3001
+ event.y !== 0 ||
3002
+ event.x < copyStart ||
3003
+ event.x >= copyStart + copyWidth
3004
+ ) {
3005
+ return undefined;
3006
+ }
3007
+ if (event.type === "click" && entry.data?.answerEntryId) {
3008
+ const answer = responseAnswerTexts.get(entry.data.answerEntryId);
3009
+ if (answer) copyTranscriptText(answer);
3010
+ }
3011
+ return { handled: true, render: false };
3012
+ },
3013
+ invalidate() {},
3014
+ };
3015
+ });
3016
+
2573
3017
  pi.registerEntryRenderer<ToolSummaryData>("pretty-tui-tool-summary", (entry, { expanded }, theme) => ({
2574
3018
  render(width: number): string[] {
2575
3019
  if (renderMode !== "clean" || expanded || cleanContextCompacted) return [];
@@ -2620,17 +3064,21 @@ export default function prettyTui(pi: ExtensionAPI) {
2620
3064
 
2621
3065
  const restoreCleanSession = (ctx: any) => {
2622
3066
  currentExtensionUi = ctx.ui;
3067
+ clearLatestActivityStatus();
2623
3068
  cleanToolsExpanded = ctx.ui.getToolsExpanded();
2624
3069
  settledSummaries.clear();
2625
3070
  legacySummaryLastToolCallIds.clear();
2626
3071
  activityTimeline.clear();
3072
+ responseAnswerTexts.clear();
3073
+ responseRunStartedAt = undefined;
3074
+ responseHasAssistantMessage = false;
3075
+ nativeCustomBoundaryKeys.clear();
2627
3076
  revealedActivityGroups.clear();
2628
3077
  expandedThinkingMembers.clear();
2629
3078
  thinkingComponents.clear();
2630
3079
  activityFallbackThemes.clear();
2631
3080
  activityUpdateComponents.clear();
2632
- customUpdateToolHints.clear();
2633
- customUpdateBeforeToolHints.clear();
3081
+ activityUpdateMarkdownComponents.clear();
2634
3082
  activityUpdateSequence = 0;
2635
3083
  assistantBoundaryKeys.clear();
2636
3084
  knownToolCallIds.clear();
@@ -2847,8 +3295,10 @@ export default function prettyTui(pi: ExtensionAPI) {
2847
3295
  }
2848
3296
 
2849
3297
  if (message.role === "assistant") {
3298
+ const answerText = assistantAnswerText(message);
3299
+ if (answerText) responseAnswerTexts.set(entry.id, answerText);
2850
3300
  const thinking = thinkingText(message);
2851
- if (thinking && !assistantSystemBoundary(message)) {
3301
+ if (thinking) {
2852
3302
  activityTimeline.addThinking(assistantMessageKey(message), thinking);
2853
3303
  }
2854
3304
  if (messageHasVisibleText(message)) finishGroup();
@@ -2857,7 +3307,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2857
3307
  toolCalls.add(item.id);
2858
3308
  activityTimeline.addTool(item.id, item.name ?? "tool");
2859
3309
  }
2860
- if (assistantSystemBoundary(message)) finishGroup();
3310
+ if (assistantTerminalState(message)) finishGroup();
2861
3311
  continue;
2862
3312
  }
2863
3313
 
@@ -2924,8 +3374,16 @@ export default function prettyTui(pi: ExtensionAPI) {
2924
3374
  (item: any) => item.type === "toolCall" && item.id,
2925
3375
  );
2926
3376
 
3377
+ const conciseActivityText = (value: string): string =>
3378
+ value
3379
+ .split("\n")
3380
+ .map((line) => line.trim())
3381
+ .find(Boolean)
3382
+ ?.replace(/^#{1,6}\s+/u, "")
3383
+ .replace(/^\*\*(.*?)\*\*$/u, "$1") ?? "";
3384
+
2927
3385
  const trackThinkingActivity = (message: any): void => {
2928
- if (message?.role !== "assistant" || assistantSystemBoundary(message)) return;
3386
+ if (message?.role !== "assistant") return;
2929
3387
  const thinking = thinkingText(message);
2930
3388
  if (thinking) activityTimeline.addThinking(assistantMessageKey(message), thinking);
2931
3389
  };
@@ -2984,16 +3442,12 @@ export default function prettyTui(pi: ExtensionAPI) {
2984
3442
  closeAtAssistantBoundary(event.message, "done");
2985
3443
  return;
2986
3444
  }
2987
- if (assistantSystemBoundary(event.message)) {
2988
- closeAtAssistantBoundary(event.message, "done");
2989
- settleLastCleanGroup();
2990
- return;
2991
- }
2992
3445
  trackThinkingActivity(event.message);
2993
3446
  trackPendingToolActivity(event.message);
2994
3447
  });
2995
3448
  pi.on("message_end", (event) => {
2996
- if (hasVisibleAssistantText(event.message) || assistantSystemBoundary(event.message)) {
3449
+ if (event.message?.role === "assistant") responseHasAssistantMessage = true;
3450
+ if (hasVisibleAssistantText(event.message)) {
2997
3451
  trackThinkingActivity(event.message);
2998
3452
  closeAtAssistantBoundary(event.message, "done");
2999
3453
  cleanRun.activity = "done";
@@ -3002,6 +3456,12 @@ export default function prettyTui(pi: ExtensionAPI) {
3002
3456
  }
3003
3457
  trackThinkingActivity(event.message);
3004
3458
  trackPendingToolActivity(event.message);
3459
+ if (assistantTerminalState(event.message)) {
3460
+ clearToolActivityHolds();
3461
+ finishCleanGroup("done");
3462
+ settleLastCleanGroup();
3463
+ cleanRun.activity = "done";
3464
+ }
3005
3465
  });
3006
3466
 
3007
3467
  pi.on("agent_start", () => {
@@ -3009,6 +3469,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3009
3469
  // count until the whole run reaches agent_settled.
3010
3470
  if (!cleanRun.active) {
3011
3471
  clearToolActivityHolds();
3472
+ responseRunStartedAt = Date.now();
3012
3473
  cleanRun.count = 0;
3013
3474
  cleanRun.failed = 0;
3014
3475
  cleanRun.currentToolCallIds = [];
@@ -3017,6 +3478,9 @@ export default function prettyTui(pi: ExtensionAPI) {
3017
3478
  cleanRun.lastCompletedToolCallId = undefined;
3018
3479
  cleanRun.settled = false;
3019
3480
  }
3481
+ // Retries share the original start time but only the newest low-level
3482
+ // run may supply a candidate final answer.
3483
+ responseHasAssistantMessage = false;
3020
3484
  cleanRun.active = true;
3021
3485
  cleanRun.activeToolCallIds.clear();
3022
3486
  cleanRun.activeToolCallId = undefined;
@@ -3058,6 +3522,8 @@ export default function prettyTui(pi: ExtensionAPI) {
3058
3522
  }
3059
3523
  const visibleSummaryToolCallId = cleanRun.activeToolCallId ?? cleanRun.lastCompletedToolCallId;
3060
3524
  setCleanGroupMembers(visibleSummaryToolCallId, cleanRun.currentToolCallIds.slice());
3525
+ // Tool success and failure remain owned by the activity group. The bottom
3526
+ // line is reserved for transient UI status messages.
3061
3527
  });
3062
3528
  pi.on("agent_end", () => {
3063
3529
  // No summary is appended here: this event may be followed by an automatic
@@ -3068,7 +3534,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3068
3534
  cleanRun.activeToolName = undefined;
3069
3535
  cleanRun.activity = "thinking";
3070
3536
  });
3071
- pi.on("agent_settled", () => {
3537
+ pi.on("agent_settled", (_event, ctx) => {
3072
3538
  if (cleanRun.settled) return;
3073
3539
 
3074
3540
  clearPendingToolActivities();
@@ -3090,6 +3556,30 @@ export default function prettyTui(pi: ExtensionAPI) {
3090
3556
  cleanRun.activity = "done";
3091
3557
  cleanRun.settled = true;
3092
3558
 
3559
+ if (responseRunStartedAt !== undefined) {
3560
+ // message_end runs before later extensions can replace the message and
3561
+ // before Pi persists it. Read the committed branch at the final boundary
3562
+ // so Copy matches the transcript even when another extension redacts it.
3563
+ const finalEntry = responseHasAssistantMessage
3564
+ ? ctx.sessionManager.getBranch().reverse().find(
3565
+ (entry) => entry.type === "message" && entry.message.role === "assistant",
3566
+ )
3567
+ : undefined;
3568
+ const answer = finalEntry && finalEntry.type === "message"
3569
+ ? assistantAnswerText(finalEntry.message)
3570
+ : "";
3571
+ if (answer && finalEntry) responseAnswerTexts.set(finalEntry.id, answer);
3572
+ pi.appendEntry<ResponseFooterData>(RESPONSE_FOOTER_ENTRY_TYPE, {
3573
+ answerEntryId: answer ? finalEntry?.id : undefined,
3574
+ durationMs: Math.max(0, Date.now() - responseRunStartedAt),
3575
+ outcome: finalEntry?.type === "message" && assistantTerminalState(finalEntry.message)
3576
+ ? "stopped"
3577
+ : "completed",
3578
+ });
3579
+ responseRunStartedAt = undefined;
3580
+ responseHasAssistantMessage = false;
3581
+ }
3582
+
3093
3583
  const groups = cleanRun.groups.slice();
3094
3584
  if (renderMode !== "clean" || groups.length === 0) return;
3095
3585
 
@@ -3129,7 +3619,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3129
3619
  if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
3130
3620
  if (options.isPartial) return partialResult(context, theme, "Reading…");
3131
3621
  const image = toolResult.content?.find((item: any) => item.type === "image");
3132
- const failed = completeStatus(context, output);
3622
+ const failed = completeStatus(context);
3133
3623
  if (image) return result(theme, "Read image", "", false, failed);
3134
3624
  const lines = nonEmptyLines(output);
3135
3625
  const truncated = toolResult.details?.truncation?.truncated ? " · truncated" : "";
@@ -3167,7 +3657,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3167
3657
  if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
3168
3658
  if (useCompactToolView(context.toolCallId, options.expanded)) {
3169
3659
  if (options.isPartial) return partialResult(context, theme, "Running…");
3170
- const failed = completeStatus(context, output);
3660
+ const failed = completeStatus(context);
3171
3661
  return result(theme, failed ? "Command failed" : "Done", "", false, failed);
3172
3662
  }
3173
3663
 
@@ -3175,7 +3665,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3175
3665
  return bashResult(context, theme, "Running…", output, options.expanded, "running");
3176
3666
  }
3177
3667
 
3178
- const failed = isError(context, output);
3668
+ const failed = isError(context);
3179
3669
  const outputLines = terminalOutputLines(output);
3180
3670
  const lineCount = outputLines.filter((line) => line.length > 0).length;
3181
3671
  const summary = failed
@@ -3208,7 +3698,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3208
3698
  const output = textContent(toolResult);
3209
3699
  if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
3210
3700
  if (options.isPartial) return partialResult(context, theme, "Editing…");
3211
- const failed = completeStatus(context, output);
3701
+ const failed = completeStatus(context);
3212
3702
  const diff = toolResult.details?.diff ?? "";
3213
3703
  const additions = diff.split("\n").filter((line: string) => line.startsWith("+") && !line.startsWith("+++")).length;
3214
3704
  const removals = diff.split("\n").filter((line: string) => line.startsWith("-") && !line.startsWith("---")).length;
@@ -3257,7 +3747,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3257
3747
  const output = textContent(toolResult);
3258
3748
  if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
3259
3749
  if (options.isPartial) return partialResult(context, theme, "Writing…");
3260
- const failed = completeStatus(context, output);
3750
+ const failed = completeStatus(context);
3261
3751
  return result(
3262
3752
  theme,
3263
3753
  failed ? output.split("\n")[0] || "Write failed" : "Written",
@@ -3283,7 +3773,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3283
3773
  const output = textContent(toolResult);
3284
3774
  if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
3285
3775
  if (options.isPartial) return partialResult(context, theme, "Searching…");
3286
- const failed = completeStatus(context, output);
3776
+ const failed = completeStatus(context);
3287
3777
  const matches = nonEmptyLines(output);
3288
3778
  return result(theme, failed ? output.split("\n")[0] : `Found ${matches} matches`, output, options.expanded, failed);
3289
3779
  },
@@ -3302,7 +3792,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3302
3792
  const output = textContent(toolResult);
3303
3793
  if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
3304
3794
  if (options.isPartial) return partialResult(context, theme, "Searching…");
3305
- const failed = completeStatus(context, output);
3795
+ const failed = completeStatus(context);
3306
3796
  const matches = nonEmptyLines(output);
3307
3797
  return result(theme, failed ? output.split("\n")[0] : `Found ${matches} paths`, output, options.expanded, failed);
3308
3798
  },
@@ -3321,7 +3811,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3321
3811
  const output = textContent(toolResult);
3322
3812
  if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
3323
3813
  if (options.isPartial) return partialResult(context, theme, "Listing…");
3324
- const failed = completeStatus(context, output);
3814
+ const failed = completeStatus(context);
3325
3815
  const entries = nonEmptyLines(output);
3326
3816
  return result(theme, failed ? output.split("\n")[0] : `Listed ${entries} entries`, output, options.expanded, failed);
3327
3817
  },