@oh-my-pi/pi-coding-agent 15.0.0 → 15.0.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.
Files changed (140) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +9 -9
  4. package/scripts/build-binary.ts +5 -0
  5. package/src/autoresearch/helpers.ts +17 -0
  6. package/src/autoresearch/tools/log-experiment.ts +9 -17
  7. package/src/autoresearch/tools/run-experiment.ts +2 -17
  8. package/src/capability/skill.ts +7 -0
  9. package/src/cli/list-models.ts +1 -1
  10. package/src/cli/shell-cli.ts +3 -13
  11. package/src/cli/update-cli.ts +1 -1
  12. package/src/cli.ts +10 -29
  13. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  14. package/src/commit/analysis/conventional.ts +8 -66
  15. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  16. package/src/commit/pipeline.ts +2 -2
  17. package/src/commit/shared-llm.ts +89 -0
  18. package/src/config/config-file.ts +210 -0
  19. package/src/config/model-equivalence.ts +8 -11
  20. package/src/config/model-registry.ts +13 -2
  21. package/src/config/model-resolver.ts +1 -4
  22. package/src/config/settings-schema.ts +71 -1
  23. package/src/config/settings.ts +1 -1
  24. package/src/config.ts +3 -219
  25. package/src/edit/renderer.ts +7 -1
  26. package/src/eval/js/executor.ts +3 -0
  27. package/src/eval/js/shared/rewrite-imports.ts +2 -2
  28. package/src/eval/py/executor.ts +5 -0
  29. package/src/exa/factory.ts +2 -2
  30. package/src/exa/mcp-client.ts +74 -1
  31. package/src/exec/bash-executor.ts +5 -1
  32. package/src/export/html/template.generated.ts +1 -1
  33. package/src/export/html/template.js +0 -11
  34. package/src/extensibility/extensions/runner.ts +1 -1
  35. package/src/extensibility/extensions/types.ts +89 -223
  36. package/src/extensibility/hooks/types.ts +89 -314
  37. package/src/extensibility/shared-events.ts +343 -0
  38. package/src/extensibility/skills.ts +9 -0
  39. package/src/goals/index.ts +3 -0
  40. package/src/goals/runtime.ts +500 -0
  41. package/src/goals/state.ts +37 -0
  42. package/src/goals/tools/goal-tool.ts +237 -0
  43. package/src/hashline/anchors.ts +2 -2
  44. package/src/hindsight/mental-models.ts +1 -1
  45. package/src/internal-urls/agent-protocol.ts +1 -20
  46. package/src/internal-urls/artifact-protocol.ts +1 -19
  47. package/src/internal-urls/docs-index.generated.ts +5 -6
  48. package/src/internal-urls/registry-helpers.ts +25 -0
  49. package/src/main.ts +11 -2
  50. package/src/mcp/oauth-flow.ts +20 -0
  51. package/src/modes/acp/acp-agent.ts +79 -45
  52. package/src/modes/components/assistant-message.ts +14 -8
  53. package/src/modes/components/bash-execution.ts +24 -63
  54. package/src/modes/components/custom-message.ts +14 -40
  55. package/src/modes/components/eval-execution.ts +27 -57
  56. package/src/modes/components/execution-shared.ts +102 -0
  57. package/src/modes/components/hook-message.ts +17 -49
  58. package/src/modes/components/mcp-add-wizard.ts +26 -5
  59. package/src/modes/components/message-frame.ts +88 -0
  60. package/src/modes/components/model-selector.ts +1 -1
  61. package/src/modes/components/session-observer-overlay.ts +6 -2
  62. package/src/modes/components/session-selector.ts +1 -1
  63. package/src/modes/components/status-line/segments.ts +55 -4
  64. package/src/modes/components/status-line/types.ts +4 -0
  65. package/src/modes/components/status-line.ts +28 -10
  66. package/src/modes/components/tool-execution.ts +7 -8
  67. package/src/modes/controllers/command-controller-shared.ts +108 -0
  68. package/src/modes/controllers/command-controller.ts +13 -4
  69. package/src/modes/controllers/event-controller.ts +36 -7
  70. package/src/modes/controllers/input-controller.ts +13 -0
  71. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  72. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  73. package/src/modes/interactive-mode.ts +624 -52
  74. package/src/modes/print-mode.ts +16 -86
  75. package/src/modes/rpc/rpc-mode.ts +14 -87
  76. package/src/modes/runtime-init.ts +115 -0
  77. package/src/modes/theme/defaults/dark-poimandres.json +2 -0
  78. package/src/modes/theme/defaults/light-poimandres.json +2 -0
  79. package/src/modes/theme/theme.ts +18 -6
  80. package/src/modes/types.ts +14 -3
  81. package/src/modes/utils/context-usage.ts +13 -13
  82. package/src/modes/utils/ui-helpers.ts +10 -3
  83. package/src/plan-mode/approved-plan.ts +35 -1
  84. package/src/prompts/goals/goal-budget-limit.md +16 -0
  85. package/src/prompts/goals/goal-continuation.md +28 -0
  86. package/src/prompts/goals/goal-mode-active.md +23 -0
  87. package/src/prompts/system/plan-mode-active.md +5 -5
  88. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  89. package/src/prompts/tools/bash.md +6 -0
  90. package/src/prompts/tools/goal.md +13 -0
  91. package/src/prompts/tools/hashline.md +102 -114
  92. package/src/prompts/tools/read.md +1 -0
  93. package/src/prompts/tools/resolve.md +6 -5
  94. package/src/sdk.ts +12 -5
  95. package/src/session/agent-session.ts +428 -106
  96. package/src/session/blob-store.ts +36 -3
  97. package/src/session/messages.ts +67 -2
  98. package/src/session/session-manager.ts +131 -12
  99. package/src/session/session-storage.ts +33 -15
  100. package/src/session/streaming-output.ts +309 -13
  101. package/src/slash-commands/builtin-registry.ts +18 -0
  102. package/src/ssh/ssh-executor.ts +5 -0
  103. package/src/system-prompt.ts +4 -2
  104. package/src/task/executor.ts +17 -7
  105. package/src/task/index.ts +3 -0
  106. package/src/task/render.ts +21 -15
  107. package/src/task/types.ts +4 -0
  108. package/src/tools/ast-edit.ts +21 -120
  109. package/src/tools/ast-grep.ts +21 -119
  110. package/src/tools/bash-interactive.ts +9 -1
  111. package/src/tools/bash.ts +27 -4
  112. package/src/tools/browser/attach.ts +3 -3
  113. package/src/tools/browser/launch.ts +81 -18
  114. package/src/tools/browser/registry.ts +1 -5
  115. package/src/tools/browser/tab-supervisor.ts +51 -14
  116. package/src/tools/conflict-detect.ts +15 -4
  117. package/src/tools/eval.ts +3 -1
  118. package/src/tools/find.ts +20 -38
  119. package/src/tools/gh.ts +7 -6
  120. package/src/tools/index.ts +22 -11
  121. package/src/tools/inspect-image.ts +3 -10
  122. package/src/tools/output-meta.ts +176 -37
  123. package/src/tools/path-utils.ts +125 -2
  124. package/src/tools/read.ts +516 -233
  125. package/src/tools/render-utils.ts +92 -0
  126. package/src/tools/renderers.ts +2 -0
  127. package/src/tools/resolve.ts +72 -44
  128. package/src/tools/search.ts +120 -186
  129. package/src/tools/write.ts +44 -9
  130. package/src/utils/file-mentions.ts +1 -1
  131. package/src/utils/image-loading.ts +7 -3
  132. package/src/utils/image-resize.ts +32 -43
  133. package/src/vim/parser.ts +0 -17
  134. package/src/vim/render.ts +1 -1
  135. package/src/vim/types.ts +1 -1
  136. package/src/web/search/providers/gemini.ts +35 -95
  137. package/src/prompts/tools/exit-plan-mode.md +0 -6
  138. package/src/tools/exit-plan-mode.ts +0 -97
  139. package/src/utils/fuzzy.ts +0 -108
  140. package/src/utils/image-convert.ts +0 -27
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Shared helpers for internal-url protocol handlers that resolve IDs against
3
+ * registered agent sessions.
4
+ */
5
+ import { AgentRegistry } from "../registry/agent-registry";
6
+
7
+ /**
8
+ * Snapshot of artifacts dirs for every registered session, deduped.
9
+ *
10
+ * Prefers `sessionManager.getArtifactsDir()` because subagents adopt their
11
+ * parent's `ArtifactManager` and report the parent's dir there; dedup then
12
+ * collapses parent + N subagents (the whole agent tree) to one entry. Falls
13
+ * back to the raw session file (with the `.jsonl` suffix stripped) when no
14
+ * live session reference is attached.
15
+ */
16
+ export function artifactsDirsFromRegistry(): string[] {
17
+ const dirs: string[] = [];
18
+ for (const ref of AgentRegistry.global().list()) {
19
+ const dir =
20
+ ref.session?.sessionManager.getArtifactsDir() ?? (ref.sessionFile ? ref.sessionFile.slice(0, -6) : null);
21
+ if (!dir) continue;
22
+ if (!dirs.includes(dir)) dirs.push(dir);
23
+ }
24
+ return dirs;
25
+ }
package/src/main.ts CHANGED
@@ -129,7 +129,7 @@ export async function submitInteractiveInput(
129
129
  InteractiveMode,
130
130
  "markPendingSubmissionStarted" | "finishPendingSubmission" | "showError" | "checkShutdownRequested"
131
131
  >,
132
- session: Pick<AgentSession, "prompt">,
132
+ session: Pick<AgentSession, "prompt" | "promptCustomMessage">,
133
133
  input: SubmittedUserInput,
134
134
  ): Promise<void> {
135
135
  if (input.cancelled) {
@@ -141,7 +141,16 @@ export async function submitInteractiveInput(
141
141
  if (!input.started && !mode.markPendingSubmissionStarted(input)) {
142
142
  return;
143
143
  }
144
- await session.prompt(input.text, { images: input.images });
144
+ if (input.customType) {
145
+ await session.promptCustomMessage({
146
+ customType: input.customType,
147
+ content: input.text,
148
+ display: input.display ?? false,
149
+ attribution: "agent",
150
+ });
151
+ } else {
152
+ await session.prompt(input.text, { images: input.images });
153
+ }
145
154
  } catch (error: unknown) {
146
155
  const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
147
156
  mode.showError(errorMessage);
@@ -133,6 +133,26 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
133
133
  this.#resolvedClientId = this.#resolveClientId(config);
134
134
  }
135
135
 
136
+ /**
137
+ * Client id used during the authorization request. Returns the value supplied
138
+ * via {@link MCPOAuthConfig.clientId} or, when the server required dynamic
139
+ * client registration, the id issued during registration. `undefined` until
140
+ * {@link generateAuthUrl} (or {@link login}) has run for a server that needs
141
+ * a client id.
142
+ */
143
+ get resolvedClientId(): string | undefined {
144
+ return this.#resolvedClientId;
145
+ }
146
+
147
+ /**
148
+ * Client secret issued by dynamic client registration, if any. Always
149
+ * `undefined` for PKCE-only/public clients and when the caller supplies the
150
+ * client id via config.
151
+ */
152
+ get registeredClientSecret(): string | undefined {
153
+ return this.#registeredClientSecret;
154
+ }
155
+
136
156
  async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
137
157
  if (!this.#resolvedClientId) {
138
158
  await this.#tryRegisterClient(redirectUri);
@@ -53,7 +53,7 @@ import type { MCPServerConfig } from "../../mcp/types";
53
53
  import { loadAllExtensions } from "../../modes/components/extensions/state-manager";
54
54
  import { theme } from "../../modes/theme/theme";
55
55
  import type { AgentSession, AgentSessionEvent } from "../../session/agent-session";
56
- import { SKILL_PROMPT_MESSAGE_TYPE } from "../../session/messages";
56
+ import { isSilentAbort, SKILL_PROMPT_MESSAGE_TYPE } from "../../session/messages";
57
57
  import {
58
58
  SessionManager,
59
59
  type SessionInfo as StoredSessionInfo,
@@ -73,6 +73,15 @@ const MODEL_CONFIG_ID = "model";
73
73
  const THINKING_CONFIG_ID = "thinking";
74
74
  const THINKING_OFF = "off";
75
75
  const SESSION_PAGE_SIZE = 50;
76
+ /**
77
+ * Delay between `session/new` (or `session/load` / `session/resume` /
78
+ * `unstable_session/fork`) returning and the agent firing the first
79
+ * notifications against the new session id. Mitigates Zed's
80
+ * `Received session notification for unknown session` race — see
81
+ * `#scheduleBootstrapUpdates`. Exported so the ACP test harness can
82
+ * wait past this guard without hard-coding the literal.
83
+ */
84
+ export const ACP_BOOTSTRAP_RACE_GUARD_MS = 50;
76
85
 
77
86
  type AgentImageContent = {
78
87
  type: "image";
@@ -97,6 +106,9 @@ type ManagedSessionRecord = {
97
106
  liveMessageId: string | undefined;
98
107
  liveMessageProgress: { textEmitted: boolean; thoughtEmitted: boolean } | undefined;
99
108
  extensionsConfigured: boolean;
109
+ // Installed inside `#scheduleBootstrapUpdates` (post-race-guard); released
110
+ // in `#disposeSessionRecord`. Lives independent of any prompt turn.
111
+ lifetimeUnsubscribe: (() => void) | undefined;
100
112
  };
101
113
 
102
114
  type ReplayableMessage = {
@@ -314,13 +326,7 @@ export class AcpAgent implements Agent {
314
326
  sessionId: record.session.sessionId,
315
327
  update: this.#buildCurrentModeUpdate(record.session),
316
328
  });
317
- await this.#connection.sessionUpdate({
318
- sessionId: record.session.sessionId,
319
- update: {
320
- sessionUpdate: "config_option_update",
321
- configOptions: this.#buildConfigOptions(record.session),
322
- },
323
- });
329
+ await this.#pushConfigOptionUpdate(record);
324
330
  return {};
325
331
  }
326
332
 
@@ -354,27 +360,21 @@ export class AcpAgent implements Agent {
354
360
  });
355
361
  }
