@oh-my-pi/pi-coding-agent 16.0.9 → 16.0.11

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 (110) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/cli.js +3402 -3443
  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/collab/host.d.ts +2 -2
  6. package/dist/types/collab/protocol.d.ts +4 -5
  7. package/dist/types/commit/agentic/agent.d.ts +1 -1
  8. package/dist/types/config/model-resolver.d.ts +11 -2
  9. package/dist/types/config/settings-schema.d.ts +12 -6
  10. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  11. package/dist/types/extensibility/extensions/types.d.ts +7 -0
  12. package/dist/types/modes/components/agent-hub.d.ts +6 -1
  13. package/dist/types/modes/components/agent-transcript-viewer.d.ts +39 -0
  14. package/dist/types/modes/components/chat-transcript-builder.d.ts +42 -0
  15. package/dist/types/modes/controllers/command-controller.d.ts +3 -2
  16. package/dist/types/modes/interactive-mode.d.ts +2 -1
  17. package/dist/types/modes/types.d.ts +2 -1
  18. package/dist/types/registry/agent-registry.d.ts +10 -3
  19. package/dist/types/session/agent-session.d.ts +13 -0
  20. package/dist/types/session/compact-modes.d.ts +60 -0
  21. package/dist/types/session/streaming-output.d.ts +0 -2
  22. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  23. package/dist/types/slash-commands/helpers/collab-qrcode.d.ts +13 -0
  24. package/dist/types/tools/__tests__/json-tree.test.d.ts +1 -0
  25. package/dist/types/tools/index.d.ts +9 -1
  26. package/dist/types/utils/image-loading.d.ts +12 -0
  27. package/dist/types/utils/qrcode.d.ts +48 -0
  28. package/package.json +12 -12
  29. package/src/advisor/index.ts +1 -0
  30. package/src/advisor/transcript-recorder.ts +136 -0
  31. package/src/cli/args.ts +7 -1
  32. package/src/cli/stats-cli.ts +2 -11
  33. package/src/collab/host.ts +29 -17
  34. package/src/collab/protocol.ts +48 -15
  35. package/src/commit/agentic/agent.ts +2 -1
  36. package/src/commit/agentic/tools/git-file-diff.ts +2 -2
  37. package/src/commit/changelog/index.ts +1 -1
  38. package/src/commit/map-reduce/map-phase.ts +1 -1
  39. package/src/commit/map-reduce/utils.ts +1 -1
  40. package/src/config/config-file.ts +1 -1
  41. package/src/config/keybindings.ts +2 -2
  42. package/src/config/model-registry.ts +16 -4
  43. package/src/config/model-resolver.ts +193 -35
  44. package/src/config/settings-schema.ts +14 -7
  45. package/src/config/settings.ts +3 -9
  46. package/src/edit/file-snapshot-store.ts +1 -1
  47. package/src/edit/renderer.ts +7 -7
  48. package/src/eval/js/tool-bridge.ts +3 -2
  49. package/src/eval/py/prelude.py +3 -2
  50. package/src/export/html/tool-views.generated.js +28 -28
  51. package/src/extensibility/extensions/types.ts +7 -0
  52. package/src/hindsight/mental-models.ts +1 -1
  53. package/src/internal-urls/docs-index.generated.txt +1 -1
  54. package/src/internal-urls/history-protocol.ts +8 -3
  55. package/src/irc/bus.ts +8 -0
  56. package/src/lsp/index.ts +2 -2
  57. package/src/main.ts +6 -3
  58. package/src/modes/acp/acp-agent.ts +63 -0
  59. package/src/modes/components/agent-hub.ts +97 -920
  60. package/src/modes/components/agent-transcript-viewer.ts +461 -0
  61. package/src/modes/components/chat-transcript-builder.ts +462 -0
  62. package/src/modes/components/diff.ts +12 -35
  63. package/src/modes/components/oauth-selector.ts +31 -2
  64. package/src/modes/controllers/command-controller.ts +12 -2
  65. package/src/modes/controllers/event-controller.ts +1 -1
  66. package/src/modes/controllers/input-controller.ts +8 -1
  67. package/src/modes/controllers/selector-controller.ts +4 -1
  68. package/src/modes/interactive-mode.ts +4 -2
  69. package/src/modes/types.ts +2 -1
  70. package/src/prompts/tools/inspect-image.md +1 -1
  71. package/src/prompts/tools/read.md +1 -1
  72. package/src/registry/agent-registry.ts +13 -4
  73. package/src/sdk.ts +27 -8
  74. package/src/session/agent-session.ts +185 -17
  75. package/src/session/compact-modes.ts +105 -0
  76. package/src/session/session-dump-format.ts +1 -1
  77. package/src/session/session-history-format.ts +1 -1
  78. package/src/session/streaming-output.ts +5 -5
  79. package/src/slash-commands/builtin-registry.ts +45 -15
  80. package/src/slash-commands/helpers/collab-qrcode.ts +28 -0
  81. package/src/task/executor.ts +1 -1
  82. package/src/task/output-manager.ts +5 -0
  83. package/src/thinking.ts +25 -5
  84. package/src/tools/__tests__/json-tree.test.ts +35 -0
  85. package/src/tools/approval.ts +1 -1
  86. package/src/tools/bash.ts +0 -1
  87. package/src/tools/browser.ts +0 -1
  88. package/src/tools/eval.ts +1 -1
  89. package/src/tools/gh.ts +1 -1
  90. package/src/tools/index.ts +10 -1
  91. package/src/tools/inspect-image.ts +72 -9
  92. package/src/tools/irc.ts +1 -1
  93. package/src/tools/json-tree.ts +22 -5
  94. package/src/tools/read.ts +5 -6
  95. package/src/utils/file-mentions.ts +5 -2
  96. package/src/utils/image-loading.ts +58 -0
  97. package/src/utils/qrcode.ts +535 -0
  98. package/src/web/scrapers/firefox-addons.ts +1 -1
  99. package/src/web/scrapers/github.ts +1 -1
  100. package/src/web/scrapers/go-pkg.ts +2 -2
  101. package/src/web/scrapers/metacpan.ts +2 -2
  102. package/src/web/scrapers/nvd.ts +2 -2
  103. package/src/web/scrapers/ollama.ts +1 -1
  104. package/src/web/scrapers/opencorporates.ts +1 -1
  105. package/src/web/scrapers/pub-dev.ts +1 -1
  106. package/src/web/scrapers/repology.ts +1 -1
  107. package/src/web/scrapers/sourcegraph.ts +1 -1
  108. package/src/web/scrapers/terraform.ts +6 -6
  109. package/src/web/scrapers/wikidata.ts +2 -2
  110. package/src/workspace-tree.ts +1 -1
