@oh-my-pi/pi-coding-agent 16.0.10 → 16.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/cli.js +3344 -3371
  3. package/dist/types/advisor/index.d.ts +1 -0
  4. package/dist/types/advisor/transcript-recorder.d.ts +52 -0
  5. package/dist/types/commit/agentic/agent.d.ts +1 -1
  6. package/dist/types/config/settings-schema.d.ts +14 -8
  7. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  8. package/dist/types/extensibility/extensions/types.d.ts +7 -0
  9. package/dist/types/modes/components/__tests__/skill-message.test.d.ts +1 -0
  10. package/dist/types/modes/components/agent-hub.d.ts +6 -1
  11. package/dist/types/modes/components/agent-transcript-viewer.d.ts +39 -0
  12. package/dist/types/modes/components/assistant-message.d.ts +8 -0
  13. package/dist/types/modes/components/cache-invalidation-marker.d.ts +34 -0
  14. package/dist/types/modes/components/chat-transcript-builder.d.ts +42 -0
  15. package/dist/types/modes/components/compaction-summary-message.d.ts +14 -1
  16. package/dist/types/modes/components/index.d.ts +0 -1
  17. package/dist/types/modes/components/message-frame.d.ts +6 -4
  18. package/dist/types/modes/controllers/command-controller.d.ts +3 -2
  19. package/dist/types/modes/interactive-mode.d.ts +4 -2
  20. package/dist/types/modes/theme/theme.d.ts +7 -1
  21. package/dist/types/modes/types.d.ts +9 -2
  22. package/dist/types/registry/agent-registry.d.ts +10 -3
  23. package/dist/types/sdk.d.ts +1 -1
  24. package/dist/types/session/agent-session.d.ts +20 -1
  25. package/dist/types/session/compact-modes.d.ts +60 -0
  26. package/dist/types/session/session-context.d.ts +7 -0
  27. package/dist/types/session/session-dump-format.d.ts +1 -0
  28. package/dist/types/session/streaming-output.d.ts +0 -2
  29. package/dist/types/session/tool-choice-queue.d.ts +14 -0
  30. package/dist/types/system-prompt.d.ts +3 -3
  31. package/dist/types/tools/__tests__/json-tree.test.d.ts +1 -0
  32. package/dist/types/tools/index.d.ts +4 -0
  33. package/dist/types/tools/resolve.d.ts +15 -5
  34. package/package.json +12 -12
  35. package/src/advisor/index.ts +1 -0
  36. package/src/advisor/transcript-recorder.ts +136 -0
  37. package/src/cli/stats-cli.ts +2 -11
  38. package/src/collab/host.ts +25 -13
  39. package/src/commit/agentic/agent.ts +2 -1
  40. package/src/commit/agentic/tools/git-file-diff.ts +2 -2
  41. package/src/commit/changelog/index.ts +1 -1
  42. package/src/commit/map-reduce/map-phase.ts +1 -1
  43. package/src/commit/map-reduce/utils.ts +1 -1
  44. package/src/config/settings-schema.ts +16 -9
  45. package/src/config/settings.ts +0 -6
  46. package/src/debug/log-viewer.ts +4 -4
  47. package/src/debug/raw-sse.ts +4 -4
  48. package/src/edit/file-snapshot-store.ts +1 -1
  49. package/src/edit/renderer.ts +9 -9
  50. package/src/eval/js/tool-bridge.ts +3 -2
  51. package/src/eval/py/prelude.py +3 -2
  52. package/src/export/html/tool-views.generated.js +28 -28
  53. package/src/extensibility/extensions/types.ts +7 -0
  54. package/src/hindsight/mental-models.ts +1 -1
  55. package/src/internal-urls/docs-index.generated.txt +1 -1
  56. package/src/internal-urls/history-protocol.ts +8 -3
  57. package/src/irc/bus.ts +8 -0
  58. package/src/lsp/index.ts +2 -2
  59. package/src/lsp/render.ts +7 -7
  60. package/src/main.ts +4 -1
  61. package/src/modes/acp/acp-agent.ts +63 -0
  62. package/src/modes/components/__tests__/skill-message.test.ts +92 -0
  63. package/src/modes/components/agent-dashboard.ts +1 -1
  64. package/src/modes/components/agent-hub.ts +97 -920
  65. package/src/modes/components/agent-transcript-viewer.ts +461 -0
  66. package/src/modes/components/assistant-message.ts +21 -0
  67. package/src/modes/components/cache-invalidation-marker.ts +84 -0
  68. package/src/modes/components/chat-transcript-builder.ts +476 -0
  69. package/src/modes/components/compaction-summary-message.ts +29 -1
  70. package/src/modes/components/custom-message.ts +4 -1
  71. package/src/modes/components/diff.ts +12 -35
  72. package/src/modes/components/dynamic-border.ts +1 -1
  73. package/src/modes/components/extensions/extension-dashboard.ts +1 -1
  74. package/src/modes/components/extensions/inspector-panel.ts +5 -5
  75. package/src/modes/components/hook-selector.ts +2 -2
  76. package/src/modes/components/index.ts +0 -1
  77. package/src/modes/components/message-frame.ts +10 -6
  78. package/src/modes/components/model-selector.ts +2 -2
  79. package/src/modes/components/overlay-box.ts +10 -9
  80. package/src/modes/components/skill-message.ts +39 -19
  81. package/src/modes/components/tiny-title-download-progress.ts +1 -1
  82. package/src/modes/components/welcome.ts +1 -1
  83. package/src/modes/controllers/command-controller.ts +12 -2
  84. package/src/modes/controllers/event-controller.ts +15 -1
  85. package/src/modes/controllers/input-controller.ts +8 -1
  86. package/src/modes/controllers/selector-controller.ts +11 -1
  87. package/src/modes/interactive-mode.ts +13 -3
  88. package/src/modes/theme/theme.ts +14 -0
  89. package/src/modes/types.ts +9 -2
  90. package/src/modes/utils/ui-helpers.ts +20 -2
  91. package/src/prompts/steering/user-interjection.md +3 -4
  92. package/src/prompts/tools/read.md +1 -1
  93. package/src/registry/agent-registry.ts +13 -4
  94. package/src/sdk.ts +9 -7
  95. package/src/session/agent-session.ts +182 -16
  96. package/src/session/compact-modes.ts +105 -0
  97. package/src/session/messages.ts +7 -9
  98. package/src/session/session-context.ts +54 -7
  99. package/src/session/session-dump-format.ts +4 -2
  100. package/src/session/session-history-format.ts +1 -1
  101. package/src/session/snapcompact-inline.ts +2 -2
  102. package/src/session/streaming-output.ts +5 -5
  103. package/src/session/tool-choice-queue.ts +59 -0
  104. package/src/slash-commands/builtin-registry.ts +16 -4
  105. package/src/system-prompt.ts +10 -9
  106. package/src/task/executor.ts +1 -1
  107. package/src/task/output-manager.ts +5 -0
  108. package/src/tools/__tests__/json-tree.test.ts +35 -0
  109. package/src/tools/approval.ts +1 -1
  110. package/src/tools/bash-interactive.ts +4 -4
  111. package/src/tools/bash.ts +0 -1
  112. package/src/tools/browser.ts +0 -1
  113. package/src/tools/eval.ts +1 -1
  114. package/src/tools/gh.ts +1 -1
  115. package/src/tools/index.ts +4 -0
  116. package/src/tools/irc.ts +1 -1
  117. package/src/tools/json-tree.ts +22 -5
  118. package/src/tools/read.ts +5 -6
  119. package/src/tools/resolve.ts +66 -41
  120. package/src/tui/output-block.ts +9 -9
  121. package/src/web/scrapers/firefox-addons.ts +1 -1
  122. package/src/web/scrapers/github.ts +1 -1
  123. package/src/web/scrapers/go-pkg.ts +2 -2
  124. package/src/web/scrapers/metacpan.ts +2 -2
  125. package/src/web/scrapers/nvd.ts +2 -2
  126. package/src/web/scrapers/ollama.ts +1 -1
  127. package/src/web/scrapers/opencorporates.ts +1 -1
  128. package/src/web/scrapers/pub-dev.ts +1 -1
  129. package/src/web/scrapers/repology.ts +1 -1
  130. package/src/web/scrapers/sourcegraph.ts +1 -1
  131. package/src/web/scrapers/terraform.ts +6 -6
  132. package/src/web/scrapers/wikidata.ts +2 -2
  133. package/src/workspace-tree.ts +1 -1
  134. package/dist/types/modes/components/branch-summary-message.d.ts +0 -13
  135. package/src/modes/components/branch-summary-message.ts +0 -46