356
362
 
357
- const configOptions = this.#buildConfigOptions(record.session);
358
- await this.#connection.sessionUpdate({
359
- sessionId: record.session.sessionId,
360
- update: {
361
- sessionUpdate: "config_option_update",
362
- configOptions,
363
- },
364
- });
365
- return { configOptions };
363
+ // For `thinking` the lifetime subscription pushes post-bootstrap; only
364
+ // push here when it's not yet installed so pre-bootstrap callers still
365
+ // see the change without a post-bootstrap duplicate.
366
+ const thinkingHandledBySubscription =
367
+ params.configId === THINKING_CONFIG_ID && record.lifetimeUnsubscribe !== undefined;
368
+ if (!thinkingHandledBySubscription) {
369
+ await this.#pushConfigOptionUpdate(record);
370
+ }
371
+ return { configOptions: this.#buildConfigOptions(record.session) };
366
372
  }
367
373
 
368
374
  async unstable_setSessionModel(params: SetSessionModelRequest): Promise<SetSessionModelResponse> {
369
375
  const record = this.#getSessionRecord(params.sessionId);
370
376
  await this.#setModelById(record.session, params.modelId);
371
- await this.#connection.sessionUpdate({
372
- sessionId: record.session.sessionId,
373
- update: {
374
- sessionUpdate: "config_option_update",
375
- configOptions: this.#buildConfigOptions(record.session),
376
- },
377
- });
377
+ await this.#pushConfigOptionUpdate(record);
378
378
  return {};
