@oh-my-pi/pi-coding-agent 17.0.0 → 17.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 (101) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/cli.js +3305 -3302
  3. package/dist/types/advisor/advise-tool.d.ts +5 -2
  4. package/dist/types/cli-commands.d.ts +14 -1
  5. package/dist/types/config/model-registry.d.ts +4 -0
  6. package/dist/types/config/models-config-schema.d.ts +10 -0
  7. package/dist/types/config/models-config.d.ts +6 -0
  8. package/dist/types/config/settings-schema.d.ts +1 -1
  9. package/dist/types/discovery/substitute-plugin-root.d.ts +20 -7
  10. package/dist/types/internal-urls/agent-protocol.d.ts +1 -0
  11. package/dist/types/lib/xai-http.d.ts +11 -0
  12. package/dist/types/modes/acp/acp-agent.d.ts +3 -0
  13. package/dist/types/modes/acp/acp-mode.d.ts +6 -2
  14. package/dist/types/modes/components/assistant-message.d.ts +2 -0
  15. package/dist/types/modes/components/custom-editor-plugin-ctor.test.d.ts +1 -0
  16. package/dist/types/modes/components/custom-editor.d.ts +23 -1
  17. package/dist/types/modes/components/hook-editor.d.ts +9 -2
  18. package/dist/types/modes/components/login-dialog.test.d.ts +1 -0
  19. package/dist/types/modes/components/session-selector.d.ts +2 -0
  20. package/dist/types/modes/components/tool-execution.d.ts +8 -1
  21. package/dist/types/modes/components/tool-execution.test.d.ts +1 -0
  22. package/dist/types/modes/interactive-mode.d.ts +6 -0
  23. package/dist/types/modes/noninteractive-dispose.test.d.ts +1 -0
  24. package/dist/types/modes/types.d.ts +10 -0
  25. package/dist/types/modes/utils/transcript-render-helpers.d.ts +2 -2
  26. package/dist/types/session/agent-session.d.ts +9 -1
  27. package/dist/types/session/streaming-output.d.ts +2 -0
  28. package/dist/types/slash-commands/types.d.ts +4 -4
  29. package/dist/types/system-prompt.d.ts +1 -1
  30. package/dist/types/tools/browser/registry.d.ts +7 -3
  31. package/dist/types/tools/browser/tab-supervisor.d.ts +2 -0
  32. package/dist/types/tools/index.d.ts +3 -1
  33. package/dist/types/tools/manage-skill.d.ts +2 -0
  34. package/dist/types/web/search/index.d.ts +2 -0
  35. package/dist/types/web/search/providers/base.d.ts +3 -0
  36. package/package.json +12 -12
  37. package/src/advisor/__tests__/advisor.test.ts +22 -12
  38. package/src/advisor/advise-tool.ts +7 -3
  39. package/src/cli/bench-cli.ts +4 -1
  40. package/src/cli/grep-cli.ts +2 -1
  41. package/src/cli-commands.ts +57 -23
  42. package/src/config/model-registry.ts +9 -0
  43. package/src/config/model-resolver.ts +43 -5
  44. package/src/config/models-config-schema.ts +1 -0
  45. package/src/config/settings-schema.ts +2 -1
  46. package/src/discovery/codex.ts +14 -4
  47. package/src/discovery/substitute-plugin-root.ts +23 -7
  48. package/src/export/html/template.js +2 -0
  49. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +73 -0
  50. package/src/internal-urls/agent-protocol.ts +73 -39
  51. package/src/lib/xai-http.ts +28 -2
  52. package/src/modes/acp/acp-agent.ts +28 -9
  53. package/src/modes/acp/acp-event-mapper.ts +8 -0
  54. package/src/modes/acp/acp-mode.ts +18 -4
  55. package/src/modes/components/assistant-message.ts +35 -14
  56. package/src/modes/components/chat-transcript-builder.ts +2 -0
  57. package/src/modes/components/custom-editor-plugin-ctor.test.ts +36 -0
  58. package/src/modes/components/custom-editor.ts +62 -1
  59. package/src/modes/components/hook-editor.ts +17 -2
  60. package/src/modes/components/login-dialog.test.ts +56 -0
  61. package/src/modes/components/login-dialog.ts +7 -3
  62. package/src/modes/components/plan-review-overlay.ts +51 -10
  63. package/src/modes/components/session-selector.ts +6 -0
  64. package/src/modes/components/status-line/component.ts +2 -4
  65. package/src/modes/components/tool-execution.test.ts +101 -0
  66. package/src/modes/components/tool-execution.ts +117 -9
  67. package/src/modes/components/transcript-container.ts +30 -2
  68. package/src/modes/controllers/command-controller-shared.ts +1 -1
  69. package/src/modes/controllers/command-controller.ts +1 -1
  70. package/src/modes/controllers/event-controller.ts +1 -0
  71. package/src/modes/controllers/selector-controller.ts +19 -17
  72. package/src/modes/interactive-mode.ts +66 -11
  73. package/src/modes/noninteractive-dispose.test.ts +60 -0
  74. package/src/modes/print-mode.ts +8 -3
  75. package/src/modes/rpc/rpc-mode.ts +12 -3
  76. package/src/modes/theme/theme.ts +2 -2
  77. package/src/modes/types.ts +10 -0
  78. package/src/modes/utils/interactive-context-helpers.ts +3 -1
  79. package/src/modes/utils/transcript-render-helpers.ts +3 -2
  80. package/src/prompts/system/system-prompt.md +1 -1
  81. package/src/sdk.ts +6 -2
  82. package/src/session/agent-session.ts +79 -34
  83. package/src/session/session-listing.ts +6 -5
  84. package/src/session/session-loader.ts +9 -0
  85. package/src/session/session-persistence.ts +11 -0
  86. package/src/session/streaming-output.ts +3 -1
  87. package/src/slash-commands/builtin-registry.ts +2 -0
  88. package/src/slash-commands/types.ts +4 -4
  89. package/src/system-prompt.ts +2 -2
  90. package/src/tools/browser/registry.ts +9 -2
  91. package/src/tools/browser/tab-supervisor.ts +65 -10
  92. package/src/tools/browser.ts +4 -3
  93. package/src/tools/eval.ts +3 -0
  94. package/src/tools/image-gen.ts +561 -512
  95. package/src/tools/index.ts +3 -1
  96. package/src/tools/manage-skill.ts +5 -3
  97. package/src/tools/output-meta.ts +7 -0
  98. package/src/tools/path-utils.ts +8 -5
  99. package/src/web/search/index.ts +21 -6
  100. package/src/web/search/providers/base.ts +3 -0
  101. package/src/web/search/providers/xai.ts +40 -9