@@ -0,0 +1,476 @@
1
+ /**
2
+ * Builds transcript components from persisted session message entries — the
3
+ * file/remote-backed counterpart to {@link UiHelpers.addMessageToChat} (which is
4
+ * bound to the live InteractiveModeContext). Used by the fullscreen transcript
5
+ * viewer ({@link AgentTranscriptViewer}) to render a parked subagent / advisor /
6
+ * collab-guest transcript that has no live session.
7
+ *
8
+ * Unlike the old incremental hub sync, {@link ChatTranscriptBuilder.rebuild}
9
+ * always discards prior components and rebuilds the whole transcript from the
10
+ * supplied entries. Re-rendering a growing transcript is therefore O(n) in the
11
+ * entry count, but it cannot duplicate or misorder rows the way incremental
12
+ * component reuse could.
13
+ */
14
+ import type { AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core";
15
+ import type { Usage } from "@oh-my-pi/pi-ai";
16
+ import { Text, type TUI } from "@oh-my-pi/pi-tui";
17
+ import { formatBytes, formatDuration } from "@oh-my-pi/pi-utils";
18
+ import type { AdvisorMessageDetails } from "../../advisor";
19
+ import { COLLAB_PROMPT_MESSAGE_TYPE, type CollabPromptDetails } from "../../collab/protocol";
20
+ import { settings } from "../../config/settings";
21
+ import type { MessageRenderer } from "../../extensibility/extensions/types";
22
+ import {
23
+ BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE,
24
+ type CustomMessage,
25
+ isSilentAbort,
26
+ LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE,
27
+ resolveAbortLabel,
28
+ SKILL_PROMPT_MESSAGE_TYPE,
29
+ type SkillPromptDetails,
30
+ } from "../../session/messages";
31
+ import type { SessionMessageEntry } from "../../session/session-entries";
32
+ import { createIrcMessageCard } from "../../tools/irc";
33
+ import { canonicalizeMessage } from "../../utils/thinking-display";
34
+ import { theme } from "../theme/theme";
35
+ import { createAdvisorMessageCard } from "./advisor-message";
36
+ import { AssistantMessageComponent } from "./assistant-message";
37
+ import { createBackgroundTanDispatchBlock } from "./background-tan-message";
38
+ import { BashExecutionComponent } from "./bash-execution";
39
+ import { detectCacheInvalidation } from "./cache-invalidation-marker";
40
+ import { CollabPromptMessageComponent } from "./collab-prompt-message";
41
+ import {
42
+ BranchSummaryMessageComponent,
43
+ CompactionSummaryMessageComponent,
44
+ createHandoffSummaryMessageComponent,
45
+ } from "./compaction-summary-message";
46
+ import { CustomMessageComponent } from "./custom-message";
47
+ import { EvalExecutionComponent } from "./eval-execution";
48
+ import { type LateDiagnosticsFile, LateDiagnosticsMessageComponent } from "./late-diagnostics-message";
49
+ import { ReadToolGroupComponent, readArgsHaveTarget, readArgsTargetInternalUrl } from "./read-tool-group";
50
+ import { SkillMessageComponent } from "./skill-message";
51
+ import { ToolExecutionComponent } from "./tool-execution";
52
+ import { TranscriptBlock, TranscriptContainer } from "./transcript-container";
53
+ import { createUsageRowBlock } from "./usage-row";
54
+ import { UserMessageComponent } from "./user-message";
55
+
56
+ export interface ChatTranscriptBuilderDeps {
57
+ ui: TUI;
58
+ getTool?: (name: string) => AgentTool | undefined;
59
+ getMessageRenderer?: (customType: string) => MessageRenderer | undefined;
60
+ cwd: string;
61
+ hideThinkingBlock?: () => boolean;
62
+ requestRender: () => void;
63
+ }
64
+
65
+ /** Extracts the plain-text content of a user message (string or text blocks). */
66
+ function userMessageText(message: Extract<AgentMessage, { role: "user" }>): string {
67
+ if (typeof message.content === "string") return message.content;
68
+ return message.content
69
+ .filter((block): block is { type: "text"; text: string } => block.type === "text")
70
+ .map(block => block.text)
71
+ .join("");
72
+ }
73
+
74
+ export class ChatTranscriptBuilder {
75
+ readonly container = new TranscriptContainer();
76
+ #pendingTools = new Map<string, ToolExecutionComponent | ReadToolGroupComponent>();
77
+ #readArgs = new Map<string, Record<string, unknown>>();
78
+ #readGroup: ReadToolGroupComponent | null = null;
79
+ #pendingUsage: Usage | undefined;
80
+ #lastAssistantUsage: Usage | undefined;
81
+ #waitingPoll: ToolExecutionComponent | null = null;
82
+ #expandables: Array<{ setExpanded(expanded: boolean): void }> = [];
83
+ #expanded = false;
84
+
85
+ constructor(private readonly deps: ChatTranscriptBuilderDeps) {}
86
+
87
+ /** Whether the transcript currently holds any rendered rows. */
88
+ get isEmpty(): boolean {
89
+ return this.container.children.length === 0;
90
+ }
91
+
92
+ /** Discard all components and rebuild the whole transcript from `entries`. */
93
+ rebuild(entries: SessionMessageEntry[]): void {
94
+ this.reset();
95
+ for (const entry of entries) this.#appendChatMessage(entry.message);
96
+ // Flush the trailing turn's usage row only once its tools are materialized
97
+ // (a read whose result has not arrived stays pending); otherwise the row
98
+ // would sit above its tools. The drain happens here at the end of the pass.
99
+ if (this.#readArgs.size === 0 && this.#pendingTools.size === 0) this.#flushPendingUsage();
100
+ }
101
+
102
+ /** Toggle tool-output expansion across every expandable component. */
103
+ setExpanded(expanded: boolean): void {
104
+ this.#expanded = expanded;
105
+ for (const component of this.#expandables) component.setExpanded(expanded);
106
+ }
107
+
108
+ get expanded(): boolean {
109
+ return this.#expanded;
110
+ }
111
+
112
+ /** Tear down components (sealing pending spinners) and clear build state. */
113
+ reset(): void {
114
+ for (const pending of this.#pendingTools.values()) pending.seal();
115
+ this.#pendingTools.clear();
116
+ this.#readArgs.clear();
117
+ this.#readGroup = null;
118
+ this.#pendingUsage = undefined;
119
+ this.#lastAssistantUsage = undefined;
120
+ this.#waitingPoll = null;
121
+ this.#expandables = [];
122
+ this.container.dispose();
123
+ this.container.clear();
124
+ }
125
+
126
+ dispose(): void {
127
+ this.reset();
128
+ }
129
+
130
+ #trackExpandable(component: { setExpanded(expanded: boolean): void }): void {
131
+ component.setExpanded(this.#expanded);
132
+ this.#expandables.push(component);
133
+ }
134
+
135
+ /** A `job` poll showing all-running is displaced by the next `job` call. */
136
+ #resolveWaitingPoll(nextToolName?: string): void {
137
+ const previous = this.#waitingPoll;
138
+ if (!previous) return;
139
+ this.#waitingPoll = null;
140
+ if (nextToolName === "job" && previous.isDisplaceableBlock()) {
141
+ this.container.removeChild(previous);
142
+ }
143
+ previous.seal();
144
+ }
145
+
146
+ #ensureReadGroup(): ReadToolGroupComponent {
147
+ if (!this.#readGroup) {
148
+ this.#readGroup = new ReadToolGroupComponent({
149
+ showContentPreview: settings.get("read.toolResultPreview"),
150
+ });
151
+ this.#trackExpandable(this.#readGroup);
152
+ this.container.addChild(this.#readGroup);
153
+ }
154
+ return this.#readGroup;
155
+ }
156
+
157
+ // The per-turn token-usage row must land below the turn's tool blocks, but
158
+ // normal `read` calls only materialize their group in #appendToolResult. Defer
159
+ // the row: stash it on the assistant message and flush once the turn's tools
160
+ // are placed, sealing the read run so the row sits under it.
161
+ #flushPendingUsage(): void {
162
+ if (!this.#pendingUsage) return;
163
+ this.#readGroup?.seal();
164
+ this.#readGroup = null;
165
+ this.container.addChild(createUsageRowBlock(this.#pendingUsage));
166
+ this.#pendingUsage = undefined;
167
+ }
168
+
169
+ #appendChatMessage(message: AgentMessage): void {
170
+ if (message.role !== "toolResult") this.#flushPendingUsage();
171
+ switch (message.role) {
172
+ case "assistant":
173
+ this.#appendAssistantMessage(message);
174
+ break;
175
+ case "toolResult":
176
+ this.#appendToolResult(message);
177
+ break;
178
+ case "user":
179
+ case "developer": {
180
+ // A user prompt closes the poll-displacement window, same as the live path.
181
+ if (message.role === "user") this.#resolveWaitingPoll();
182
+ const textContent = message.role === "user" ? userMessageText(message) : "";
183
+ if (textContent) {
184
+ const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false);
185
+ this.container.addChild(new UserMessageComponent(textContent, isSynthetic));
186
+ }
187
+ break;
188
+ }
189
+ case "bashExecution": {
190
+ const component = new BashExecutionComponent(message.command, this.deps.ui, message.excludeFromContext);
191
+ if (message.output) component.appendOutput(message.output);
192
+ component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation });
193
+ this.container.addChild(component);
194
+ break;
195
+ }
196
+ case "pythonExecution": {
197
+ const component = new EvalExecutionComponent(message.code, this.deps.ui, message.excludeFromContext);
198
+ if (message.output) component.appendOutput(message.output);
199
+ component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation });
200
+ this.container.addChild(component);
201
+ break;
202
+ }
203
+ case "hookMessage":
204
+ case "custom":
205
+ this.#appendCustomMessage(message);
206
+ break;
207
+ case "compactionSummary": {
208
+ const component = new CompactionSummaryMessageComponent(message);
209
+ this.#trackExpandable(component);
210
+ this.container.addChild(component);
211
+ break;
212
+ }
213
+ case "branchSummary": {
214
+ const component = new BranchSummaryMessageComponent(message);
215
+ this.#trackExpandable(component);
216
+ this.container.addChild(component);
217
+ break;
218
+ }
219
+ case "fileMention": {
220
+ const block = new TranscriptBlock();
221
+ for (const file of message.files) {
222
+ let suffix: string;
223
+ if (file.skippedReason === "tooLarge") {
224
+ const size = typeof file.byteSize === "number" ? formatBytes(file.byteSize) : "unknown size";
225
+ suffix = `(skipped: ${size})`;
226
+ } else {
227
+ suffix = file.image
228
+ ? "(image)"
229
+ : file.lineCount === undefined
230
+ ? "(unknown lines)"
231
+ : `(${file.lineCount} lines)`;
232
+ }
233
+ const text = `${theme.fg("dim", `${theme.tree.last} `)}${theme.fg("muted", "Read")} ${theme.fg(
234
+ "accent",
235
+ file.path,
236
+ )} ${theme.fg("dim", suffix)}`;
237
+ // Indent one column to match the transcript's other rows (the viewer renders
238
+ // body rows without an outer gutter; rows own their left pad).
239
+ block.addChild(new Text(text, 1, 0));
240
+ }
241
+ if (block.children.length > 0) this.container.addChild(block);
242
+ break;
243
+ }
244
+ default:
245
+ message satisfies never;
246
+ }
247
+ }
248
+
249
+ #appendAssistantMessage(message: Extract<AgentMessage, { role: "assistant" }>): void {
250
+ const assistantComponent = new AssistantMessageComponent(message, this.deps.hideThinkingBlock?.() ?? false, () =>
251
+ this.deps.requestRender(),
252
+ );
253
+ this.container.addChild(assistantComponent);
254
+
255
+ if (settings.get("display.cacheMissMarker")) {
256
+ const invalidation = detectCacheInvalidation(this.#lastAssistantUsage, message.usage);
257
+ if (invalidation) assistantComponent.setCacheInvalidation(invalidation);
258
+ }
259
+ if (message.usage.cacheRead + message.usage.cacheWrite + message.usage.input > 0) {
260
+ this.#lastAssistantUsage = message.usage;
261
+ }
262
+
263
+ const hasVisibleAssistantContent = message.content.some(
264
+ content =>
265
+ (content.type === "text" && canonicalizeMessage(content.text)) ||
266
+ (content.type === "thinking" && canonicalizeMessage(content.thinking)),
267
+ );
268
+ if (hasVisibleAssistantContent) {
269
+ // New visible turn content closes the current read run (mirrors rebuild).
270
+ this.#readGroup?.seal();
271
+ this.#readGroup = null;
272
+ }
273
+
274
+ const isAbortedSilently = message.stopReason === "aborted" && isSilentAbort(message.errorMessage);
275
+ const hasErrorStop = !isAbortedSilently && (message.stopReason === "aborted" || message.stopReason === "error");
276
+ const errorMessage = hasErrorStop
277
+ ? message.stopReason === "aborted"
278
+ ? resolveAbortLabel(message.errorMessage)
279
+ : message.errorMessage || "Error"
280
+ : null;
281
+
282
+ for (const content of message.content) {
283
+ if (content.type !== "toolCall") continue;
284
+ this.#resolveWaitingPoll(content.name);
285
+
286
+ if (
287
+ content.name === "read" &&
288
+ readArgsHaveTarget(content.arguments) &&
289
+ !readArgsTargetInternalUrl(content.arguments)
290
+ ) {
291
+ if (hasErrorStop && errorMessage) {
292
+ const group = this.#ensureReadGroup();
293
+ group.updateArgs(content.arguments, content.id);
294
+ group.updateResult(
295
+ { content: [{ type: "text", text: errorMessage }], isError: true },
296
+ false,
297
+ content.id,
298
+ );
299
+ } else {
300
+ const normalizedArgs =
301
+ content.arguments && typeof content.arguments === "object" && !Array.isArray(content.arguments)
302
+ ? (content.arguments as Record<string, unknown>)
303
+ : {};
304
+ this.#readArgs.set(content.id, normalizedArgs);
305
+ }
306
+ continue;
307
+ }
308
+
309
+ this.#readGroup?.seal();
310
+ this.#readGroup = null;
311
+ const component = new ToolExecutionComponent(
312
+ content.name,
313
+ content.arguments,
314
+ {
315
+ // Images can't be sliced through the scroll viewport; keep them off.
316
+ showImages: false,
317
+ editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
318
+ editAllowFuzzy: settings.get("edit.fuzzyMatch"),
319
+ liveRegion: this.container,
320
+ },
321
+ this.deps.getTool?.(content.name),
322
+ this.deps.ui,
323
+ this.deps.cwd,
324
+ content.id,
325
+ );
326
+ this.#trackExpandable(component);
327
+ this.container.addChild(component);
328
+
329
+ if (hasErrorStop && errorMessage) {
330
+ component.updateResult(
331
+ { content: [{ type: "text", text: errorMessage }], isError: true },
332
+ false,
333
+ content.id,
334
+ );
335
+ } else {
336
+ this.#pendingTools.set(content.id, component);
337
+ }
338
+ }
339
+
340
+ this.#pendingUsage = settings.get("display.showTokenUsage") ? message.usage : undefined;
341
+ }
342
+
343
+ #appendToolResult(message: Extract<AgentMessage, { role: "toolResult" }>): void {
344
+ const pending = this.#pendingTools.get(message.toolCallId);
345
+ const isReadGroupResult = message.toolName === "read" && (!pending || pending instanceof ReadToolGroupComponent);
346
+ if (isReadGroupResult) {
347
+ let component = pending;
348
+ if (!component) {
349
+ const group = this.#ensureReadGroup();
350
+ const args = this.#readArgs.get(message.toolCallId);
351
+ if (args) group.updateArgs(args, message.toolCallId);
352
+ component = group;
353
+ }
354
+ component.updateResult(message, false, message.toolCallId);
355
+ this.#pendingTools.delete(message.toolCallId);
356
+ this.#readArgs.delete(message.toolCallId);
357
+ return;
358
+ }
359
+ if (!pending) return;
360
+ pending.updateResult(message, false, message.toolCallId);
361
+ this.#pendingTools.delete(message.toolCallId);
362
+ if (message.toolName === "job" && pending instanceof ToolExecutionComponent && pending.isDisplaceableBlock()) {
363
+ this.#waitingPoll = pending;
364
+ }
365
+ }
366
+
367
+ #appendCustomMessage(message: Extract<AgentMessage, { role: "custom" | "hookMessage" }>): void {
368
+ if (!message.display) return;
369
+ if (message.customType === "async-result") {
370
+ const details = (
371
+ message as CustomMessage<{
372
+ jobId?: string;
373
+ type?: "bash" | "task";
374
+ label?: string;
375
+ durationMs?: number;
376
+ jobs?: Array<{ jobId?: string; type?: "bash" | "task"; label?: string; durationMs?: number }>;
377
+ }>
378
+ ).details;
379
+ const jobs =
380
+ details?.jobs && details.jobs.length > 0
381
+ ? details.jobs
382
+ : [
383
+ {
384
+ jobId: details?.jobId,
385
+ type: details?.type,
386
+ label: details?.label,
387
+ durationMs: details?.durationMs,
388
+ },
389
+ ];
390
+ const block = new TranscriptBlock();
391
+ for (const job of jobs) {
392
+ const jobId = job.jobId ?? "unknown";
393
+ const typeLabel = job.type ? `[${job.type}]` : "[job]";
394
+ const duration = typeof job.durationMs === "number" ? formatDuration(job.durationMs) : undefined;
395
+ const line = [
396
+ theme.fg("success", `${theme.status.done} Background job completed`),
397
+ theme.fg("dim", typeLabel),
398
+ theme.fg("accent", jobId),
399
+ duration ? theme.fg("dim", `(${duration})`) : undefined,
400
+ ]
401
+ .filter(Boolean)
402
+ .join(" ");
403
+ block.addChild(new Text(line, 1, 0));
404
+ }
405
+ this.container.addChild(block);
406
+ return;
407
+ }
408
+ if (message.customType === LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE) {
409
+ const details = (message as CustomMessage<{ files?: LateDiagnosticsFile[] }>).details;
410
+ const component = new LateDiagnosticsMessageComponent(details?.files ?? []);
411
+ this.#trackExpandable(component);
412
+ this.container.addChild(component);
413
+ return;
414
+ }
415
+ if (message.customType === COLLAB_PROMPT_MESSAGE_TYPE) {
416
+ this.container.addChild(new CollabPromptMessageComponent(message as CustomMessage<CollabPromptDetails>));
417
+ return;
418
+ }
419
+ if (message.customType === SKILL_PROMPT_MESSAGE_TYPE) {
420
+ const component = new SkillMessageComponent(message as CustomMessage<SkillPromptDetails>);
421
+ this.#trackExpandable(component);
422
+ this.container.addChild(component);
423
+ return;
424
+ }
425
+ if (
426
+ message.customType === "irc:incoming" ||
427
+ message.customType === "irc:autoreply" ||
428
+ message.customType === "irc:relay"
429
+ ) {
430
+ const details = (
431
+ message as CustomMessage<{ from?: string; to?: string; message?: string; body?: string; replyTo?: string }>
432
+ ).details;
433
+ const kind =
434
+ message.customType === "irc:incoming"
435
+ ? ("incoming" as const)
436
+ : message.customType === "irc:autoreply"
437
+ ? ("autoreply" as const)
438
+ : ("relay" as const);
439
+ const card = createIrcMessageCard(
440
+ {
441
+ kind,
442
+ from: details?.from,
443
+ to: details?.to,
444
+ body: kind === "incoming" ? details?.message : details?.body,
445
+ replyTo: details?.replyTo,
446
+ timestamp: message.timestamp,
447
+ },
448
+ () => this.#expanded,
449
+ theme,
450
+ );
451
+ this.container.addChild(card);
452
+ return;
453
+ }
454
+ if (message.customType === "advisor") {
455
+ const details = (message as CustomMessage<AdvisorMessageDetails>).details;
456
+ this.container.addChild(createAdvisorMessageCard(details, () => this.#expanded, theme));
457
+ return;
458
+ }
459
+ if (message.customType === BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE) {
460
+ this.container.addChild(createBackgroundTanDispatchBlock(message as CustomMessage<unknown>));
461
+ return;
462
+ }
463
+ const handoffComponent = createHandoffSummaryMessageComponent(message as CustomMessage<unknown>, this.#expanded);
464
+ if (handoffComponent) {
465
+ this.#trackExpandable(handoffComponent);
466
+ this.container.addChild(handoffComponent);
467
+ return;
468
+ }
469
+ const component = new CustomMessageComponent(
470
+ message as CustomMessage<unknown>,
471
+ this.deps.getMessageRenderer?.(message.customType),
472
+ );
473
+ this.#trackExpandable(component);
474
+ this.container.addChild(component);
475
+ }
476
+ }
@@ -1,6 +1,6 @@
1
1
  import { Box, type Component, Markdown } from "@oh-my-pi/pi-tui";
2
2
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
3
- import type { CompactionSummaryMessage, CustomMessage } from "../../session/messages";
3
+ import type { BranchSummaryMessage, CompactionSummaryMessage, CustomMessage } from "../../session/messages";
4
4
 
5
5
  interface SummaryDividerOptions {
6
6
  label: () => string;
@@ -156,6 +156,34 @@ export function createHandoffSummaryMessageComponent(
156
156
  return component;
157
157
  }
158
158
 
159
+ /**
160
+ * A branch summary collapses a side branch back into the main line. Render it
161
+ * with the same slim divider as `/compact` and handoff rather than a `[branch]`
162
+ * box, so every history-collapse point reads as one consistent banner.
163
+ */
164
+ export class BranchSummaryMessageComponent implements Component {
165
+ #divider: SummaryDividerComponent;
166
+
167
+ constructor(private readonly message: BranchSummaryMessage) {
168
+ this.#divider = new SummaryDividerComponent({
169
+ label: () => `${theme.icon.branch} branch`,
170
+ detailMarkdown: () => `**Branch summary**\n\n${this.message.summary}`,
171
+ });
172
+ }
173
+
174
+ setExpanded(expanded: boolean): void {
175
+ this.#divider.setExpanded(expanded);
176
+ }
177
+
178
+ invalidate(): void {
179
+ this.#divider.invalidate();
180
+ }
181
+
182
+ render(width: number): readonly string[] {
183
+ return this.#divider.render(width);
184
+ }
185
+ }
186
+
159
187
  function getCustomMessageText(message: CustomMessage<unknown>): string {
160
188
  if (typeof message.content === "string") return message.content;
161
189
  let firstText: string | undefined;
@@ -46,12 +46,15 @@ export class CustomMessageComponent extends Container {
46
46
  }
47
47
  this.removeChild(this.#box);
48
48
 
49
+ // The transcript dispatch routes both `custom` and legacy `hookMessage` roles here:
50
+ // tag hooks with the hook glyph, other injected messages with a neutral package.
51
+ const isHook = (this.message.role as string) === "hookMessage";
49
52
  const custom = renderFramedMessage({
50
53
  message: this.message,
51
54
  box: this.#box,
52
55
  expanded: this.#expanded,
53
56
  customRenderer: this.customRenderer,
54
- // Extension messages render full content; no collapse-on-fold behaviour.
57
+ icon: isHook ? theme.icon.extensionHook : theme.icon.package,
55
58
  });
56
59
 
57
60
  if (custom) {
@@ -1,4 +1,4 @@
1
- import { getIndentation, sanitizeText } from "@oh-my-pi/pi-utils";
1
+ import { DEFAULT_TAB_WIDTH, sanitizeText } from "@oh-my-pi/pi-utils";
2
2
  import * as Diff from "diff";
3
3
  import { getLanguageFromPath, highlightCode, theme } from "../../modes/theme/theme";
4
4
  import { type CodeFrameMarker, formatCodeFrameLine, replaceTabs } from "../../tools/render-utils";
@@ -13,12 +13,12 @@ const DIM_OFF = "\x1b[22m";
13
13
  * before the first non-whitespace character; remaining tabs in code
14
14
  * content are replaced with spaces (like replaceTabs).
15
15
  */
16
- function visualizeIndent(text: string, filePath?: string): string {
16
+ function visualizeIndent(text: string): string {
17
17
  const match = text.match(/^([ \t]+)/);
18
- if (!match) return replaceTabs(text, filePath);
18
+ if (!match) return replaceTabs(text);
19
19
  const indent = match[1];
20
20
  const rest = text.slice(indent.length);
21
- const tabWidth = getIndentation(filePath);
21
+ const tabWidth = DEFAULT_TAB_WIDTH;
22
22
  const leftPadding = Math.floor(tabWidth / 2);
23
23
  const rightPadding = Math.max(0, tabWidth - leftPadding - 1);
24
24
  const tabMarker = `${DIM}${" ".repeat(leftPadding)}→${" ".repeat(rightPadding)}${DIM_OFF}`;
@@ -30,7 +30,7 @@ function visualizeIndent(text: string, filePath?: string): string {
30
30
  visible += `${DIM}·${DIM_OFF}`;
31
31
  }
32
32
  }
33
- return `${visible}${replaceTabs(rest, filePath)}`;
33
+ return `${visible}${replaceTabs(rest)}`;
34
34
  }
35
35
 
36
36
  /**
@@ -153,7 +153,7 @@ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): s
153
153
  // unicode ellipsis.
154
154
  const trimmed = line.trim();
155
155
  const isGapRow = trimmed.length === 0 || trimmed === "..." || trimmed === "…";
156
- result.push(theme.fg("toolDiffContext", isGapRow ? "…" : replaceTabs(line, options.filePath)));
156
+ result.push(theme.fg("toolDiffContext", isGapRow ? "…" : replaceTabs(line)));
157
157
  i++;
158
158
  continue;
159
159
  }
@@ -184,47 +184,24 @@ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): s
184
184
  replaceTabs(added.content),
185
185
  );
186
186
 
187
- result.push(
188
- theme.fg(
189
- "toolDiffRemoved",
190
- formatLine("-", removed.lineNum, visualizeIndent(removedLine, options.filePath)),
191
- ),
192
- );
193
- result.push(
194
- theme.fg("toolDiffAdded", formatLine("+", added.lineNum, visualizeIndent(addedLine, options.filePath))),
195
- );
187
+ result.push(theme.fg("toolDiffRemoved", formatLine("-", removed.lineNum, visualizeIndent(removedLine))));
188
+ result.push(theme.fg("toolDiffAdded", formatLine("+", added.lineNum, visualizeIndent(addedLine))));
196
189
  } else {
197
190
  for (const removed of removedLines) {
198
191
  result.push(
199
- theme.fg(
200
- "toolDiffRemoved",
201
- formatLine("-", removed.lineNum, visualizeIndent(removed.content, options.filePath)),
202
- ),
192
+ theme.fg("toolDiffRemoved", formatLine("-", removed.lineNum, visualizeIndent(removed.content))),
203
193
  );
204
194
  }
205
195
  for (const added of addedLines) {
206
- result.push(
207
- theme.fg(
208
- "toolDiffAdded",
209
- formatLine("+", added.lineNum, visualizeIndent(added.content, options.filePath)),
210
- ),
211
- );
196
+ result.push(theme.fg("toolDiffAdded", formatLine("+", added.lineNum, visualizeIndent(added.content))));
212
197
  }
213
198
  }
214
199
  } else if (parsed.prefix === "+") {
215
- result.push(
216
- theme.fg(
217
- "toolDiffAdded",
218
- formatLine("+", parsed.lineNum, visualizeIndent(parsed.content, options.filePath)),
219
- ),
220
- );
200
+ result.push(theme.fg("toolDiffAdded", formatLine("+", parsed.lineNum, visualizeIndent(parsed.content))));
221
201
  i++;
222
202
  } else {
223
203
  const highlighted = contextHighlights.get(i);
224
- const content =
225
- highlighted !== undefined
226
- ? replaceTabs(highlighted, options.filePath)
227
- : visualizeIndent(parsed.content, options.filePath);
204
+ const content = highlighted !== undefined ? replaceTabs(highlighted) : visualizeIndent(parsed.content);
228
205
  result.push(theme.fg("toolDiffContext", formatLine(" ", parsed.lineNum, content)));
229
206
  i++;
230
207
  }
@@ -26,7 +26,7 @@ export class DynamicBorder implements Component {
26
26
  if (this.#cachedLines && this.#cachedWidth === width) {
27
27
  return this.#cachedLines;
28
28
  }
29
- const lines = [this.#color(theme.boxSharp.horizontal.repeat(Math.max(1, width)))];
29
+ const lines = [this.#color(theme.boxRound.horizontal.repeat(Math.max(1, width)))];
30
30
  this.#cachedWidth = width;
31
31
  this.#cachedLines = lines;
32
32
  return lines;
@@ -380,7 +380,7 @@ class TwoColumnBody implements Component {
380
380
  // Fill the full body height so the dashboard reads as a full-screen view.
381
381
  const numLines = this.maxHeight;
382
382
  const combined: string[] = [];
383
- const separator = theme.fg("dim", ` ${theme.boxSharp.vertical} `);
383
+ const separator = theme.fg("dim", ` ${theme.boxRound.vertical} `);
384
384
 
385
385
  for (let i = 0; i < numLines; i++) {
386
386
  const left = truncateToWidth(leftLines[i] ?? "", leftWidth);