pi-pretty-tui 0.2.0 → 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.
@@ -32,15 +32,17 @@ 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;
44
46
  const SPECIALIZED_TOOL_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]);
45
47
 
46
48
  const stripAnsiBackgrounds = (value: string): string =>
@@ -95,14 +97,88 @@ export default function prettyTui(pi: ExtensionAPI) {
95
97
  let currentTui: any;
96
98
  let currentExtensionUi: any;
97
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
+ };
98
171
  const activityTimeline = new ActivityTimeline();
99
172
  const revealedActivityGroups = new Set<string>();
100
173
  const expandedThinkingMembers = new Set<string>();
101
174
  const thinkingComponents = new Map<string, any>();
102
175
  const activityFallbackThemes = new Map<string, any>();
103
176
  const activityUpdateComponents = new Map<string, Component>();
104
- const customUpdateToolHints = new Map<string, string>();
105
- const customUpdateBeforeToolHints = new Set<string>();
177
+ const activityUpdateMarkdownComponents = new Map<string, {
178
+ content: string;
179
+ theme: any;
180
+ component: Markdown;
181
+ }>();
106
182
  let activityUpdateSequence = 0;
107
183
  const assistantBoundaryKeys = new Set<string>();
108
184
  const cleanCompactToolCallIds = new Set<string>();