379
379
  }
380
380
 
@@ -432,13 +432,7 @@ export class AcpAgent implements Agent {
432
432
  });
433
433
  },
434
434
  notifyConfigChanged: async () => {
435
- await this.#connection.sessionUpdate({
436
- sessionId: record.session.sessionId,
437
- update: {
438
- sessionUpdate: "config_option_update",
439
- configOptions: this.#buildConfigOptions(record.session),
440
- },
441
- });
435
+ await this.#pushConfigOptionUpdate(record);
442
436
  },
443
437
  });
444
438
  if (builtinResult !== false) {
@@ -688,6 +682,8 @@ export class AcpAgent implements Agent {
688
682
  async #registerPreparedSession(session: AgentSession, mcpServers: McpServer[]): Promise<ManagedSessionRecord> {
689
683
  const record = this.#createManagedSessionRecord(session);
690
684
  session.setClientBridge(createAcpClientBridge(this.#connection, session.sessionId, this.#clientCapabilities));
685
+ // `record.lifetimeUnsubscribe` is installed in `#scheduleBootstrapUpdates`
686
+ // so it shares the bootstrap race guard — see that comment for why.
691
687
  try {
692
688
  await this.#configureExtensions(record);
693
689
  await this.#configureMcpServers(record, mcpServers);
@@ -707,9 +703,24 @@ export class AcpAgent implements Agent {
707
703
  liveMessageId: undefined,
708
704
  liveMessageProgress: undefined,
709
705
  extensionsConfigured: false,
706
+ lifetimeUnsubscribe: undefined,
710
707
  };
711
708
  }
712
709
 
710
+ async #handleLifetimeEvent(record: ManagedSessionRecord, event: AgentSessionEvent): Promise<void> {
711
+ if (event.type !== "thinking_level_changed") {
712
+ return;
713
+ }
714
+ try {
715
+ await this.#pushConfigOptionUpdate(record);
716
+ } catch (error) {
717
+ logger.warn("Failed to push thinking-level config_option_update", {
718
+ sessionId: record.session.sessionId,
719
+ error,
720
+ });
721
+ }
722
+ }
723
+
713
724
  #getSessionRecord(sessionId: string): ManagedSessionRecord {
714
725
  const record = this.#sessions.get(sessionId);
715
726
  if (!record) {
@@ -912,6 +923,16 @@ export class AcpAgent implements Agent {
912
923
  };
913
924
  }
914
925
 
926
+ async #pushConfigOptionUpdate(record: ManagedSessionRecord): Promise<void> {
927
+ await this.#connection.sessionUpdate({
928
+ sessionId: record.session.sessionId,
929
+ update: {
930
+ sessionUpdate: "config_option_update",
931
+ configOptions: this.#buildConfigOptions(record.session),
932
+ },
933
+ });
934
+ }
935
+
915
936
  #buildConfigOptions(session: AgentSession): SessionConfigOption[] {