@@ -0,0 +1,462 @@
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 { BranchSummaryMessageComponent } from "./branch-summary-message";
40
+ import { CollabPromptMessageComponent } from "./collab-prompt-message";
41
+ import { CompactionSummaryMessageComponent, createHandoffSummaryMessageComponent } from "./compaction-summary-message";
42
+ import { CustomMessageComponent } from "./custom-message";
43
+ import { EvalExecutionComponent } from "./eval-execution";
44
+ import { type LateDiagnosticsFile, LateDiagnosticsMessageComponent } from "./late-diagnostics-message";
45
+ import { ReadToolGroupComponent, readArgsHaveTarget, readArgsTargetInternalUrl } from "./read-tool-group";
46
+ import { SkillMessageComponent } from "./skill-message";
47
+ import { ToolExecutionComponent } from "./tool-execution";
48
+ import { TranscriptBlock, TranscriptContainer } from "./transcript-container";
49
+ import { createUsageRowBlock } from "./usage-row";
50
+ import { UserMessageComponent } from "./user-message";
51
+
52
+ export interface ChatTranscriptBuilderDeps {
53
+ ui: TUI;
54
+ getTool?: (name: string) => AgentTool | undefined;
55
+ getMessageRenderer?: (customType: string) => MessageRenderer | undefined;
56
+ cwd: string;
57
+ hideThinkingBlock?: () => boolean;
58
+ requestRender: () => void;
59
+ }
60
+
61
+ /** Extracts the plain-text content of a user message (string or text blocks). */
62
+ function userMessageText(message: Extract<AgentMessage, { role: "user" }>): string {
63
+ if (typeof message.content === "string") return message.content;
64
+ return message.content
65
+ .filter((block): block is { type: "text"; text: string } => block.type === "text")
66
+ .map(block => block.text)
67
+ .join("");
68
+ }
69
+
70
+ export class ChatTranscriptBuilder {
71
+ readonly container = new TranscriptContainer();
72
+ #pendingTools = new Map<string, ToolExecutionComponent | ReadToolGroupComponent>();
73
+ #readArgs = new Map<string, Record<string, unknown>>();
74
+ #readGroup: ReadToolGroupComponent | null = null;
75
+ #pendingUsage: Usage | undefined;
76
+ #waitingPoll: ToolExecutionComponent | null = null;
77
+ #expandables: Array<{ setExpanded(expanded: boolean): void }> = [];
78
+ #expanded = false;
79
+
80
+ constructor(private readonly deps: ChatTranscriptBuilderDeps) {}
81
+
82
+ /** Whether the transcript currently holds any rendered rows. */
83
+ get isEmpty(): boolean {
84
+ return this.container.children.length === 0;
85
+ }
86
+
87
+ /** Discard all components and rebuild the whole transcript from `entries`. */
88
+ rebuild(entries: SessionMessageEntry[]): void {
89
+ this.reset();
90
+ for (const entry of entries) this.#appendChatMessage(entry.message);
91
+ // Flush the trailing turn's usage row only once its tools are materialized
92
+ // (a read whose result has not arrived stays pending); otherwise the row
93
+ // would sit above its tools. The drain happens here at the end of the pass.
94
+ if (this.#readArgs.size === 0 && this.#pendingTools.size === 0) this.#flushPendingUsage();
95
+ }
96
+
97
+ /** Toggle tool-output expansion across every expandable component. */
98
+ setExpanded(expanded: boolean): void {
99
+ this.#expanded = expanded;
100
+ for (const component of this.#expandables) component.setExpanded(expanded);
101
+ }
102
+
103
+ get expanded(): boolean {
104
+ return this.#expanded;
105
+ }
106
+
107
+ /** Tear down components (sealing pending spinners) and clear build state. */
108
+ reset(): void {
109
+ for (const pending of this.#pendingTools.values()) pending.seal();
110
+ this.#pendingTools.clear();
111
+ this.#readArgs.clear();
112
+ this.#readGroup = null;
113
+ this.#pendingUsage = undefined;
114
+ this.#waitingPoll = null;
115
+ this.#expandables = [];
116
+ this.container.dispose();
117
+ this.container.clear();
118
+ }
119
+
120
+ dispose(): void {
121
+ this.reset();
122
+ }
123
+
124
+ #trackExpandable(component: { setExpanded(expanded: boolean): void }): void {
125
+ component.setExpanded(this.#expanded);
126
+ this.#expandables.push(component);
127
+ }
128
+
129
+ /** A `job` poll showing all-running is displaced by the next `job` call. */
130
+ #resolveWaitingPoll(nextToolName?: string): void {
131
+ const previous = this.#waitingPoll;
132
+ if (!previous) return;
133
+ this.#waitingPoll = null;
134
+ if (nextToolName === "job" && previous.isDisplaceableBlock()) {
135
+ this.container.removeChild(previous);
136
+ }
137
+ previous.seal();
138
+ }
139
+
140
+ #ensureReadGroup(): ReadToolGroupComponent {
141
+ if (!this.#readGroup) {
142
+ this.#readGroup = new ReadToolGroupComponent({
143
+ showContentPreview: settings.get("read.toolResultPreview"),
144
+ });
145
+ this.#trackExpandable(this.#readGroup);
146
+ this.container.addChild(this.#readGroup);
147
+ }
148
+ return this.#readGroup;
149
+ }
150
+
151
+ // The per-turn token-usage row must land below the turn's tool blocks, but
152
+ // normal `read` calls only materialize their group in #appendToolResult. Defer
153
+ // the row: stash it on the assistant message and flush once the turn's tools
154
+ // are placed, sealing the read run so the row sits under it.
155
+ #flushPendingUsage(): void {
156
+ if (!this.#pendingUsage) return;
157
+ this.#readGroup?.seal();
158
+ this.#readGroup = null;
159
+ this.container.addChild(createUsageRowBlock(this.#pendingUsage));
160
+ this.#pendingUsage = undefined;
161
+ }
162
+
163
+ #appendChatMessage(message: AgentMessage): void {
164
+ if (message.role !== "toolResult") this.#flushPendingUsage();
165
+ switch (message.role) {
166
+ case "assistant":
167
+ this.#appendAssistantMessage(message);
168
+ break;
169
+ case "toolResult":
170
+ this.#appendToolResult(message);
171
+ break;
172
+ case "user":
173
+ case "developer": {
174
+ // A user prompt closes the poll-displacement window, same as the live path.
175
+ if (message.role === "user") this.#resolveWaitingPoll();
176
+ const textContent = message.role === "user" ? userMessageText(message) : "";
177
+ if (textContent) {
178
+ const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false);
179
+ this.container.addChild(new UserMessageComponent(textContent, isSynthetic));
180
+ }
181
+ break;
182
+ }
183
+ case "bashExecution": {
184
+ const component = new BashExecutionComponent(message.command, this.deps.ui, message.excludeFromContext);
185
+ if (message.output) component.appendOutput(message.output);
186
+ component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation });
187
+ this.container.addChild(component);
188
+ break;
189
+ }
190
+ case "pythonExecution": {
191
+ const component = new EvalExecutionComponent(message.code, this.deps.ui, message.excludeFromContext);
192
+ if (message.output) component.appendOutput(message.output);
193
+ component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation });
194
+ this.container.addChild(component);
195
+ break;
196
+ }
197
+ case "hookMessage":
198
+ case "custom":
199
+ this.#appendCustomMessage(message);
200
+ break;
201
+ case "compactionSummary": {
202
+ const component = new CompactionSummaryMessageComponent(message);
203
+ this.#trackExpandable(component);
204
+ this.container.addChild(component);
205
+ break;
206
+ }
207
+ case "branchSummary": {
208
+ const component = new BranchSummaryMessageComponent(message);
209
+ this.#trackExpandable(component);
210
+ this.container.addChild(component);
211
+ break;
212
+ }
213
+ case "fileMention": {
214
+ const block = new TranscriptBlock();
215
+ for (const file of message.files) {
216
+ let suffix: string;
217
+ if (file.skippedReason === "tooLarge") {
218
+ const size = typeof file.byteSize === "number" ? formatBytes(file.byteSize) : "unknown size";
219
+ suffix = `(skipped: ${size})`;
220
+ } else {
221
+ suffix = file.image
222
+ ? "(image)"
223
+ : file.lineCount === undefined
224
+ ? "(unknown lines)"
225
+ : `(${file.lineCount} lines)`;
226
+ }
227
+ const text = `${theme.fg("dim", `${theme.tree.last} `)}${theme.fg("muted", "Read")} ${theme.fg(
228
+ "accent",
229
+ file.path,
230
+ )} ${theme.fg("dim", suffix)}`;
231
+ // Indent one column to match the transcript's other rows (the viewer renders
232
+ // body rows without an outer gutter; rows own their left pad).
233
+ block.addChild(new Text(text, 1, 0));
234
+ }
235
+ if (block.children.length > 0) this.container.addChild(block);
236
+ break;
237
+ }
238
+ default:
239
+ message satisfies never;
240
+ }
241
+ }
242
+
243
+ #appendAssistantMessage(message: Extract<AgentMessage, { role: "assistant" }>): void {
244
+ const assistantComponent = new AssistantMessageComponent(message, this.deps.hideThinkingBlock?.() ?? false, () =>
245
+ this.deps.requestRender(),
246
+ );
247
+ this.container.addChild(assistantComponent);
248
+
249
+ const hasVisibleAssistantContent = message.content.some(
250
+ content =>
251
+ (content.type === "text" && canonicalizeMessage(content.text)) ||
252
+ (content.type === "thinking" && canonicalizeMessage(content.thinking)),
253
+ );
254
+ if (hasVisibleAssistantContent) {
255
+ // New visible turn content closes the current read run (mirrors rebuild).
256
+ this.#readGroup?.seal();
257
+ this.#readGroup = null;
258
+ }
259
+
260
+ const isAbortedSilently = message.stopReason === "aborted" && isSilentAbort(message.errorMessage);
261
+ const hasErrorStop = !isAbortedSilently && (message.stopReason === "aborted" || message.stopReason === "error");
262
+ const errorMessage = hasErrorStop
263
+ ? message.stopReason === "aborted"
264
+ ? resolveAbortLabel(message.errorMessage)
265
+ : message.errorMessage || "Error"
266
+ : null;
267
+
268
+ for (const content of message.content) {
269
+ if (content.type !== "toolCall") continue;
270
+ this.#resolveWaitingPoll(content.name);
271
+
272
+ if (
273
+ content.name === "read" &&
274
+ readArgsHaveTarget(content.arguments) &&
275
+ !readArgsTargetInternalUrl(content.arguments)
276
+ ) {
277
+ if (hasErrorStop && errorMessage) {
278
+ const group = this.#ensureReadGroup();
279
+ group.updateArgs(content.arguments, content.id);
280
+ group.updateResult(
281
+ { content: [{ type: "text", text: errorMessage }], isError: true },
282
+ false,
283
+ content.id,
284
+ );
285
+ } else {
286
+ const normalizedArgs =
287
+ content.arguments && typeof content.arguments === "object" && !Array.isArray(content.arguments)
288
+ ? (content.arguments as Record<string, unknown>)
289
+ : {};
290
+ this.#readArgs.set(content.id, normalizedArgs);
291
+ }
292
+ continue;
293
+ }
294
+
295
+ this.#readGroup?.seal();
296
+ this.#readGroup = null;
297
+ const component = new ToolExecutionComponent(
298
+ content.name,
299
+ content.arguments,
300
+ {
301
+ // Images can't be sliced through the scroll viewport; keep them off.
302
+ showImages: false,
303
+ editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
304
+ editAllowFuzzy: settings.get("edit.fuzzyMatch"),
305
+ liveRegion: this.container,
306
+ },
307
+ this.deps.getTool?.(content.name),
308
+ this.deps.ui,
309
+ this.deps.cwd,
310
+ content.id,
311
+ );
312
+ this.#trackExpandable(component);
313
+ this.container.addChild(component);
314
+
315
+ if (hasErrorStop && errorMessage) {
316
+ component.updateResult(
317
+ { content: [{ type: "text", text: errorMessage }], isError: true },
318
+ false,
319
+ content.id,
320
+ );
321
+ } else {
322
+ this.#pendingTools.set(content.id, component);
323
+ }
324
+ }
325
+
326
+ this.#pendingUsage = settings.get("display.showTokenUsage") ? message.usage : undefined;
327
+ }
328
+
329
+ #appendToolResult(message: Extract<AgentMessage, { role: "toolResult" }>): void {
330
+ const pending = this.#pendingTools.get(message.toolCallId);
331
+ const isReadGroupResult = message.toolName === "read" && (!pending || pending instanceof ReadToolGroupComponent);
332
+ if (isReadGroupResult) {
333
+ let component = pending;
334
+ if (!component) {
335
+ const group = this.#ensureReadGroup();
336
+ const args = this.#readArgs.get(message.toolCallId);
337
+ if (args) group.updateArgs(args, message.toolCallId);
338
+ component = group;
339
+ }
340
+ component.updateResult(message, false, message.toolCallId);
341
+ this.#pendingTools.delete(message.toolCallId);
342
+ this.#readArgs.delete(message.toolCallId);
343
+ return;
344
+ }
345
+ if (!pending) return;
346
+ pending.updateResult(message, false, message.toolCallId);
347
+ this.#pendingTools.delete(message.toolCallId);
348
+ if (message.toolName === "job" && pending instanceof ToolExecutionComponent && pending.isDisplaceableBlock()) {
349
+ this.#waitingPoll = pending;
350
+ }
351
+ }
352
+
353
+ #appendCustomMessage(message: Extract<AgentMessage, { role: "custom" | "hookMessage" }>): void {
354
+ if (!message.display) return;
355
+ if (message.customType === "async-result") {
356
+ const details = (
357
+ message as CustomMessage<{
358
+ jobId?: string;
359
+ type?: "bash" | "task";
360
+ label?: string;
361
+ durationMs?: number;
362
+ jobs?: Array<{ jobId?: string; type?: "bash" | "task"; label?: string; durationMs?: number }>;
363
+ }>
364
+ ).details;
365
+ const jobs =
366
+ details?.jobs && details.jobs.length > 0
367
+ ? details.jobs
368
+ : [
369
+ {
370
+ jobId: details?.jobId,
371
+ type: details?.type,
372
+ label: details?.label,
373
+ durationMs: details?.durationMs,
374
+ },
375
+ ];
376
+ const block = new TranscriptBlock();
377
+ for (const job of jobs) {
378
+ const jobId = job.jobId ?? "unknown";
379
+ const typeLabel = job.type ? `[${job.type}]` : "[job]";
380
+ const duration = typeof job.durationMs === "number" ? formatDuration(job.durationMs) : undefined;
381
+ const line = [
382
+ theme.fg("success", `${theme.status.done} Background job completed`),
383
+ theme.fg("dim", typeLabel),
384
+ theme.fg("accent", jobId),
385
+ duration ? theme.fg("dim", `(${duration})`) : undefined,
386
+ ]
387
+ .filter(Boolean)
388
+ .join(" ");
389
+ block.addChild(new Text(line, 1, 0));
390
+ }
391
+ this.container.addChild(block);
392
+ return;
393
+ }
394
+ if (message.customType === LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE) {
395
+ const details = (message as CustomMessage<{ files?: LateDiagnosticsFile[] }>).details;
396
+ const component = new LateDiagnosticsMessageComponent(details?.files ?? []);
397
+ this.#trackExpandable(component);
398
+ this.container.addChild(component);
399
+ return;
400
+ }
401
+ if (message.customType === COLLAB_PROMPT_MESSAGE_TYPE) {
402
+ this.container.addChild(new CollabPromptMessageComponent(message as CustomMessage<CollabPromptDetails>));
403
+ return;
404
+ }
405
+ if (message.customType === SKILL_PROMPT_MESSAGE_TYPE) {
406
+ const component = new SkillMessageComponent(message as CustomMessage<SkillPromptDetails>);
407
+ this.#trackExpandable(component);
408
+ this.container.addChild(component);
409
+ return;
410
+ }
411
+ if (
412
+ message.customType === "irc:incoming" ||
413
+ message.customType === "irc:autoreply" ||
414
+ message.customType === "irc:relay"
415
+ ) {
416
+ const details = (
417
+ message as CustomMessage<{ from?: string; to?: string; message?: string; body?: string; replyTo?: string }>
418
+ ).details;
419
+ const kind =
420
+ message.customType === "irc:incoming"
421
+ ? ("incoming" as const)
422
+ : message.customType === "irc:autoreply"
423
+ ? ("autoreply" as const)
424
+ : ("relay" as const);
425
+ const card = createIrcMessageCard(
426
+ {
427
+ kind,
428
+ from: details?.from,
429
+ to: details?.to,
430
+ body: kind === "incoming" ? details?.message : details?.body,
431
+ replyTo: details?.replyTo,
432
+ timestamp: message.timestamp,
433
+ },
434
+ () => this.#expanded,
435
+ theme,
436
+ );
437
+ this.container.addChild(card);
438
+ return;
439
+ }
440
+ if (message.customType === "advisor") {
441
+ const details = (message as CustomMessage<AdvisorMessageDetails>).details;
442
+ this.container.addChild(createAdvisorMessageCard(details, () => this.#expanded, theme));
443
+ return;
444
+ }
445
+ if (message.customType === BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE) {
446
+ this.container.addChild(createBackgroundTanDispatchBlock(message as CustomMessage<unknown>));
447
+ return;
448
+ }
449
+ const handoffComponent = createHandoffSummaryMessageComponent(message as CustomMessage<unknown>, this.#expanded);
450
+ if (handoffComponent) {
451
+ this.#trackExpandable(handoffComponent);
452
+ this.container.addChild(handoffComponent);
453
+ return;
454
+ }
455
+ const component = new CustomMessageComponent(
456
+ message as CustomMessage<unknown>,
457
+ this.deps.getMessageRenderer?.(message.customType),
458
+ );
459
+ this.#trackExpandable(component);
460
+ this.container.addChild(component);
461
+ }
462
+ }
@@ -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
  }