@@ -8,7 +8,11 @@
8
8
  *
9
9
  * URL forms:
10
10
  * - agent://<id> - Full output content
11
- * - agent://<id>/<path> - JSON extraction via path form
11
+ * - agent://<id>/<child> - Nested subagent output (hierarchy separator; the
12
+ * registry allocates a subagent's own children as dot-qualified ids, so
13
+ * `agent://Parent/Child` resolves `Parent.Child.md`)
14
+ * - agent://<id>/<path> - JSON extraction via path form (fallback when no
15
+ * nested output matches the path)
12
16
  * - agent://<id>?q=<query> - JSON extraction via query form
13
17
  */
14
18
  import * as fs from "node:fs/promises";
@@ -44,65 +48,56 @@ export class AgentProtocolHandler implements ProtocolHandler {
44
48
  }
45
49
 
46
50
  const dirs = artifactsDirsFromRegistry();
47
-
48
51
  if (dirs.length === 0) {
49
52
  throw new Error("No session - agent outputs unavailable");
50
53
  }
51
54
 
52
- let foundPath: string | undefined;
53
- let anyDirExists = false;
54
- const availableIds = new Set<string>();
55
-
56
- for (const dir of dirs) {
57
- try {
58
- await fs.stat(dir);
59
- anyDirExists = true;
60
- } catch (err) {
61
- if (isEnoent(err)) continue;
62
- throw err;
63
- }
64
- const candidate = path.join(dir, `${outputId}.md`);
55
+ // A subagent allocates its own children as dot-qualified ids
56
+ // (`Parent.Child`), so the slash path form is first tried as a hierarchy
57
+ // separator: `agent://Parent/Child` resolves `Parent.Child.md`. Only when
58
+ // no such nested output exists does the path fall back to jq-style JSON
59
+ // extraction on `<outputId>.md`. Query form (`?q=`) is always extraction.
60
+ const pathSegments = hasPathExtraction ? urlPath.split("/").filter(Boolean) : [];
61
+ const decodedSegments = pathSegments.map(segment => {
65
62
  try {
66
- await fs.stat(candidate);
67
- foundPath = candidate;
68
- break;
69
- } catch (err) {
70
- if (!isEnoent(err)) throw err;
71
- try {
72
- const files = await fs.readdir(dir);
73
- for (const f of files) {
74
- if (f.endsWith(".md")) availableIds.add(f.replace(/\.md$/, ""));
75
- }
76
- } catch {
77
- // Listing failures are non-fatal; continue searching.
78
- }
63
+ return decodeURIComponent(segment);
64
+ } catch {
65
+ return segment;
79
66
  }
80
- }
67
+ });
68
+ const nestedId =
69
+ decodedSegments.length > 0 && decodedSegments.every(segment => !segment.includes("."))
70
+ ? [outputId, ...decodedSegments].join(".")
71
+ : undefined;
81
72
 