@@ -113,6 +189,20 @@ export default function prettyTui(pi: ExtensionAPI) {
113
189
  const cleanToolNames = new Map<string, string>();
114
190
  let renderCleanGroupSummary = (_lastToolCallId: string, _width: number): string[] => [];
115
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
+
116
206
  const setCleanGroupMembers = (lastToolCallId: string, toolCallIds: string[]) => {
117
207
  cleanGroupToolCallIds.set(lastToolCallId, toolCallIds);
118
208
  for (const toolCallId of toolCallIds) cleanToolCallGroupOwners.set(toolCallId, lastToolCallId);
@@ -167,11 +257,17 @@ export default function prettyTui(pi: ExtensionAPI) {
167
257
  };
168
258
 
169
259
  const defaultActivityTheme = () => {
260
+ if (currentExtensionUi?.theme?.fg) return currentExtensionUi.theme;
170
261
  const markdownTheme = getMarkdownTheme();
171
262
  return {
172
263
  fg: (color: string, text: string) => {
173
264
  if (color === "accent") return markdownTheme.listBullet(text);
174
- if (color === "success" || color === "syntaxComment") return markdownTheme.codeBlock(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);
175
271
  if (color === "thinkingLow") return markdownTheme.link(text);
176
272
  if (color === "dim" || color === "muted" || color === "thinkingText") {
177
273
  return markdownTheme.quote(text);
@@ -204,6 +300,12 @@ export default function prettyTui(pi: ExtensionAPI) {
204
300
  const activityGroupRevealed = (group: ActivityGroup): boolean =>
205
301
  cleanToolsExpanded || revealedActivityGroups.has(group.id);
206
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
+
207
309
  const activityMemberPosition = (group: ActivityGroup, member: ActivityMember) => ({
208
310
  first: group.members[0]?.id === member.id,
209
311
  last: group.members[group.members.length - 1]?.id === member.id,
@@ -238,6 +340,50 @@ export default function prettyTui(pi: ExtensionAPI) {
238
340
  .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
239
341
  .join(" ") || "Update";
240
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
+
241
387
  const renderActivityUpdate = (
242
388
  group: ActivityGroup,
243
389
  member: ActivityMember,
@@ -247,10 +393,11 @@ export default function prettyTui(pi: ExtensionAPI) {
247
393
  if (renderMode !== "clean" || !activityGroupRevealed(group)) return [];
248
394
  const { prefix, continuation, childWidth } = activityTreeStyle(group, member, width, theme);
249
395
  const title = truncateToWidth(member.updateTitle ?? "Update", Math.max(1, childWidth - 2), "…");
250
- const lines = [theme.fg("accent", "◇ ") + theme.fg("toolTitle", theme.bold(title))];
396
+ const style = activityUpdateStyle(member, theme);
397
+ const lines = [style.icon + style.title(title)];
251
398
  if (member.updateContent) {
252
399
  const detailWidth = Math.max(1, childWidth - visibleWidth(" │ "));
253
- const detailLines = wrapTextWithAnsi(theme.fg("muted", member.updateContent), detailWidth);
400
+ const detailLines = renderActivityUpdateMarkdown(member, detailWidth, theme);
254
401
  lines.push(...detailLines.map((line, index) =>
255
402
  theme.fg("dim", index === detailLines.length - 1 ? " └ " : " │ ") + line
256
403
  ));
@@ -267,10 +414,11 @@ export default function prettyTui(pi: ExtensionAPI) {
267
414
  const renderPendingActivityUpdate = (member: ActivityMember, width: number): string[] => {
268
415
  const theme = defaultActivityTheme();
269
416
  const title = truncateToWidth(member.updateTitle ?? "Update", Math.max(1, width - 2), "…");
270
- const lines = [theme.fg("accent", "◇ ") + theme.fg("toolTitle", theme.bold(title))];
417
+ const style = activityUpdateStyle(member, theme);
418
+ const lines = [style.icon + style.title(title)];
271
419
  if (member.updateContent) {
272
420
  const detailWidth = Math.max(1, width - visibleWidth(" │ "));
273
- const details = wrapTextWithAnsi(theme.fg("muted", member.updateContent), detailWidth);
421
+ const details = renderActivityUpdateMarkdown(member, detailWidth, theme);
274
422
  lines.push(...details.map((line, index) =>
275
423
  theme.fg("dim", index === details.length - 1 ? " └ " : " │ ") + line
276
424
  ));
@@ -278,13 +426,28 @@ export default function prettyTui(pi: ExtensionAPI) {
278
426
  return ["", ...lines.map((line) => truncateToWidth(line, Math.max(1, width), ""))];
279
427
  };
280
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
+
281
444
  const handleActivityUpdateMouse = (
282
445
  group: ActivityGroup,
283
446
  member: ActivityMember,
284
447
  event: any,
285
448
  ) => {
286
449
  const isLeftClick = event.type === "click" && event.button === "left";
287
- if (!isLeftClick || group.toolCallIds.length === 0) return undefined;
450
+ if (!isLeftClick || !activityGroupCollapsible(group)) return undefined;
288
451
  const position = activityMemberPosition(group, member);
289
452
  if (!activityGroupRevealed(group)) {
290
453
  if (!position.first) return undefined;
@@ -306,6 +469,8 @@ export default function prettyTui(pi: ExtensionAPI) {
306
469
  cleanToolComponents.get(child.toolCallId)?.updateDisplay?.();
307
470
  } else if (child.kind === "thinking" && child.messageKey) {
308
471
  thinkingComponents.get(child.messageKey)?.invalidate?.();
472
+ } else if (child.kind === "update") {
473
+ activityUpdateComponents.get(child.id)?.invalidate?.();
309
474
  }
310
475
  }
311
476
  currentTui?.requestRender?.();
@@ -399,6 +564,8 @@ export default function prettyTui(pi: ExtensionAPI) {
399
564
  revealedActivityGroups.clear();
400
565
  expandedThinkingMembers.clear();
401
566
  renderMode = requested;
567
+ if (renderMode === "clean") refreshLatestActivityWidget();
568
+ else clearLatestActivityStatus();
402
569
  for (const component of cleanToolComponents.values()) component.updateDisplay?.();
403
570
  for (const component of thinkingComponents.values()) component.invalidate?.();
404
571
  try {
@@ -415,20 +582,6 @@ export default function prettyTui(pi: ExtensionAPI) {
415
582
  // render patches or override built-in tools until the next enabled reload.
416
583
  if (!activeForSession) return;
417
584
 
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
585
  // Give Pi's main prompt editor a complete rounded frame. Render the native
433
586
  // editor at a two-column narrower width so cursor layout, wrapping, IME, and
434
587
  // autocomplete remain native, then add one themed border column per side.
@@ -615,14 +768,31 @@ export default function prettyTui(pi: ExtensionAPI) {
615
768
  .join("\n")
616
769
  : "";
617
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
+
618
777
  const addPersistentActivityUpdate = (message: any): ActivityMember => {
619
778
  const key = customMessageKey(message);
620
779
  const title = humanizeCustomType(message?.customType ?? "update");
621
780
  const content = customMessageText(message);
622
- return activityTimeline.addUpdate(key, title, content, true) ??
623
- activityTimeline.addPendingUpdate(key, title, content, true);
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;
624
791
  };
625
792
 
793
+ const nativeCustomBoundaryKeys = new Set<string>();
794
+ let closeCleanAtNativeCustomBoundary = () => activityTimeline.boundary();
795
+ let closeCleanAtStandaloneAlert = () => activityTimeline.boundary();
626
796
  const customMessagePrototype = CustomMessageComponent.prototype as any;
627
797
  const customMessagePatchKey = Symbol.for("pretty-tui.clean-custom-message");
628
798
  if (!customMessagePrototype[customMessagePatchKey]) {
@@ -631,9 +801,13 @@ export default function prettyTui(pi: ExtensionAPI) {
631
801
  const releaseSemanticCustomMessage = (component: any): boolean => {
632
802
  if (!component.customRenderer) return false;
633
803
  const key = customMessageKey(component.message);
634
- const member = activityTimeline.memberForUpdate(key);
635
- if (member) activityUpdateComponents.delete(member.id);
636
- activityTimeline.removeUpdate(key);
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
+ }
637
811
  return true;
638
812
  };
639
813
  const patchedCustomRender = function (this: any, width: number): string[] {
@@ -643,37 +817,19 @@ export default function prettyTui(pi: ExtensionAPI) {
643
817
  const message = this.message;
644
818
  const key = customMessageKey(message);
645
819
  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
- }
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);
663
826
  }
664
827
  const group = member ? activityTimeline.groupForMember(member.id) : undefined;
665
828
  if (renderMode !== "clean" || !member || !group) {
666
829
  return originalCustomRender.call(this, width);
667
830
  }
668
831
  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);
832
+ return renderActivityUpdateProjection(group, member, width);
677
833
  };
678
834
  const patchedCustomHandleMouse = function (this: any, event: any) {
679
835
  if (releaseSemanticCustomMessage(this)) {
@@ -684,7 +840,6 @@ export default function prettyTui(pi: ExtensionAPI) {
684
840
  if (renderMode !== "clean" || !member || !group) {
685
841
  return originalCustomHandleMouse?.call(this, event);
686
842
  }
687
- if (group.toolCallIds.length === 0) return undefined;
688
843
  return handleActivityUpdateMouse(group, member, event);
689
844
  };
690
845
  customMessagePrototype[customMessagePatchKey] = {
@@ -728,14 +883,38 @@ export default function prettyTui(pi: ExtensionAPI) {
728
883
  const cleanThinkingRenderCacheKey = Symbol("pretty-tui.clean-thinking-render-cache");
729
884
  const cleanThinkingDetailKey = Symbol("pretty-tui.clean-thinking-detail");
730
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
+
731
911
  const eligibleThinking = (message: any) =>
732
912
  Array.isArray(message?.content) &&
733
913
  message.content.some(
734
914
  (item: any) => item?.type === "thinking" &&
735
915
  typeof item.thinking === "string" &&
736
916
  item.thinking.length > 0,
737
- ) &&
738
- !assistantSystemBoundary(message);
917
+ );
739
918
 
740
919
  const refreshGroup = (group: ActivityGroup) => {
741
920
  for (const member of group.members) {
@@ -743,11 +922,41 @@ export default function prettyTui(pi: ExtensionAPI) {
743
922
  cleanToolComponents.get(member.toolCallId)?.updateDisplay?.();
744
923
  } else if (member.kind === "thinking" && member.messageKey) {
745
924
  thinkingComponents.get(member.messageKey)?.invalidate?.();
925
+ } else if (member.kind === "update") {
926
+ activityUpdateComponents.get(member.id)?.invalidate?.();
746
927
  }
747
928
  }
748
929
  currentTui?.requestRender?.();
749
930
  };
750
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
+ };
959
+
751
960
  const patchedUpdateContent = function (this: any, message: any, isStreaming = this.isStreaming) {
752
961
  this[originalMessageKey] = message;
753
962
  this[renderedModeKey] = renderMode;
@@ -790,8 +999,14 @@ export default function prettyTui(pi: ExtensionAPI) {
790
999
  ) {
791
1000
  patchedUpdateContent.call(this, message, this.isStreaming);
792
1001
  }
793
- if (renderMode !== "clean" || !eligibleThinking(message)) {
794
- return originalRender.call(this, width);
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);
795
1010
  }
796
1011
 
797
1012
  const key = assistantMessageKey(message);
@@ -803,25 +1018,29 @@ export default function prettyTui(pi: ExtensionAPI) {
803
1018
  // assistant text is never lost.
804
1019
  return originalRender.call(this, width);
805
1020
  }
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
1021
  thinkingComponents.set(key, this);
814
1022
  if (!activityFallbackThemes.has(group.id)) {
815
1023
  activityFallbackThemes.set(group.id, defaultActivityTheme());
816
1024
  }
817
1025
  const position = activityMemberPosition(group, member);
818
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
+ ];
819
1038
  const revealed = activityGroupRevealed(group);
820
1039
  if (!revealed) {
821
1040
  const activityLines = position.first
822
1041
  ? ["", ...renderActivityGroupSummary(group, width, true)]
823
1042
  : [];
824
- return [...activityLines, ...visibleLines];
1043
+ return appendTail(activityLines);
825
1044
  }
826
1045
 
827
1046
  const groupTheme = activityGroupTheme(group);
@@ -831,10 +1050,16 @@ export default function prettyTui(pi: ExtensionAPI) {
831
1050
  width,
832
1051
  groupTheme,
833
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.
834
1055
  const expanded = cleanToolsExpanded || expandedThinkingMembers.has(member.id);
835
1056
  const settledOwner = position.first ? activityGroupOwner(group) : undefined;
836
- const stableSummary = !position.first || Boolean(
837
- settledOwner && settledSummaries.has(settledOwner) && !toolActivityHolds.has(settledOwner),
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)
838
1063
  );
839
1064
  const cacheable = !expanded && !this.isStreaming && stableSummary;
840
1065
  const cached = cacheable ? this[cleanThinkingRenderCacheKey] : undefined;
@@ -848,7 +1073,7 @@ export default function prettyTui(pi: ExtensionAPI) {
848
1073
  // Only the stable activity projection is cached. Visible assistant
849
1074
  // text belongs outside the collapsible group and must be appended on
850
1075
  // every render, including cache hits.
851
- return [...cached.lines, ...visibleLines];
1076
+ return appendTail(cached.lines);
852
1077
  }
853
1078
  const stateLabel = this.isStreaming ? "thinking" : "thought";
854
1079
  const label = truncateToWidth(stateLabel, Math.max(1, childWidth - 2), "…");
@@ -856,28 +1081,11 @@ export default function prettyTui(pi: ExtensionAPI) {
856
1081
  groupTheme.fg("toolTitle", groupTheme.bold(label));
857
1082
  let contentLines: string[] = [header];
858
1083
  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
1084
+ contentLines.push(...thinkingDetailLines(
1085
+ this,
1086
+ member.thinking ?? thinkingText(message),
1087
+ groupTheme,
1088
+ Math.max(1, childWidth - visibleWidth(" │ ")),
881
1089
  ));
882
1090
  }
883
1091
  const decorated = contentLines.map((line, index) =>
@@ -896,7 +1104,7 @@ export default function prettyTui(pi: ExtensionAPI) {
896
1104
  lines: output,
897
1105
  };
898
1106
  }
899
- return [...output, ...visibleLines];
1107
+ return appendTail(output);
900
1108
  };
901
1109
 
902
1110
  const patchedRender = function (this: any, width: number): string[] {
@@ -905,14 +1113,20 @@ export default function prettyTui(pi: ExtensionAPI) {
905
1113
 
906
1114
  const handleAssistantTranscriptMouse = function (this: any, event: any) {
907
1115
  const message = this[originalMessageKey] ?? this.lastMessage;
908
- if (renderMode !== "clean" || !eligibleThinking(message)) {
909
- return originalHandleMouse.call(this, event);
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);
910
1125
  }
911
1126
  const key = assistantMessageKey(message);
912
1127
  const member = activityTimeline.memberForThinking(key);
913
1128
  const group = member ? activityTimeline.groupForMember(member.id) : undefined;
914
1129
  if (!member || !group) return originalHandleMouse.call(this, event);
915
- if (group.toolCallIds.length === 0) return originalHandleMouse.call(this, event);
916
1130
  const position = activityMemberPosition(group, member);
917
1131
  const isLeftClick = event.type === "click" && event.button === "left";
918
1132
 
@@ -1027,53 +1241,42 @@ export default function prettyTui(pi: ExtensionAPI) {
1027
1241
  ];
1028
1242
  };
1029
1243
 
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
- }
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;
1077
1280
  };
1078
1281
 
1079
1282
  const interactiveModePrototype = InteractiveMode.prototype as any;
@@ -1083,6 +1286,10 @@ export default function prettyTui(pi: ExtensionAPI) {
1083
1286
  const originalRenderSessionEntries = interactiveModePrototype.renderSessionEntries;
1084
1287
  const originalSwitchTuiMode = interactiveModePrototype.switchTuiMode;
1085
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;
1086
1293
  const patchedSetToolsExpanded = function (this: any, expanded: boolean) {
1087
1294
  cleanToolsExpanded = expanded;
1088
1295
  changingAllToolsExpansion = true;
@@ -1107,7 +1314,6 @@ export default function prettyTui(pi: ExtensionAPI) {
1107
1314
  // while Pi's live compaction UI appends it chronologically. Keep reloads
1108
1315
  // and transcript rebuilds consistent with that live presentation.
1109
1316
  const orderedEntries = orderContextEntriesForTranscript(entries);
1110
- indexDisplayedCustomUpdateHints(orderedEntries);
1111
1317
  return originalRenderSessionEntries.call(this, orderedEntries, options);
1112
1318
  };
1113
1319
 
@@ -1118,43 +1324,80 @@ export default function prettyTui(pi: ExtensionAPI) {
1118
1324
  return result;
1119
1325
  };
1120
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
+
1121
1352
  const patchedShowExtensionNotify = function (
1122
1353
  this: any,
1123
1354
  message: string,
1124
1355
  type?: "info" | "warning" | "error",
1125
1356
  ) {
1126
- if (renderMode !== "clean" || (type !== undefined && type !== "info")) {
1357
+ if (renderMode !== "clean") {
1127
1358
  return originalShowExtensionNotify.call(this, message, type);
1128
1359
  }
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);
1360
+ const severity: ActivitySeverity = type ?? "info";
1361
+ if (!appendRuntimeActivityUpdate(this, message, severity)) {
1362
+ nativeExtensionNotify.call(this, message, severity);
1138
1363
  }
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);
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
+ });
1155
1386
  this.ui?.requestRender?.();
1156
1387
  };
1157
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
+
1158
1401
  interactiveModePrototype[toolsExpansionPatchKey] = {
1159
1402
  originalSetToolsExpanded,
1160
1403
  patchedSetToolsExpanded,
@@ -1164,15 +1407,28 @@ export default function prettyTui(pi: ExtensionAPI) {
1164
1407
  patchedSwitchTuiMode,
1165
1408
  originalShowExtensionNotify,
1166
1409
  patchedShowExtensionNotify,
1410
+ originalToggleThinkingBlockVisibility,
1411
+ patchedToggleThinkingBlockVisibility,
1412
+ originalShowStatus,
1413
+ patchedShowStatus,
1414
+ originalShowWarning,
1415
+ patchedShowWarning,
1416
+ originalShowError,
1417
+ patchedShowError,
1167
1418
  };
1168
1419
  interactiveModePrototype.setToolsExpanded = patchedSetToolsExpanded;
1169
1420
  interactiveModePrototype.renderSessionEntries = patchedRenderSessionEntries;
1170
1421
  interactiveModePrototype.switchTuiMode = patchedSwitchTuiMode;
1171
1422
  interactiveModePrototype.showExtensionNotify = patchedShowExtensionNotify;
1423
+ interactiveModePrototype.toggleThinkingBlockVisibility = patchedToggleThinkingBlockVisibility;
1424
+ interactiveModePrototype.showStatus = patchedShowStatus;
1425
+ interactiveModePrototype.showWarning = patchedShowWarning;
1426
+ interactiveModePrototype.showError = patchedShowError;
1172
1427
 
1173
1428
  pi.on("session_shutdown", () => {
1174
1429
  fullscreenTui = false;
1175
1430
  currentTui = undefined;
1431
+ clearLatestActivityStatus();
1176
1432
  currentExtensionUi = undefined;
1177
1433
  const patch = interactiveModePrototype[toolsExpansionPatchKey];
1178
1434
  if (!patch) return;
@@ -1188,11 +1444,27 @@ export default function prettyTui(pi: ExtensionAPI) {
1188
1444
  if (patch.patchedShowExtensionNotify === interactiveModePrototype.showExtensionNotify) {
1189
1445
  interactiveModePrototype.showExtensionNotify = patch.originalShowExtensionNotify;
1190
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
+ }
1191
1459
  if (
1192
1460
  interactiveModePrototype.setToolsExpanded === patch.originalSetToolsExpanded &&
1193
1461
  interactiveModePrototype.renderSessionEntries === patch.originalRenderSessionEntries &&
1194
1462
  interactiveModePrototype.switchTuiMode === patch.originalSwitchTuiMode &&
1195
- interactiveModePrototype.showExtensionNotify === patch.originalShowExtensionNotify
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
1196
1468
  ) {
1197
1469
  delete interactiveModePrototype[toolsExpansionPatchKey];
1198
1470
  }
@@ -1527,8 +1799,14 @@ export default function prettyTui(pi: ExtensionAPI) {
1527
1799
  } else {
1528
1800
  // Defensive fallback for older Pi versions without the fullscreen helper.
1529
1801
  void copyToClipboard(region.code)
1530
- .then(() => currentExtensionUi?.notify("Copied!", "info"))
1531
- .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
+ }));
1532
1810
  }
1533
1811
  }
1534
1812
  return { handled: true, render: false };
@@ -1760,8 +2038,11 @@ export default function prettyTui(pi: ExtensionAPI) {
1760
2038
  return block(rows);
1761
2039
  };
1762
2040
 
1763
- const isError = (renderContext: any, output: string): boolean =>
1764
- 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);
1765
2046
 
1766
2047
  const partialResult = (context: any, theme: any, label: string): Component => {
1767
2048
  setStatus(context, "running");
@@ -1834,8 +2115,8 @@ export default function prettyTui(pi: ExtensionAPI) {
1834
2115
  return block(rows);
1835
2116
  };
1836
2117
 
1837
- const completeStatus = (context: any, output: string): boolean => {
1838
- const failed = isError(context, output);
2118
+ const completeStatus = (context: any): boolean => {
2119
+ const failed = isError(context);
1839
2120
  setStatus(context, failed ? "error" : "success");
1840
2121
  return failed;
1841
2122
  };
@@ -2351,6 +2632,15 @@ export default function prettyTui(pi: ExtensionAPI) {
2351
2632
  activityTimeline.boundary();
2352
2633
  };
2353
2634
 
2635
+ closeCleanAtNativeCustomBoundary = () => {
2636
+ finishCleanGroup("done");
2637
+ settleLastCleanGroup();
2638
+ };
2639
+ closeCleanAtStandaloneAlert = () => {
2640
+ finishCleanGroup("done");
2641
+ settleLastCleanGroup();
2642
+ };
2643
+
2354
2644
  const settleLastCleanGroup = () => {
2355
2645
  const group = cleanRun.groups[cleanRun.groups.length - 1];
2356
2646
  if (!group) return;
@@ -2374,14 +2664,16 @@ export default function prettyTui(pi: ExtensionAPI) {
2374
2664
  const activityText = /^thinking(?:\.\.\.)?$/iu.test(normalizedActivityText)
2375
2665
  ? "thinking"
2376
2666
  : rawActivityText;
2377
- const countLabel = `${count} tool ${count === 1 ? "call" : "calls"}`;
2667
+ const countLabel = count > 0 ? `${count} tool ${count === 1 ? "call" : "calls"}` : "";
2378
2668
  const thoughtLabel = thoughtCount > 0
2379
- ? ` · ${thoughtCount} ${thoughtCount === 1 ? "thought" : "thoughts"}`
2669
+ ? `${thoughtCount} ${thoughtCount === 1 ? "thought" : "thoughts"}`
2380
2670
  : "";
2381
- const activityLabel = activityText !== "done" && activityText.trim()
2382
- ? ` · ${activityText}`
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
2383
2675
  : "";
2384
- return `${countLabel}${thoughtLabel}${activityLabel}`;
2676
+ return [countLabel, thoughtLabel, activityLabel].filter(Boolean).join(" · ");
2385
2677
  };
2386
2678
 
2387
2679
  const summaryRow = (
@@ -2455,36 +2747,53 @@ export default function prettyTui(pi: ExtensionAPI) {
2455
2747
  ): string[] => {
2456
2748
  const fit = (lines: string[]) => lines.map((line) => truncateToWidth(line, Math.max(1, width), ""));
2457
2749
  const summaryTheme = activityGroupTheme(group);
2750
+ if (!summaryTheme || !activityTimeline.hasWork(group)) return [];
2458
2751
  const owner = activityGroupOwner(group);
2459
- if (!summaryTheme || !owner || group.toolCallIds.length === 0) return [];
2460
- const settled = settledSummaries.get(owner);
2752
+ const settled = owner ? settledSummaries.get(owner) : undefined;
2753
+ const toolCount = group.toolCallIds.length;
2754
+ const live = () => cleanRun.activeToolName ?? currentCleanActivity();
2461
2755
  if (settled) {
2462
2756
  return fit(block([summaryRow(
2463
2757
  summaryTheme,
2464
- Math.max(settled.count, group.toolCallIds.length),
2465
- settled.failed,
2758
+ group.hardBoundarySplit ? toolCount : Math.max(settled.count, toolCount),
2759
+ group.hardBoundarySplit ? 0 : settled.failed,
2466
2760
  Math.max(settled.thoughtCount, group.thoughtCount),
2467
2761
  settled.activity,
2468
2762
  collapsedDone,
2469
2763
  )]).render(width));
2470
2764
  }
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) {
2765
+ if (owner && !cleanRun.settled) {
2766
+ // Live tool group: keep the current tool name and the running count.
2478
2767
  return fit(block([summaryRow(
2479
2768
  summaryTheme,
2480
- Math.max(group.toolCallIds.length, liveCleanToolCount()),
2769
+ Math.max(toolCount, liveCleanToolCount()),
2481
2770
  cleanRun.failed,
2482
2771
  group.thoughtCount,
2483
- () => cleanRun.activeToolName ?? currentCleanActivity(),
2772
+ live,
2484
2773
  collapsedDone,
2485
2774
  )]).render(width));
2486
2775
  }
2487
- return [];
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));
2488
2797
  };
2489
2798
 
2490
2799
  /**
@@ -2552,7 +2861,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2552
2861
 
2553
2862
  const hiddenToolResult = (context: any, options: any, output: string): Component => {
2554
2863
  if (options.isPartial) setStatus(context, "running");
2555
- else completeStatus(context, output);
2864
+ else completeStatus(context);
2556
2865
  return block([]);
2557
2866
  };
2558
2867
 
@@ -2620,17 +2929,18 @@ export default function prettyTui(pi: ExtensionAPI) {
2620
2929
 
2621
2930
  const restoreCleanSession = (ctx: any) => {
2622
2931
  currentExtensionUi = ctx.ui;
2932
+ clearLatestActivityStatus();
2623
2933
  cleanToolsExpanded = ctx.ui.getToolsExpanded();
2624
2934
  settledSummaries.clear();
2625
2935
  legacySummaryLastToolCallIds.clear();
2626
2936
  activityTimeline.clear();
2937
+ nativeCustomBoundaryKeys.clear();
2627
2938
  revealedActivityGroups.clear();
2628
2939
  expandedThinkingMembers.clear();
2629
2940
  thinkingComponents.clear();
2630
2941
  activityFallbackThemes.clear();
2631
2942
  activityUpdateComponents.clear();
2632
- customUpdateToolHints.clear();
2633
- customUpdateBeforeToolHints.clear();
2943
+ activityUpdateMarkdownComponents.clear();
2634
2944
  activityUpdateSequence = 0;
2635
2945
  assistantBoundaryKeys.clear();
2636
2946
  knownToolCallIds.clear();
@@ -2848,7 +3158,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2848
3158
 
2849
3159
  if (message.role === "assistant") {
2850
3160
  const thinking = thinkingText(message);
2851
- if (thinking && !assistantSystemBoundary(message)) {
3161
+ if (thinking) {
2852
3162
  activityTimeline.addThinking(assistantMessageKey(message), thinking);
2853
3163
  }
2854
3164
  if (messageHasVisibleText(message)) finishGroup();
@@ -2857,7 +3167,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2857
3167
  toolCalls.add(item.id);
2858
3168
  activityTimeline.addTool(item.id, item.name ?? "tool");
2859
3169
  }
2860
- if (assistantSystemBoundary(message)) finishGroup();
3170
+ if (assistantTerminalState(message)) finishGroup();
2861
3171
  continue;
2862
3172
  }
2863
3173
 
@@ -2924,8 +3234,16 @@ export default function prettyTui(pi: ExtensionAPI) {
2924
3234
  (item: any) => item.type === "toolCall" && item.id,
2925
3235
  );
2926
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
+
2927
3245
  const trackThinkingActivity = (message: any): void => {
2928
- if (message?.role !== "assistant" || assistantSystemBoundary(message)) return;
3246
+ if (message?.role !== "assistant") return;
2929
3247
  const thinking = thinkingText(message);
2930
3248
  if (thinking) activityTimeline.addThinking(assistantMessageKey(message), thinking);
2931
3249
  };
@@ -2984,16 +3302,11 @@ export default function prettyTui(pi: ExtensionAPI) {
2984
3302
  closeAtAssistantBoundary(event.message, "done");
2985
3303
  return;
2986
3304
  }
2987
- if (assistantSystemBoundary(event.message)) {
2988
- closeAtAssistantBoundary(event.message, "done");
2989
- settleLastCleanGroup();
2990
- return;
2991
- }
2992
3305
  trackThinkingActivity(event.message);
2993
3306
  trackPendingToolActivity(event.message);
2994
3307
  });
2995
3308
  pi.on("message_end", (event) => {
2996
- if (hasVisibleAssistantText(event.message) || assistantSystemBoundary(event.message)) {
3309
+ if (hasVisibleAssistantText(event.message)) {
2997
3310
  trackThinkingActivity(event.message);
2998
3311
  closeAtAssistantBoundary(event.message, "done");
2999
3312
  cleanRun.activity = "done";
@@ -3002,6 +3315,12 @@ export default function prettyTui(pi: ExtensionAPI) {
3002
3315
  }
3003
3316
  trackThinkingActivity(event.message);
3004
3317
  trackPendingToolActivity(event.message);
3318
+ if (assistantTerminalState(event.message)) {
3319
+ clearToolActivityHolds();
3320
+ finishCleanGroup("done");
3321
+ settleLastCleanGroup();
3322
+ cleanRun.activity = "done";
3323
+ }
3005
3324
  });
3006
3325
 
3007
3326
  pi.on("agent_start", () => {
@@ -3058,6 +3377,8 @@ export default function prettyTui(pi: ExtensionAPI) {
3058
3377
  }
3059
3378
  const visibleSummaryToolCallId = cleanRun.activeToolCallId ?? cleanRun.lastCompletedToolCallId;
3060
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.
3061
3382
  });
3062
3383
  pi.on("agent_end", () => {
3063
3384
  // No summary is appended here: this event may be followed by an automatic
@@ -3129,7 +3450,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3129
3450
  if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
3130
3451
  if (options.isPartial) return partialResult(context, theme, "Reading…");
3131
3452
  const image = toolResult.content?.find((item: any) => item.type === "image");
3132
- const failed = completeStatus(context, output);
3453
+ const failed = completeStatus(context);
3133
3454
  if (image) return result(theme, "Read image", "", false, failed);
3134
3455
  const lines = nonEmptyLines(output);
3135
3456
  const truncated = toolResult.details?.truncation?.truncated ? " · truncated" : "";
@@ -3167,7 +3488,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3167
3488
  if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
3168
3489
  if (useCompactToolView(context.toolCallId, options.expanded)) {
3169
3490
  if (options.isPartial) return partialResult(context, theme, "Running…");
3170
- const failed = completeStatus(context, output);
3491
+ const failed = completeStatus(context);
3171
3492
  return result(theme, failed ? "Command failed" : "Done", "", false, failed);
3172
3493
  }
3173
3494
 
@@ -3175,7 +3496,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3175
3496
  return bashResult(context, theme, "Running…", output, options.expanded, "running");
3176
3497
  }
3177
3498
 
3178
- const failed = isError(context, output);
3499
+ const failed = isError(context);
3179
3500
  const outputLines = terminalOutputLines(output);
3180
3501
  const lineCount = outputLines.filter((line) => line.length > 0).length;
3181
3502
  const summary = failed
@@ -3208,7 +3529,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3208
3529
  const output = textContent(toolResult);
3209
3530
  if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
3210
3531
  if (options.isPartial) return partialResult(context, theme, "Editing…");
3211
- const failed = completeStatus(context, output);
3532
+ const failed = completeStatus(context);
3212
3533
  const diff = toolResult.details?.diff ?? "";
3213
3534
  const additions = diff.split("\n").filter((line: string) => line.startsWith("+") && !line.startsWith("+++")).length;
3214
3535
  const removals = diff.split("\n").filter((line: string) => line.startsWith("-") && !line.startsWith("---")).length;
@@ -3257,7 +3578,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3257
3578
  const output = textContent(toolResult);
3258
3579
  if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
3259
3580
  if (options.isPartial) return partialResult(context, theme, "Writing…");
3260
- const failed = completeStatus(context, output);
3581
+ const failed = completeStatus(context);
3261
3582
  return result(
3262
3583
  theme,
3263
3584
  failed ? output.split("\n")[0] || "Write failed" : "Written",
@@ -3283,7 +3604,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3283
3604
  const output = textContent(toolResult);
3284
3605
  if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
3285
3606
  if (options.isPartial) return partialResult(context, theme, "Searching…");
3286
- const failed = completeStatus(context, output);
3607
+ const failed = completeStatus(context);
3287
3608
  const matches = nonEmptyLines(output);
3288
3609
  return result(theme, failed ? output.split("\n")[0] : `Found ${matches} matches`, output, options.expanded, failed);
3289
3610
  },
@@ -3302,7 +3623,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3302
3623
  const output = textContent(toolResult);
3303
3624
  if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
3304
3625
  if (options.isPartial) return partialResult(context, theme, "Searching…");
3305
- const failed = completeStatus(context, output);
3626
+ const failed = completeStatus(context);
3306
3627
  const matches = nonEmptyLines(output);
3307
3628
  return result(theme, failed ? output.split("\n")[0] : `Found ${matches} paths`, output, options.expanded, failed);
3308
3629
  },
@@ -3321,7 +3642,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3321
3642
  const output = textContent(toolResult);
3322
3643
  if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
3323
3644
  if (options.isPartial) return partialResult(context, theme, "Listing…");
3324
- const failed = completeStatus(context, output);
3645
+ const failed = completeStatus(context);
3325
3646
  const entries = nonEmptyLines(output);
3326
3647
  return result(theme, failed ? output.split("\n")[0] : `Listed ${entries} entries`, output, options.expanded, failed);
3327
3648
  },