916
937
  const currentModeId = this.#getCurrentModeId(session);
917
938
  const modeOptions = this.#getAvailableModes(session).map(mode => ({
@@ -1124,18 +1145,25 @@ export class AcpAgent implements Agent {
1124
1145
  }
1125
1146
 
1126
1147
  #scheduleBootstrapUpdates(sessionId: string): void {
1127
- // Delay the bootstrap so the client has time to handle the `session/new`
1128
- // (or `session/load` / `session/resume`) RPC response and register the
1129
- // new sessionId before we start firing notifications against it. Zed's
1130
- // agent-client-protocol reader dispatches responses and notifications
1131
- // to different async tasks; sending the first `available_commands_update`
1132
- // from `setTimeout(0)` reliably loses the race against the response
1133
- // handler and Zed logs `Received session notification for unknown
1134
- // session` then drops the update leaving the slash-command palette
1135
- // empty (#1015 follow-up; see zed-industries/zed#55965 for the same
1136
- // race biting other ACP agents). 50ms is invisible to the operator and
1137
- // large enough that the response future has scheduled before our timer
1138
- // fires on stdio-only transports.
1148
+ // Defer first notifications until the response has reached the client.
1149
+ // Zed's agent-client-protocol reader dispatches responses and
1150
+ // notifications to different async tasks; sending the first
1151
+ // `available_commands_update` from `setTimeout(0)` reliably loses the
1152
+ // race against the response handler and Zed logs `Received session
1153
+ // notification for unknown session` then drops the update leaving
1154
+ // the slash-command palette empty (#1015 follow-up; see
1155
+ // zed-industries/zed#55965 for the same race biting other ACP agents).
1156
+ // `ACP_BOOTSTRAP_RACE_GUARD_MS` is invisible to the operator and large
1157
+ // enough that the response future has scheduled before our timer fires
1158
+ // on stdio-only transports.
1159
+ //
1160
+ // The session-lifetime subscription is installed inside the same timer
1161
+ // so it shares this guard — without it, an extension's `session_start`
1162
+ // handler (or any async work it schedules) calling `setThinkingLevel`
1163
+ // would push a `config_option_update` for a session id the client
1164
+ // hasn't been told about yet. The pre-bootstrap thinking level is
1165
+ // reported in the response's `configOptions`, so deferring the
1166
+ // notification loses no state.
1139
1167
  setTimeout(() => {
1140
1168
  if (this.#connection.signal.aborted) {
1141
1169
  return;
@@ -1144,8 +1172,13 @@ export class AcpAgent implements Agent {
1144
1172
  if (!record) {
1145
1173
  return;
1146
1174
  }
1175
+ if (!record.lifetimeUnsubscribe) {
1176
+ record.lifetimeUnsubscribe = record.session.subscribe(event => {
1177
+ void this.#handleLifetimeEvent(record, event);
1178
+ });
1179
+ }
1147
1180
  void this.#emitBootstrapUpdates(sessionId, record);
1148
- }, 50);
1181
+ }, ACP_BOOTSTRAP_RACE_GUARD_MS);
1149
1182
  }