82
- if (!anyDirExists) {
73
+ const scan = await this.#findOutput(dirs, nestedId ? [nestedId, outputId] : [outputId]);
74
+ if (!scan.anyDirExists) {
83
75
  throw new Error("No artifacts directory found");
84
76
  }
85
-
86
- if (!foundPath) {
87
- const availableStr = availableIds.size > 0 ? [...availableIds].join(", ") : "none";
88
- throw new Error(`Not found: ${outputId}\nAvailable: ${availableStr}`);
77
+ if (!scan.foundPath) {
78
+ const target = nestedId ?? outputId;
79
+ const availableStr = scan.availableIds.size > 0 ? [...scan.availableIds].join(", ") : "none";
80
+ throw new Error(`Not found: ${target}\nAvailable: ${availableStr}`);
89
81
  }
90
82
 
91
- const rawContent = await Bun.file(foundPath).text();
83
+ const rawContent = await Bun.file(scan.foundPath).text();
92
84
  const notes: string[] = [];
93
85
  let content = rawContent;
94
86
  let contentType: InternalResource["contentType"] = "text/markdown";
95
87
 
96
- if (hasPathExtraction || hasQueryExtraction) {
88
+ // Extraction applies only when the URL did NOT resolve to a nested output
89
+ // (a slash that named a real child is a hierarchy hop, not a jq path).
90
+ const extract = hasQueryExtraction || (hasPathExtraction && scan.matchedId !== nestedId);
91
+ if (extract) {
97
92
  let jsonValue: unknown;
98
93
  try {
99
94
  jsonValue = JSON.parse(rawContent);
100
95
  } catch (err) {
101
96
  const message = err instanceof Error ? err.message : String(err);
102
- throw new Error(`Output ${outputId} is not valid JSON: ${message}`);
97
+ throw new Error(`Output ${scan.matchedId} is not valid JSON: ${message}`);
103
98
  }
104
99
 
105
- const query = hasPathExtraction ? pathToQuery(urlPath) : queryParam!;
100
+ const query = hasQueryExtraction ? queryParam! : pathToQuery(urlPath);
106
101
  if (query) {
107
102
  const extracted = applyQuery(jsonValue, query);
108
103
  try {
@@ -122,11 +117,50 @@ export class AgentProtocolHandler implements ProtocolHandler {
122
117
  content,
123
118
  contentType,
124
119
  size: Buffer.byteLength(content, "utf-8"),
125
- sourcePath: foundPath,
120
+ sourcePath: scan.foundPath,
126
121
  notes,
127
122
  };
128
123
  }
129
124
 
125
+ /**
126
+ * Scan every registered artifacts dir for the first `<id>.md` among
127
+ * `candidateIds` (tried in order, so a hierarchy match wins over the base
128
+ * id). Returns the resolved path and the id it matched, plus the set of
129
+ * available ids gathered from the scanned dirs for the not-found message.
130
+ */
131
+ async #findOutput(
132
+ dirs: string[],
133
+ candidateIds: string[],
134
+ ): Promise<{ foundPath?: string; matchedId?: string; anyDirExists: boolean; availableIds: Set<string> }> {
135
+ // Build a full id→path map across every registered dir before picking, so
136
+ // candidate priority is global: a nested id in a deeper dir must win over
137
+ // the base id even when the base id's dir is scanned first.
138
+ const byId = new Map<string, string>();
139
+ let anyDirExists = false;
140
+ for (const dir of dirs) {
141
+ let files: string[];
142
+ try {
143
+ files = await fs.readdir(dir);
144
+ } catch (err) {
145
+ if (isEnoent(err)) continue;
146
+ throw err;
147
+ }
148
+ anyDirExists = true;
149
+ for (const f of files) {
150
+ if (!f.endsWith(".md")) continue;
151
+ const id = f.slice(0, -3);
152
+ if (!byId.has(id)) byId.set(id, path.join(dir, f));
153
+ }
154
+ }
155
+ for (const id of candidateIds) {
156
+ const foundPath = byId.get(id);
157
+ if (foundPath) {
158
+ return { foundPath, matchedId: id, anyDirExists, availableIds: new Set(byId.keys()) };
159
+ }
160
+ }
161
+ return { anyDirExists, availableIds: new Set(byId.keys()) };
162
+ }
163
+
130
164
  async complete(): Promise<UrlCompletion[]> {
131
165
  const ids = new Set<string>();
132
166
  for (const dir of artifactsDirsFromRegistry()) {
@@ -16,7 +16,14 @@ export function ohMyPiXAIUserAgent(): string {
16
16
  return "oh-my-pi/xai";
17
17
  }
18
18
 
19
- type XAIProvider = "xai-oauth" | "xai";
19
+ /** xAI provider ids supported by shared HTTP tool transport resolution. */
20
+ export type XAIHttpProvider = "xai-oauth" | "xai";
21
+
22
+ /** Resolved endpoint and configured headers for an xAI HTTP tool request. */
23
+ export interface XAIHttpTransport {
24
+ baseURL: string;
25
+ headers?: Record<string, string>;
26
+ }
20
27
 
21
28
  /**
22
29
  * Resolve the HTTP base URL for an xAI tool call.
@@ -48,7 +55,11 @@ type XAIProvider = "xai-oauth" | "xai";
48
55
  * let xai-oauth entries hijack a xai tool call (or vice versa) when the
49
56
  * same model id ships under both descriptors.
50
57
  */
51
- function resolveXAIBaseURL(modelRegistry: ModelRegistry, provider: XAIProvider, modelId: string | undefined): string {
58
+ function resolveXAIBaseURL(
59
+ modelRegistry: ModelRegistry,
60
+ provider: XAIHttpProvider,
61
+ modelId: string | undefined,
62
+ ): string {
52
63
  if (modelId) {
53
64
  const merged = modelRegistry.getAll().find(m => m.id === modelId && m.provider === provider);
54
65
  if (merged?.baseUrl) {
@@ -68,6 +79,21 @@ function resolveXAIBaseURL(modelRegistry: ModelRegistry, provider: XAIProvider,
68
79
  }
69
80
  return ($env.XAI_BASE_URL || DEFAULT_BASE_URL).replace(/\/$/, "");
70
81
  }
82
+ /**
83
+ * Resolve an xAI tool endpoint and its provider/model header overrides.
84
+ */
85
+ export function resolveXAIHttpTransport(
86
+ modelRegistry: ModelRegistry,
87
+ provider: XAIHttpProvider,
88
+ modelId?: string,
89
+ ): XAIHttpTransport {
90
+ return {
91
+ baseURL: resolveXAIBaseURL(modelRegistry, provider, modelId),
92
+ headers:
93
+ (modelId ? modelRegistry.find(provider, modelId)?.headers : undefined) ??
94
+ modelRegistry.getProviderHeaders(provider),
95
+ };
96
+ }
71
97
 
72
98
  /**
73
99
  * Resolve xAI credentials for HTTP tool calls.
@@ -42,7 +42,7 @@ import {
42
42
  } from "@agentclientprotocol/sdk";
43
43
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
44
44
  import type { AssistantMessage, Model } from "@oh-my-pi/pi-ai";
45
- import { getBlobsDir, isEnoent, logger, VERSION } from "@oh-my-pi/pi-utils";
45
+ import { getBlobsDir, isEnoent, logger, type postmortem, VERSION } from "@oh-my-pi/pi-utils";
46
46
  import { disableProvider, enableProvider, reset as resetCapabilities } from "../../capability";
47
47
  import { Settings } from "../../config/settings";
48
48
  import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
@@ -1027,7 +1027,7 @@ export class AcpAgent implements Agent {
1027
1027
  this.#connection.signal.addEventListener(
1028
1028
  "abort",
1029
1029
  () => {
1030
- void this.#disposeAllSessions();
1030
+ void this.dispose();
1031
1031
  },
1032
1032
  { once: true },
1033
1033
  );
@@ -1912,6 +1912,7 @@ export class AcpAgent implements Agent {
1912
1912
  const projectPath = await resolveActiveProjectRegistryPath(cwd);
1913
1913
  clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined);
1914
1914
  resetCapabilities();
1915
+ await record.session.refreshSkills();
1915
1916
  const fileCommands = await loadSlashCommands({ cwd });
1916
1917
  record.session.setSlashCommands(fileCommands);
1917
1918
  await this.#emitAvailableCommandsUpdate(record);
@@ -2109,6 +2110,23 @@ export class AcpAgent implements Agent {
2109
2110
  });
2110
2111
  continue;
2111
2112
  }
2113
+ if (
2114
+ item.type === "image" &&
2115
+ "data" in item &&
2116
+ typeof item.data === "string" &&
2117
+ "mimeType" in item &&
2118
+ typeof item.mimeType === "string"
2119
+ ) {
2120
+ notifications.push({
2121
+ sessionId,
2122
+ update: {
2123
+ sessionUpdate: "agent_message_chunk",
2124
+ content: { type: "image", data: item.data, mimeType: item.mimeType },
2125
+ messageId,
2126
+ },
2127
+ });
2128
+ continue;
2129
+ }
2112
2130
  if (item.type === "thinking" && "thinking" in item && typeof item.thinking === "string") {
2113
2131
  const thinking = canonicalizeMessage(item.thinking);
2114
2132
  if (thinking.length === 0) continue;
@@ -2443,7 +2461,7 @@ export class AcpAgent implements Agent {
2443
2461
  }
2444
2462
  }
2445
2463
 
2446
- async #disposeSessionRecord(record: ManagedSessionRecord): Promise<void> {
2464
+ async #disposeSessionRecord(record: ManagedSessionRecord, reason?: postmortem.Reason): Promise<void> {
2447
2465
  record.lifetimeUnsubscribe?.();
2448
2466
  if (record.mcpManager) {
2449
2467
  try {
@@ -2454,21 +2472,22 @@ export class AcpAgent implements Agent {
2454
2472
  record.mcpManager = undefined;
2455
2473
  }
2456
2474
  try {
2457
- await record.session.dispose();
2475
+ await record.session.dispose({ reason });
2458
2476
  } catch (error) {
2459
2477
  logger.warn("Failed to dispose ACP session", { error });
2460
2478
  }
2461
2479
  }
2462
2480
 
2463
- async #disposeStandaloneSession(session: AgentSession): Promise<void> {
2481
+ async #disposeStandaloneSession(session: AgentSession, reason?: postmortem.Reason): Promise<void> {
2464
2482
  try {
2465
- await session.dispose();
2483
+ await session.dispose({ reason });
2466
2484
  } catch (error) {
2467
2485
  logger.warn("Failed to dispose ACP session", { error });
2468
2486
  }
2469
2487
  }
2470
2488
 
2471
- async #disposeAllSessions(): Promise<void> {
2489
+ /** Dispose every session owned by this ACP connection and await persisted teardown. */
2490
+ async dispose(reason?: postmortem.Reason): Promise<void> {
2472
2491
  if (this.#disposePromise) {
2473
2492
  await this.#disposePromise;
2474
2493
  return;
@@ -2484,7 +2503,7 @@ export class AcpAgent implements Agent {
2484
2503
  "ACP agent disposed before queued prompt could run",
2485
2504
  );
2486
2505
  await this.#cancelPromptForClose(record);
2487
- await this.#disposeSessionRecord(record);
2506
+ await this.#disposeSessionRecord(record, reason);
2488
2507
  } catch (error) {
2489
2508
  logger.warn("Failed to clean up ACP session", { sessionId, error });
2490
2509
  }
@@ -2494,7 +2513,7 @@ export class AcpAgent implements Agent {
2494
2513
  const initialSession = this.#initialSession;
2495
2514
  this.#initialSession = undefined;
2496
2515
  if (initialSession) {
2497
- await this.#disposeStandaloneSession(initialSession);
2516
+ await this.#disposeStandaloneSession(initialSession, reason);
2498
2517
  }
2499
2518
  })();
2500
2519
 
@@ -272,6 +272,14 @@ function mapAssistantMessageUpdate(
272
272
  let text: string;
273
273
  const progress = options.getMessageProgress?.(event.message);
274
274
  switch (event.assistantMessageEvent.type) {
275
+ case "image_end":
276
+ return [
277
+ toSessionNotification(sessionId, {
278
+ sessionUpdate: "agent_message_chunk",
279
+ content: event.assistantMessageEvent.content,
280
+ messageId: options.getMessageId?.(event.message),
281
+ }),
282
+ ];
275
283
  case "text_delta":
276
284
  sessionUpdate = "agent_message_chunk";
277
285
  text = event.assistantMessageEvent.delta;
@@ -1,19 +1,28 @@
1
1
  import * as stream from "node:stream";
2
2
  import { AgentSideConnection, ndJsonStream, type Stream } from "@agentclientprotocol/sdk";
3
+ import { postmortem } from "@oh-my-pi/pi-utils";
3
4
  import type { AgentSession } from "../../session/agent-session";
4
5
  import { AcpAgent } from "./acp-agent";
5
6
 
7
+ /** Creates sessions requested by an ACP client. */
6
8
  export type AcpSessionFactory = (cwd: string) => Promise<AgentSession>;
7
9
 
10
+ /** Creates an ACP connection and exposes its agent when process-level teardown must own it. */
8
11
  export function createAcpConnection(
9
12
  transport: Stream,
10
13
  createSession: AcpSessionFactory,
11
14
  initialSession?: AgentSession,
15
+ onAgent?: (agent: AcpAgent) => void,
12
16
  ): AgentSideConnection {
13
- return new AgentSideConnection(conn => new AcpAgent(conn, createSession, initialSession), transport);
17
+ return new AgentSideConnection(connection => {
18
+ const agent = new AcpAgent(connection, createSession, initialSession);
19
+ onAgent?.(agent);
20
+ return agent;
21
+ }, transport);
14
22
  }
15
23
 
16
- export async function runAcpMode(createSession: AcpSessionFactory, initialSession?: AgentSession): Promise<never> {
24
+ /** Serves ACP over stdio until the peer disconnects, then awaits session teardown before exit. */
25
+ export async function runAcpMode(createSession: AcpSessionFactory, initialSession?: AgentSession): Promise<void> {
17
26
  // Humans who run `omp acp` by hand see a silent process and assume it is
18
27
  // broken (stdout is the JSON-RPC transport, so nothing may be printed
19
28
  // there). When stdin is a TTY no ACP client is attached — say so on stderr
@@ -25,10 +34,15 @@ export async function runAcpMode(createSession: AcpSessionFactory, initialSessio
25
34
  "Waiting for protocol frames on stdin; logs: ~/.omp/logs/\n",
26
35
  );
27
36
  }
37
+ let agent: AcpAgent | undefined;
38
+ postmortem.register("acp-session-teardown", reason => agent?.dispose(reason));
39
+ postmortem.registerStdioDisconnectHandling();
28
40
  const input = stream.Writable.toWeb(process.stdout);
29
41
  const output = stream.Readable.toWeb(process.stdin);
30
42
  const transport = ndJsonStream(input, output);
31
- const connection = createAcpConnection(transport, createSession, initialSession);
43
+ const connection = createAcpConnection(transport, createSession, initialSession, createdAgent => {
44
+ agent = createdAgent;
45
+ });
32
46
  await connection.closed;
33
- process.exit(0);
47
+ await postmortem.quit(0);
34
48
  }
@@ -173,6 +173,7 @@ export class AssistantMessageComponent extends Container {
173
173
  #lastMessage?: AssistantMessage;
174
174
  #toolImagesByCallId = new Map<string, ImageContent[]>();
175
175
  #convertedKittyImages = new Map<string, ImageContent>();
176
+ #showImages = true;
176
177
  #kittyConversionsInFlight = new Set<string>();
177
178
  #transcriptBlockFinalized: boolean;
178
179
  /**
@@ -497,6 +498,15 @@ export class AssistantMessageComponent extends Container {
497
498
  }
498
499
  }
499
500
 
501
+ /** Toggle rendering for assistant-native and tool-result images. */
502
+ setImagesVisible(visible: boolean): void {
503
+ if (this.#showImages === visible) return;
504
+ this.#showImages = visible;
505
+ if (this.#lastMessage) {
506
+ this.updateContent(this.#lastMessage, { transient: this.#lastUpdateTransient });
507
+ }
508
+ }
509
+
500
510
  setToolResultImages(toolCallId: string, images: ImageContent[]): void {
501
511
  if (!toolCallId) return;
502
512
  const validImages = images.filter(img => img.type === "image" && img.data && img.mimeType);
@@ -514,19 +524,17 @@ export class AssistantMessageComponent extends Container {
514
524
  this.#toolImagesByCallId.delete(toolCallId);
515
525
  } else {
516
526
  this.#toolImagesByCallId.set(toolCallId, validImages);
517
- this.#convertToolImagesForKitty(toolCallId, validImages);
527
+ this.#convertImagesForKitty(validImages.map((image, index) => ({ image, key: `${toolCallId}:${index}` })));
518
528
  }
519
529
  if (this.#lastMessage) {
520
530
  this.updateContent(this.#lastMessage, { transient: this.#lastUpdateTransient });
521
531
  }
522
532
  }
523
533
 
524
- #convertToolImagesForKitty(toolCallId: string, images: ImageContent[]): void {
534
+ #convertImagesForKitty(entries: Array<{ image: ImageContent; key: string }>): void {
525
535
  if (TERMINAL.imageProtocol !== ImageProtocol.Kitty) return;
526
- for (let index = 0; index < images.length; index++) {
527
- const image = images[index];
528
- if (!image || image.mimeType === "image/png") continue;
529
- const key = `${toolCallId}:${index}`;
536
+ for (const { image, key } of entries) {
537
+ if (image.mimeType === "image/png") continue;
530
538
  if (this.#convertedKittyImages.has(key) || this.#kittyConversionsInFlight.has(key)) continue;
531
539
  this.#kittyConversionsInFlight.add(key);
532
540
  new Bun.Image(Buffer.from(image.data, "base64"))
@@ -550,14 +558,12 @@ export class AssistantMessageComponent extends Container {
550
558
  }
551
559
  }
552
560
 
553
- #renderToolImages(): void {
554
- const imageEntries = Array.from(this.#toolImagesByCallId.entries()).flatMap(([toolCallId, images]) =>
555
- images.map((image, index) => ({ image, key: `${toolCallId}:${index}` })),
556
- );
557
- if (imageEntries.length === 0) return;
561
+ #renderImageEntries(entries: Array<{ image: ImageContent; key: string }>, withLeadingSpacer: boolean): void {
562
+ if (!this.#showImages || entries.length === 0) return;
563
+ this.#convertImagesForKitty(entries);
558
564
 
559
- this.#contentContainer.addChild(new Spacer(1));
560
- for (const { image, key } of imageEntries) {
565
+ if (withLeadingSpacer) this.#contentContainer.addChild(new Spacer(1));
566
+ for (const { image, key } of entries) {
561
567
  const displayImage =
562
568
  TERMINAL.imageProtocol === ImageProtocol.Kitty && image.mimeType !== "image/png"
563
569
  ? this.#convertedKittyImages.get(key)
@@ -577,6 +583,13 @@ export class AssistantMessageComponent extends Container {
577
583
  }
578
584
  }
579
585
 
586
+ #renderToolImages(): void {
587
+ const entries = Array.from(this.#toolImagesByCallId.entries()).flatMap(([toolCallId, images]) =>
588
+ images.map((image, index) => ({ image, key: `${toolCallId}:${index}` })),
589
+ );
590
+ this.#renderImageEntries(entries, true);
591
+ }
592
+
580
593
  #appendThinkingExtensions(contentIndex: number, thinkingIndex: number, text: string): void {
581
594
  for (const renderer of this.thinkingRenderers) {
582
595
  try {
@@ -620,7 +633,7 @@ export class AssistantMessageComponent extends Container {
620
633
 
621
634
  #canFastPath(message: AssistantMessage): boolean {
622
635
  for (const content of message.content) {
623
- if (content.type === "toolCall") return false;
636
+ if (content.type === "toolCall" || content.type === "image") return false;
624
637
  }
625
638
  if (this.#toolImagesByCallId.size > 0) return false;
626
639
  const errorPresentation = resolveAssistantErrorPresentation(message);
@@ -769,6 +782,7 @@ export class AssistantMessageComponent extends Container {
769
782
  const hasVisibleContent = message.content.some(
770
783
  c =>
771
784
  (c.type === "text" && canonicalizeMessage(c.text)) ||
785
+ (c.type === "image" && c.data && c.mimeType) ||
772
786
  (!this.hideThinkingBlock &&
773
787
  c.type === "thinking" &&
774
788
  resolveThinkingDisplay(c, this.proseOnlyThinking).visible),
@@ -776,6 +790,7 @@ export class AssistantMessageComponent extends Container {
776
790
 
777
791
  // Render content in order
778
792
  let thinkingIndex = 0;
793
+ let hasRenderedContent = false;
779
794
  for (let i = 0; i < message.content.length; i++) {
780
795
  const content = message.content[i];
781
796
  if (content.type === "text" && canonicalizeMessage(content.text)) {
@@ -785,6 +800,7 @@ export class AssistantMessageComponent extends Container {
785
800
  md.transientRenderCache = this.#lastUpdateTransient;
786
801
  this.#contentContainer.addChild(md);
787
802
  captureItems?.push({ md, contentIndex: i, blockType: "text", lastText: trimmed });
803
+ hasRenderedContent = true;
788
804
  } else if (content.type === "thinking" && resolveThinkingDisplay(content, this.proseOnlyThinking).visible) {
789
805
  const thinkingText = resolveThinkingDisplay(content, this.proseOnlyThinking).text;
790
806
  if (this.hideThinkingBlock) {
@@ -798,6 +814,7 @@ export class AssistantMessageComponent extends Container {
798
814
  .some(
799
815
  c =>
800
816
  (c.type === "text" && canonicalizeMessage(c.text)) ||
817
+ (c.type === "image" && c.data && c.mimeType) ||
801
818
  (c.type === "thinking" && resolveThinkingDisplay(c, this.proseOnlyThinking).visible),
802
819
  );
803
820
 
@@ -810,10 +827,14 @@ export class AssistantMessageComponent extends Container {
810
827
  this.#contentContainer.addChild(md);
811
828
  captureItems?.push({ md, contentIndex: i, blockType: "thinking", lastText: thinkingText });
812
829
  this.#appendThinkingExtensions(i, thinkingIndex, thinkingText);
830
+ hasRenderedContent = true;
813
831
  thinkingIndex += 1;
814
832
  if (hasVisibleContentAfter) {
815
833
  this.#contentContainer.addChild(new Spacer(1));
816
834
  }
835
+ } else if (content.type === "image" && content.data && content.mimeType) {
836
+ this.#renderImageEntries([{ image: content, key: `native:${i}` }], hasRenderedContent);
837
+ hasRenderedContent ||= this.#showImages;
817
838
  }
818
839
  }
819
840
 
@@ -283,6 +283,7 @@ export class ChatTranscriptBuilder {
283
283
  this.deps.ui.imageBudget,
284
284
  proseOnlyThinking,
285
285
  );
286
+ assistantComponent.setImagesVisible(settings.get("terminal.showImages"));
286
287
  this.container.addChild(assistantComponent);
287
288
 
288
289
  if (settings.get("display.cacheMissMarker")) {
@@ -313,6 +314,7 @@ export class ChatTranscriptBuilder {
313
314
  undefined,
314
315
  proseOnlyThinking,
315
316
  );
317
+ component.setImagesVisible(settings.get("terminal.showImages"));
316
318
  this.container.addChild(component);
317
319
  };
318
320
 
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { ProcessTerminal, TUI } from "@oh-my-pi/pi-tui";
3
+ import { getEditorTheme, initTheme } from "../theme/theme";
4
+ import { CustomEditor } from "./custom-editor";
5
+
6
+ /**
7
+ * Regression for issue #4766: plugins written against upstream pi subclass
8
+ * `CustomEditor`/`Editor` and forward `super(tui, theme, keybindings)`. omp's
9
+ * `setEditorComponent` factory contract advertises exactly that arg order, so
10
+ * the base constructor must resolve the real theme by shape (not position) or
11
+ * every render throws `undefined is not an object (evaluating
12
+ * 'this.#theme.symbols.boxRound')`.
13
+ */
14
+ describe("CustomEditor upstream-pi constructor compatibility (#4766)", () => {
15
+ it("renders when constructed as (tui, theme, keybindings)", async () => {
16
+ await initTheme();
17
+ const tui = new TUI(new ProcessTerminal());
18
+ const editor = new CustomEditor(tui, getEditorTheme(), {});
19
+ editor.setText("run this workflow");
20
+ expect(() => editor.render(80)).not.toThrow();
21
+ // The rounded border glyphs from the resolved theme must reach the frame.
22
+ const frame = editor.render(80).join("\n");
23
+ expect(frame).toContain(getEditorTheme().symbols.boxRound.horizontal);
24
+ // The leading TUI is captured so plugin overrides calling
25
+ // `this.tui.requestRender()` keep working.
26
+ expect(editor.tui).toBe(tui);
27
+ });
28
+
29
+ it("still accepts omp's own (theme) constructor", async () => {
30
+ await initTheme();
31
+ const editor = new CustomEditor(getEditorTheme());
32
+ editor.setText("hello");
33
+ expect(() => editor.render(80)).not.toThrow();
34
+ expect(editor.tui).toBeUndefined();
35
+ });
36
+ });
@@ -1,6 +1,15 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import type { ImageContent } from "@oh-my-pi/pi-ai";
3
- import { addKeyAliases, canonicalKeyId, Editor, type KeyId, parseKey, parseKittySequence } from "@oh-my-pi/pi-tui";
3
+ import {
4
+ addKeyAliases,
5
+ canonicalKeyId,
6
+ Editor,
7
+ type EditorTheme,
8
+ type KeyId,
9
+ parseKey,
10
+ parseKittySequence,
11
+ TUI,
12
+ } from "@oh-my-pi/pi-tui";
4
13
  import { BracketedPasteHandler } from "@oh-my-pi/pi-tui/bracketed-paste";
5
14
  import type { AppKeybinding } from "../../config/keybindings";
6
15
  import { isSettingsInitialized, settings } from "../../config/settings";
@@ -284,6 +293,31 @@ export function extractImagePathFromText(text: string): string | undefined {
284
293
  return undefined;
285
294
  }
286
295
 
296
+ /**
297
+ * Resolve the {@link EditorTheme} from a `CustomEditor`/`Editor` constructor
298
+ * argument list, tolerating both the omp `(theme)` and upstream-pi
299
+ * `(tui, theme, keybindings)` conventions (see {@link CustomEditor}'s
300
+ * constructor). A real `EditorTheme` is identified structurally — it exposes a
301
+ * `borderColor` function and a `symbols` object — so a `TUI` passed in the first
302
+ * slot is skipped rather than mistaken for the theme.
303
+ */
304
+ function pickEditorTheme(args: readonly unknown[]): EditorTheme {
305
+ for (const arg of args) {
306
+ if (isEditorTheme(arg)) return arg;
307
+ }
308
+ // Fall back to the first argument so a caller passing a bare theme that
309
+ // somehow fails the shape probe still reaches the base constructor.
310
+ return args[0] as EditorTheme;
311
+ }
312
+
313
+ function isEditorTheme(value: unknown): value is EditorTheme {
314
+ if (typeof value !== "object" || value === null) return false;
315
+ const candidate = value as Partial<EditorTheme>;
316
+ return (
317
+ typeof candidate.borderColor === "function" && typeof candidate.symbols === "object" && candidate.symbols !== null
318
+ );
319
+ }
320
+
287
321
  /**
288
322
  * Custom editor that handles configurable app-level shortcuts for coding-agent.
289
323
  */
@@ -297,6 +331,33 @@ export class CustomEditor extends Editor {
297
331
  * `undefined` entries are images without a backing reference yet. */
298
332
  pendingImageLinks: (string | undefined)[] = [];
299
333
 
334
+ /**
335
+ * The host {@link TUI}, captured when a plugin constructs this editor through
336
+ * the upstream-pi `(tui, theme, keybindings)` convention. Undefined for omp's
337
+ * own `new CustomEditor(theme)` callers (they drive repaints through the
338
+ * interactive-mode wiring instead). Plugins that call `this.tui.requestRender()`
339
+ * in their overrides read it here (issue #4766).
340
+ */
341
+ tui?: TUI;
342
+
343
+ /**
344
+ * Accept both the omp constructor convention — `new CustomEditor(theme)` —
345
+ * and the upstream-pi `Editor` convention — `new Editor(tui, theme, keybindings)`
346
+ * — that {@link ExtensionUIContext.setEditorComponent}'s factory contract
347
+ * advertises `(tui, theme, keybindings)`. Plugins written against upstream pi
348
+ * subclass `CustomEditor`/`Editor` and forward `super(tui, theme, keybindings)`;
349
+ * without this shim the `TUI` lands in the `theme` slot and every render throws
350
+ * `undefined is not an object (evaluating 'this.#theme.symbols.boxRound')`
351
+ * (issue #4766). We locate the real {@link EditorTheme} among the args by shape
352
+ * (it carries `symbols`/`borderColor`) rather than by position, and capture a
353
+ * leading {@link TUI} so plugin overrides calling `this.tui.requestRender()`
354
+ * keep working.
355
+ */
356
+ constructor(...args: readonly unknown[]) {
357
+ super(pickEditorTheme(args));
358
+ if (args[0] instanceof TUI) this.tui = args[0];
359
+ }
360
+
300
361
  /** Clear the composer draft: optionally commit `historyText` to history, then
301
362
  * reset the editor text and all pending draft-image state. The shared tail of
302
363
  * every "message submitted" path; pass no argument for a plain discard. */