@@ -10,6 +10,7 @@ import {
10
10
  Spacer,
11
11
  TruncatedText,
12
12
  } from "@oh-my-pi/pi-tui";
13
+ import { settings } from "../../config/settings";
13
14
  import { theme } from "../../modes/theme/theme";
14
15
  import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
15
16
  import type { AuthStorage, CredentialOriginKind } from "../../session/auth-storage";
@@ -17,6 +18,20 @@ import { DynamicBorder } from "./dynamic-border";
17
18
 
18
19
  const OAUTH_SELECTOR_MAX_VISIBLE = 10;
19
20
 
21
+ /**
22
+ * Provider ids the user has disabled via settings. `/login` (login mode) hides
23
+ * these so a disabled provider's models stay out of reach end-to-end, mirroring
24
+ * the model picker's `disabledProviders` filtering. Reads the settings singleton
25
+ * defensively: it throws before `Settings.init()`, in which case nothing is disabled.
26
+ */
27
+ function getDisabledProviderIds(): ReadonlySet<string> {
28
+ try {
29
+ return new Set(settings.get("disabledProviders"));
30
+ } catch {
31
+ return new Set();
32
+ }
33
+ }
34
+
20
35
  /**
21
36
  * Rendered lines before the provider rows: top border, spacer, title, spacer
22
37
  * (must mirror the constructor's addChild order).
@@ -102,8 +117,22 @@ export class OAuthSelectorComponent extends Container {
102
117
 
103
118
  #loadProviders(): void {
104
119
  const providers = getOAuthProviders();
105
- this.#allProviders =
106
- this.#mode === "logout" ? providers.filter(provider => this.#hasSelectableAuth(provider.id)) : providers;
120
+ if (this.#mode === "logout") {
121
+ // Logout stays unfiltered by `disabledProviders`: a now-disabled
122
+ // provider may still hold stored credentials worth removing.
123
+ this.#allProviders = providers.filter(provider => this.#hasSelectableAuth(provider.id));
124
+ } else {
125
+ const disabled = getDisabledProviderIds();
126
+ // Hide a login entry when either its own id or the provider id it
127
+ // stores credentials under is disabled, so alias logins (e.g.
128
+ // `openai-codex-device` ⇒ `openai-codex`) disappear alongside the
129
+ // model provider they authenticate.
130
+ this.#allProviders = providers.filter(
131
+ provider =>
132
+ !disabled.has(provider.id) &&
133
+ !(provider.storeCredentialsAs && disabled.has(provider.storeCredentialsAs)),
134
+ );
135
+ }
107
136
  this.#filteredProviders = this.#allProviders;
108
137
  }
109
138
 
@@ -38,6 +38,7 @@ import { buildHotkeysMarkdown } from "../../modes/utils/hotkeys-markdown";
38
38
  import { buildToolsMarkdown } from "../../modes/utils/tools-markdown";
39
39
  import type { AsyncJobSnapshotItem } from "../../session/agent-session";
40
40
  import type { AuthStorage, OAuthAccountIdentity } from "../../session/auth-storage";
41
+ import type { CompactMode } from "../../session/compact-modes";
41
42
  import type { NewSessionOptions } from "../../session/session-entries";
42
43
  import { formatShakeSummary, type ShakeMode, type ShakeResult } from "../../session/shake-types";
43
44
  import { limitMatchesActiveAccount } from "../../slash-commands/helpers/active-oauth-account";
@@ -1034,6 +1035,7 @@ export class CommandController {
1034
1035
 
1035
1036
  async handleCompactCommand(
1036
1037
  customInstructions?: string,
1038
+ mode?: CompactMode,
1037
1039
  beforeFlush?: (outcome: CompactionOutcome) => void | Promise<void>,
1038
1040
  ): Promise<CompactionOutcome> {
1039
1041
  const entries = this.ctx.sessionManager.getEntries();
@@ -1044,7 +1046,7 @@ export class CommandController {
1044
1046
  return "ok";
1045
1047
  }
1046
1048
 
1047
- return this.executeCompaction(customInstructions, false, beforeFlush);
1049
+ return this.executeCompaction(customInstructions, false, beforeFlush, mode);
1048
1050
  }
1049
1051
 
1050
1052
  /**
@@ -1090,6 +1092,7 @@ export class CommandController {
1090
1092
  customInstructionsOrOptions?: string | CompactOptions,
1091
1093
  isAuto = false,
1092
1094
  beforeFlush?: (outcome: CompactionOutcome) => void | Promise<void>,
1095
+ mode?: CompactMode,
1093
1096
  ): Promise<CompactionOutcome> {
1094
1097
  if (this.ctx.loadingAnimation) {
1095
1098
  this.ctx.loadingAnimation.stop();
@@ -1111,10 +1114,17 @@ export class CommandController {
1111
1114
  let outcome: CompactionOutcome = "ok";
1112
1115
  try {
1113
1116
  const instructions = typeof customInstructionsOrOptions === "string" ? customInstructionsOrOptions : undefined;
1114
- const options =
1117
+ const baseOptions =
1115
1118
  customInstructionsOrOptions && typeof customInstructionsOrOptions === "object"
1116
1119
  ? customInstructionsOrOptions
1117
1120
  : undefined;
1121
+ // The slash path passes `mode` positionally; the extension path carries
1122
+ // it inside the options object. Either source wins over no mode.
1123
+ const effectiveMode = mode ?? baseOptions?.mode;
1124
+ const options =
1125
+ baseOptions || effectiveMode
1126
+ ? { ...baseOptions, ...(effectiveMode ? { mode: effectiveMode } : {}) }
1127
+ : undefined;
1118
1128
  await this.ctx.session.compact(instructions, options);
1119
1129
 
1120
1130
  compactingLoader.stop();
@@ -1,6 +1,6 @@
1
- import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
2
1
  import type { ImageContent } from "@oh-my-pi/pi-ai";
3
2
  import { type Component, Loader, TERMINAL } from "@oh-my-pi/pi-tui";
3
+ import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
4
4
  import { extractTextContent } from "../../commit/utils";
5
5
  import { settings } from "../../config/settings";
6
6
  import { getFileSnapshotStore } from "../../edit/file-snapshot-store";