1150
1183
 
1151
1184
  async #emitBootstrapUpdates(sessionId: string, record: ManagedSessionRecord): Promise<void> {
@@ -1393,7 +1426,7 @@ export class AcpAgent implements Agent {
1393
1426
  }
1394
1427
  }
1395
1428
  }
1396
- if (notifications.length === 0 && message.errorMessage) {
1429
+ if (notifications.length === 0 && message.errorMessage && !isSilentAbort(message.errorMessage)) {
1397
1430
  notifications.push({
1398
1431
  sessionId,
1399
1432
  update: {
@@ -1674,6 +1707,7 @@ export class AcpAgent implements Agent {
1674
1707
  }
1675
1708
 
1676
1709
  async #disposeSessionRecord(record: ManagedSessionRecord): Promise<void> {
1710
+ record.lifetimeUnsubscribe?.();
1677
1711
  if (record.mcpManager) {
1678
1712
  try {
1679
1713
  await record.mcpManager.disconnectAll();
@@ -3,8 +3,8 @@ import { Container, Image, ImageProtocol, Markdown, Spacer, TERMINAL, Text } fro
3
3
  import { formatNumber } from "@oh-my-pi/pi-utils";
4
4
  import { settings } from "../../config/settings";
5
5
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
6
+ import { isSilentAbort } from "../../session/messages";
6
7
  import { resolveImageOptions } from "../../tools/render-utils";
7
- import { convertToPng } from "../../utils/image-convert";
8
8
 
9
9
  /**
10
10
  * Component that renders a complete assistant message
@@ -76,14 +76,15 @@ export class AssistantMessageComponent extends Container {
76
76
  const key = `${toolCallId}:${index}`;
77
77
  if (this.#convertedKittyImages.has(key) || this.#kittyConversionsInFlight.has(key)) continue;
78
78
  this.#kittyConversionsInFlight.add(key);
79
- convertToPng(image.data, image.mimeType)
80
- .then(converted => {
79
+ new Bun.Image(Buffer.from(image.data, "base64"))
80
+ .png()
81
+ .toBase64()
82
+ .then(data => {
81
83
  this.#kittyConversionsInFlight.delete(key);
82
- if (!converted) return;
83
84
  this.#convertedKittyImages.set(key, {
84
85
  type: "image",
85
- data: converted.data,
86
- mimeType: converted.mimeType,
86
+ data,
87
+ mimeType: "image/png",
87
88
  });
88
89
  if (this.#lastMessage) {
89
90
  this.updateContent(this.#lastMessage);
@@ -184,7 +185,7 @@ export class AssistantMessageComponent extends Container {
184
185
  // But only if there are no tool calls (tool execution components will show the error)
185
186
  const hasToolCalls = message.content.some(c => c.type === "toolCall");
186
187
  if (!hasToolCalls) {
187
- if (message.stopReason === "aborted") {
188
+ if (message.stopReason === "aborted" && !isSilentAbort(message.errorMessage)) {
188
189
  const abortMessage =
189
190
  message.errorMessage && message.errorMessage !== "Request was aborted"
190
191
  ? message.errorMessage
@@ -201,7 +202,12 @@ export class AssistantMessageComponent extends Container {
201
202
  this.#contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0));
202
203
  }
203
204
  }
204
- if (message.errorMessage && message.stopReason !== "aborted" && message.stopReason !== "error") {
205
+ if (
206
+ message.errorMessage &&
207
+ !isSilentAbort(message.errorMessage) &&
208
+ message.stopReason !== "aborted" &&
209
+ message.stopReason !== "error"
210
+ ) {
205
211
  this.#contentContainer.addChild(new Spacer(1));
206
212
  this.#contentContainer.addChild(new Text(theme.fg("error", `Error: ${message.errorMessage}`), 1, 0));
207
213
  }
@@ -7,19 +7,23 @@ import {
7
7
  Container,
8
8
  Ellipsis,
9
9
  ImageProtocol,
10
- Loader,
11
- Spacer,
10
+ type Loader,
12
11
  TERMINAL,
13
12
  Text,
14
13
  type TUI,
15
14
  truncateToWidth,
16
15
  visibleWidth,
17
16
  } from "@oh-my-pi/pi-tui";
18
- import { getSymbolTheme, theme } from "../../modes/theme/theme";
19
- import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
17
+ import { theme } from "../../modes/theme/theme";
18
+ import type { TruncationMeta } from "../../tools/output-meta";
20
19
  import { getSixelLineMask, isSixelPassthroughEnabled, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
21
- import { DynamicBorder } from "./dynamic-border";
22
- import { truncateToVisualLines } from "./visual-truncate";
20
+ import {
21
+ buildExecutionFrame,
22
+ buildStatusFooter,
23
+ createCollapsedPreview,
24
+ type ExecutionStatus,
25
+ resolveExecutionStatus,
26
+ } from "./execution-shared";
23
27
 
24
28
  // Preview line limit when not expanded (matches tool execution behavior)
25
29
  const PREVIEW_LINES = 20;
@@ -31,7 +35,7 @@ const CHUNK_THROTTLE_MS = 50;
31
35
 
32
36
  export class BashExecutionComponent extends Container {
33
37
  #outputLines: string[] = [];
34
- #status: "running" | "complete" | "cancelled" | "error" = "running";
38
+ #status: ExecutionStatus = "running";
35
39
  #exitCode: number | undefined = undefined;
36
40
  #loader: Loader;
37
41
  #truncation?: TruncationMeta;
@@ -50,34 +54,14 @@ export class BashExecutionComponent extends Container {
50
54
 
51
55
  // Use dim border for excluded-from-context commands (!! prefix)
52
56
  const colorKey = excludeFromContext ? "dim" : "bashMode";
53
- const borderColor = (str: string) => theme.fg(colorKey, str);
54
-
55
- // Add spacer
56
- this.addChild(new Spacer(1));
57
-
58
- // Top border
59
- this.addChild(new DynamicBorder(borderColor));
60
-
61
- // Content container (holds dynamic content between borders)
62
- this.#contentContainer = new Container();
63
- this.addChild(this.#contentContainer);
57
+ const { contentContainer, loader } = buildExecutionFrame(this, ui, colorKey);
58
+ this.#contentContainer = contentContainer;
59
+ this.#loader = loader;
64
60
 
65
61
  // Command header
66
62
  this.#headerText = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0);
67
63
  this.#contentContainer.addChild(this.#headerText);
68
-
69
- // Loader
70
- this.#loader = new Loader(
71
- ui,
72
- spinner => theme.fg(colorKey, spinner),
73
- text => theme.fg("muted", text),
74
- `Running… (esc to cancel)`,
75
- getSymbolTheme().spinnerFrames,
76
- );
77
64
  this.#contentContainer.addChild(this.#loader);
78
-
79
- // Bottom border
80
- this.addChild(new DynamicBorder(borderColor));
81
65
  }
82
66
 
83
67
  /**
@@ -130,11 +114,7 @@ export class BashExecutionComponent extends Container {
130
114
  options?: { output?: string; truncation?: TruncationMeta },
131
115
  ): void {
132
116
  this.#exitCode = exitCode;
133
- this.#status = cancelled
134
- ? "cancelled"
135
- : exitCode !== 0 && exitCode !== undefined && exitCode !== null
136
- ? "error"
137
- : "complete";
117
+ this.#status = resolveExecutionStatus(exitCode, cancelled);
138
118
  this.#truncation = options?.truncation;
139
119
  if (options?.output !== undefined) {
140
120
  this.#setOutput(options.output);
@@ -182,14 +162,7 @@ export class BashExecutionComponent extends Container {
182
162
  } else {
183
163
  // Use shared visual truncation utility, recomputed per render width
184
164
  const styledOutput = previewLogicalLines.map(line => theme.fg("muted", line)).join("\n");
185
- const previewText = `\n${styledOutput}`;
186
- this.#contentContainer.addChild({
187
- render: (width: number) => {
188
- const { visualLines } = truncateToVisualLines(previewText, PREVIEW_LINES, width, 1);
189
- return visualLines;
190
- },
191
- invalidate: () => {},
192
- });
165
+ this.#contentContainer.addChild(createCollapsedPreview(`\n${styledOutput}`, PREVIEW_LINES));
193
166
  }
194
167
  }
195
168
 
@@ -197,26 +170,14 @@ export class BashExecutionComponent extends Container {
197
170
  if (this.#status === "running") {
198
171
  this.#contentContainer.addChild(this.#loader);
199
172
  } else {
200
- const statusParts: string[] = [];
201
-
202
- // Show how many lines are hidden (collapsed preview)
203
- if (hiddenLineCount > 0 && !hasSixelOutput) {
204
- statusParts.push(theme.fg("dim", `… ${hiddenLineCount} more lines (ctrl+o to expand)`));
205
- }
206
-
207
- if (this.#status === "cancelled") {
208
- statusParts.push(theme.fg("warning", "(cancelled)"));
209
- } else if (this.#status === "error") {
210
- statusParts.push(theme.fg("error", `(exit ${this.#exitCode})`));
211
- }
212
-
213
- if (this.#truncation) {
214
- statusParts.push(theme.fg("warning", formatTruncationMetaNotice(this.#truncation)));
215
- }
216
-
217
- if (statusParts.length > 0) {
218
- this.#contentContainer.addChild(new Text(`\n${statusParts.join("\n")}`, 1, 0));
219
- }
173
+ const footer = buildStatusFooter({
174
+ status: this.#status,
175
+ exitCode: this.#exitCode,
176
+ truncation: this.#truncation,
177
+ hiddenLineCount,
178
+ suppressHiddenCount: hasSixelOutput,
179
+ });
180
+ if (footer) this.#contentContainer.addChild(footer);
220
181
  }
221
182
  }
222
183
 
@@ -1,9 +1,9 @@
1
- import type { TextContent } from "@oh-my-pi/pi-ai";
2
1
  import type { Component } from "@oh-my-pi/pi-tui";
3
- import { Box, Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
2
+ import { Box, Container, Spacer } from "@oh-my-pi/pi-tui";
4
3
  import type { MessageRenderer } from "../../extensibility/extensions/types";
5
- import { getMarkdownTheme, theme } from "../../modes/theme/theme";
4
+ import { theme } from "../../modes/theme/theme";
6
5
  import type { CustomMessage } from "../../session/messages";
6
+ import { renderFramedMessage } from "./message-frame";
7
7
 
8
8
  /**
9
9
  * Component that renders a custom message entry from extensions.
@@ -41,51 +41,25 @@ export class CustomMessageComponent extends Container {
41
41
  }
42
42
 
43
43
  #rebuild(): void {
44
- // Remove previous content component
45
44
  if (this.#customComponent) {
46
45
  this.removeChild(this.#customComponent);
47
46
  this.#customComponent = undefined;
48
47
  }
49
48
  this.removeChild(this.#box);
50
49
 
51
- // Try custom renderer first - it handles its own styling
52
- if (this.customRenderer) {
53
- try {
54
- const component = this.customRenderer(this.message, { expanded: this.#expanded }, theme);
55
- if (component) {
56
- this.#customComponent = component;
57
- this.addChild(component);
58
- return;
59
- }
60
- } catch {
61
- // Fall through to default rendering
62
- }
63
- }
64
-
65
- // Default rendering uses our box
66
- this.addChild(this.#box);
67
- this.#box.clear();
50
+ const custom = renderFramedMessage({
51
+ message: this.message,
52
+ box: this.#box,
53
+ expanded: this.#expanded,
54
+ customRenderer: this.customRenderer,
55
+ // Extension messages render full content; no collapse-on-fold behaviour.
56
+ });
68
57
 
69
- // Default rendering: label + content
70
- const label = theme.fg("customMessageLabel", theme.bold(`[${this.message.customType}]`));
71
- this.#box.addChild(new Text(label, 0, 0));
72
- this.#box.addChild(new Spacer(1));
73
-
74
- // Extract text content
75
- let text: string;
76
- if (typeof this.message.content === "string") {
77
- text = this.message.content;
58
+ if (custom) {
59
+ this.#customComponent = custom;
60
+ this.addChild(custom);
78
61
  } else {
79
- text = this.message.content
80
- .filter((c): c is TextContent => c.type === "text")
81
- .map(c => c.text)
82
- .join("\n");
62
+ this.addChild(this.#box);
83
63
  }
84
-
85
- this.#box.addChild(
86
- new Markdown(text, 0, 0, getMarkdownTheme(), {
87
- color: (value: string) => theme.fg("customMessageText", value),
88
- }),
89
- );
90
64
  }
91
65
  }