@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.
- package/CHANGELOG.md +56 -0
- package/dist/cli.js +3305 -3302
- package/dist/types/advisor/advise-tool.d.ts +5 -2
- package/dist/types/cli-commands.d.ts +14 -1
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/models-config-schema.d.ts +10 -0
- package/dist/types/config/models-config.d.ts +6 -0
- package/dist/types/config/settings-schema.d.ts +1 -1
- package/dist/types/discovery/substitute-plugin-root.d.ts +20 -7
- package/dist/types/internal-urls/agent-protocol.d.ts +1 -0
- package/dist/types/lib/xai-http.d.ts +11 -0
- package/dist/types/modes/acp/acp-agent.d.ts +3 -0
- package/dist/types/modes/acp/acp-mode.d.ts +6 -2
- package/dist/types/modes/components/assistant-message.d.ts +2 -0
- package/dist/types/modes/components/custom-editor-plugin-ctor.test.d.ts +1 -0
- package/dist/types/modes/components/custom-editor.d.ts +23 -1
- package/dist/types/modes/components/hook-editor.d.ts +9 -2
- package/dist/types/modes/components/login-dialog.test.d.ts +1 -0
- package/dist/types/modes/components/session-selector.d.ts +2 -0
- package/dist/types/modes/components/tool-execution.d.ts +8 -1
- package/dist/types/modes/components/tool-execution.test.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +6 -0
- package/dist/types/modes/noninteractive-dispose.test.d.ts +1 -0
- package/dist/types/modes/types.d.ts +10 -0
- package/dist/types/modes/utils/transcript-render-helpers.d.ts +2 -2
- package/dist/types/session/agent-session.d.ts +9 -1
- package/dist/types/session/streaming-output.d.ts +2 -0
- package/dist/types/slash-commands/types.d.ts +4 -4
- package/dist/types/system-prompt.d.ts +1 -1
- package/dist/types/tools/browser/registry.d.ts +7 -3
- package/dist/types/tools/browser/tab-supervisor.d.ts +2 -0
- package/dist/types/tools/index.d.ts +3 -1
- package/dist/types/tools/manage-skill.d.ts +2 -0
- package/dist/types/web/search/index.d.ts +2 -0
- package/dist/types/web/search/providers/base.d.ts +3 -0
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +22 -12
- package/src/advisor/advise-tool.ts +7 -3
- package/src/cli/bench-cli.ts +4 -1
- package/src/cli/grep-cli.ts +2 -1
- package/src/cli-commands.ts +57 -23
- package/src/config/model-registry.ts +9 -0
- package/src/config/model-resolver.ts +43 -5
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings-schema.ts +2 -1
- package/src/discovery/codex.ts +14 -4
- package/src/discovery/substitute-plugin-root.ts +23 -7
- package/src/export/html/template.js +2 -0
- package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +73 -0
- package/src/internal-urls/agent-protocol.ts +73 -39
- package/src/lib/xai-http.ts +28 -2
- package/src/modes/acp/acp-agent.ts +28 -9
- package/src/modes/acp/acp-event-mapper.ts +8 -0
- package/src/modes/acp/acp-mode.ts +18 -4
- package/src/modes/components/assistant-message.ts +35 -14
- package/src/modes/components/chat-transcript-builder.ts +2 -0
- package/src/modes/components/custom-editor-plugin-ctor.test.ts +36 -0
- package/src/modes/components/custom-editor.ts +62 -1
- package/src/modes/components/hook-editor.ts +17 -2
- package/src/modes/components/login-dialog.test.ts +56 -0
- package/src/modes/components/login-dialog.ts +7 -3
- package/src/modes/components/plan-review-overlay.ts +51 -10
- package/src/modes/components/session-selector.ts +6 -0
- package/src/modes/components/status-line/component.ts +2 -4
- package/src/modes/components/tool-execution.test.ts +101 -0
- package/src/modes/components/tool-execution.ts +117 -9
- package/src/modes/components/transcript-container.ts +30 -2
- package/src/modes/controllers/command-controller-shared.ts +1 -1
- package/src/modes/controllers/command-controller.ts +1 -1
- package/src/modes/controllers/event-controller.ts +1 -0
- package/src/modes/controllers/selector-controller.ts +19 -17
- package/src/modes/interactive-mode.ts +66 -11
- package/src/modes/noninteractive-dispose.test.ts +60 -0
- package/src/modes/print-mode.ts +8 -3
- package/src/modes/rpc/rpc-mode.ts +12 -3
- package/src/modes/theme/theme.ts +2 -2
- package/src/modes/types.ts +10 -0
- package/src/modes/utils/interactive-context-helpers.ts +3 -1
- package/src/modes/utils/transcript-render-helpers.ts +3 -2
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/sdk.ts +6 -2
- package/src/session/agent-session.ts +79 -34
- package/src/session/session-listing.ts +6 -5
- package/src/session/session-loader.ts +9 -0
- package/src/session/session-persistence.ts +11 -0
- package/src/session/streaming-output.ts +3 -1
- package/src/slash-commands/builtin-registry.ts +2 -0
- package/src/slash-commands/types.ts +4 -4
- package/src/system-prompt.ts +2 -2
- package/src/tools/browser/registry.ts +9 -2
- package/src/tools/browser/tab-supervisor.ts +65 -10
- package/src/tools/browser.ts +4 -3
- package/src/tools/eval.ts +3 -0
- package/src/tools/image-gen.ts +561 -512
- package/src/tools/index.ts +3 -1
- package/src/tools/manage-skill.ts +5 -3
- package/src/tools/output-meta.ts +7 -0
- package/src/tools/path-utils.ts +8 -5
- package/src/web/search/index.ts +21 -6
- package/src/web/search/providers/base.ts +3 -0
- 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>/<
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
const availableStr = availableIds.size > 0 ? [...availableIds].join(", ") : "none";
|
|
88
|
-
throw new Error(`Not found: ${
|
|
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
|
-
|
|
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 ${
|
|
97
|
+
throw new Error(`Output ${scan.matchedId} is not valid JSON: ${message}`);
|
|
103
98
|
}
|
|
104
99
|
|
|
105
|
-
const query =
|
|
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()) {
|
package/src/lib/xai-http.ts
CHANGED
|
@@ -16,7 +16,14 @@ export function ohMyPiXAIUserAgent(): string {
|
|
|
16
16
|
return "oh-my-pi/xai";
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.#
|
|
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
|
-
#
|
|
534
|
+
#convertImagesForKitty(entries: Array<{ image: ImageContent; key: string }>): void {
|
|
525
535
|
if (TERMINAL.imageProtocol !== ImageProtocol.Kitty) return;
|
|
526
|
-
for (
|
|
527
|
-
|
|
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
|
-
#
|
|
554
|
-
|
|
555
|
-
|
|
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
|
|
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 {
|
|
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. */
|