@ikuma.cloud/pix-mcp 0.0.6 → 0.0.8
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/README.md +83 -29
- package/mcp.schema.json +1 -1
- package/package.json +4 -2
- package/src/catalog.ts +1 -1
- package/src/client.ts +268 -13
- package/src/index.ts +259 -6
- package/src/output.ts +162 -29
- package/src/prompt-picker.ts +120 -0
- package/src/prompts.ts +382 -0
package/src/index.ts
CHANGED
|
@@ -2,13 +2,30 @@ import { resolve } from "node:path";
|
|
|
2
2
|
import { Type } from "@earendil-works/pi-ai";
|
|
3
3
|
import type {
|
|
4
4
|
ExtensionAPI,
|
|
5
|
+
ExtensionCommandContext,
|
|
5
6
|
ExtensionContext,
|
|
6
7
|
} from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
8
|
+
import type { Prompt, Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
8
9
|
import { compact, entry, search, summary, type Entry } from "./catalog.ts";
|
|
9
10
|
import { Connection } from "./client.ts";
|
|
10
11
|
import { readConfig, type ConfigIssue, type ServerConfig } from "./config.ts";
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
formatEditableContent,
|
|
14
|
+
formatResult,
|
|
15
|
+
removeEditablePromptGuard,
|
|
16
|
+
} from "./output.ts";
|
|
17
|
+
import { pickPrompt } from "./prompt-picker.ts";
|
|
18
|
+
import {
|
|
19
|
+
formatPromptList,
|
|
20
|
+
formatPromptResult,
|
|
21
|
+
parsePromptCommand,
|
|
22
|
+
promptCompletions,
|
|
23
|
+
promptKey,
|
|
24
|
+
resolvePromptArguments,
|
|
25
|
+
type PromptArgumentToken,
|
|
26
|
+
type PromptCommand,
|
|
27
|
+
type PromptEntry,
|
|
28
|
+
} from "./prompts.ts";
|
|
12
29
|
import { prepareSchema } from "./schema.ts";
|
|
13
30
|
import { ToolRejectionError, type RejectionCode } from "./rejection.ts";
|
|
14
31
|
|
|
@@ -22,14 +39,22 @@ interface Rejections {
|
|
|
22
39
|
}[];
|
|
23
40
|
}
|
|
24
41
|
|
|
42
|
+
interface PromptSelection {
|
|
43
|
+
command: PromptCommand;
|
|
44
|
+
item: PromptEntry;
|
|
45
|
+
}
|
|
46
|
+
|
|
25
47
|
interface State {
|
|
26
48
|
alive: boolean;
|
|
27
49
|
status: string;
|
|
28
50
|
entries: Map<string, Entry>;
|
|
51
|
+
prompts: Map<string, PromptEntry>;
|
|
29
52
|
loaded: Map<string, string>;
|
|
30
53
|
connections: Map<string, Connection>;
|
|
31
54
|
rejected: Map<string, Rejections>;
|
|
32
55
|
configIssues: ConfigIssue[];
|
|
56
|
+
promptDraftCleanups: Set<() => Promise<void>>;
|
|
57
|
+
promptDraftGuards: Set<string>;
|
|
33
58
|
}
|
|
34
59
|
|
|
35
60
|
const discoveryParameters = Type.Object(
|
|
@@ -62,6 +87,175 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
62
87
|
description:
|
|
63
88
|
"Read this MCP config file for this session (default: .mcp.json).",
|
|
64
89
|
});
|
|
90
|
+
pi.registerCommand("mcp-prompt", {
|
|
91
|
+
description: "Select, list, or run a user-controlled MCP prompt",
|
|
92
|
+
getArgumentCompletions: (prefix) =>
|
|
93
|
+
promptCompletions(prefix, [...(state?.prompts.values() ?? [])]),
|
|
94
|
+
async handler(input, ctx) {
|
|
95
|
+
try {
|
|
96
|
+
const owner = state;
|
|
97
|
+
if (!owner) throw new Error("MCP session has not started.");
|
|
98
|
+
current(owner);
|
|
99
|
+
const editBeforeSending = ctx.mode === "tui" && input.trim() === "";
|
|
100
|
+
const selection = editBeforeSending
|
|
101
|
+
? await choosePrompt(owner, ctx)
|
|
102
|
+
: undefined;
|
|
103
|
+
const command =
|
|
104
|
+
selection?.command ??
|
|
105
|
+
(editBeforeSending ? undefined : parsePromptCommand(input));
|
|
106
|
+
if (!command) return;
|
|
107
|
+
if (command.action === "list") {
|
|
108
|
+
if (
|
|
109
|
+
command.server &&
|
|
110
|
+
!owner.connections.has(command.server) &&
|
|
111
|
+
!owner.configIssues.some((issue) => issue.name === command.server)
|
|
112
|
+
)
|
|
113
|
+
throw new Error("Unknown MCP server.");
|
|
114
|
+
const promptStatus = [
|
|
115
|
+
...owner.configIssues
|
|
116
|
+
.filter(
|
|
117
|
+
(issue) => !command.server || issue.name === command.server,
|
|
118
|
+
)
|
|
119
|
+
.map((issue) => `${issue.name}: ${issue.message}`),
|
|
120
|
+
...[...owner.connections.values()]
|
|
121
|
+
.filter(
|
|
122
|
+
(connection) =>
|
|
123
|
+
!command.server || connection.config.name === command.server,
|
|
124
|
+
)
|
|
125
|
+
.map(
|
|
126
|
+
(connection) =>
|
|
127
|
+
`${connection.config.name}: ${connection.promptStatus}`,
|
|
128
|
+
),
|
|
129
|
+
].join("\n");
|
|
130
|
+
const text = `${promptStatus}${promptStatus ? "\n\n" : ""}${formatPromptList(
|
|
131
|
+
[...owner.prompts.values()],
|
|
132
|
+
command.server,
|
|
133
|
+
)}`;
|
|
134
|
+
if (!ctx.hasUI) throw new Error(text);
|
|
135
|
+
ctx.ui.notify(text, "info");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const key = promptKey(command.server, command.name);
|
|
139
|
+
const item = selection?.item ?? owner.prompts.get(key);
|
|
140
|
+
if (!item)
|
|
141
|
+
throw new Error(
|
|
142
|
+
"Unknown or unavailable MCP prompt. List prompts again.",
|
|
143
|
+
);
|
|
144
|
+
const ensureSelectedPromptCurrent = () => {
|
|
145
|
+
if (selection && owner.prompts.get(key) !== item)
|
|
146
|
+
throw new Error(
|
|
147
|
+
"MCP prompt catalog changed. Open the picker again.",
|
|
148
|
+
);
|
|
149
|
+
};
|
|
150
|
+
ensureSelectedPromptCurrent();
|
|
151
|
+
const connection = owner.connections.get(command.server);
|
|
152
|
+
if (!connection) throw new Error("MCP connection unavailable.");
|
|
153
|
+
const args = resolvePromptArguments(
|
|
154
|
+
item.prompt,
|
|
155
|
+
command.argumentTokens,
|
|
156
|
+
);
|
|
157
|
+
if (ctx.mode === "tui")
|
|
158
|
+
ctx.ui.setStatus(
|
|
159
|
+
"mcp-prompt",
|
|
160
|
+
`Loading ${item.server} / ${item.prompt.name}…`,
|
|
161
|
+
);
|
|
162
|
+
try {
|
|
163
|
+
const result = await connection.getPrompt(
|
|
164
|
+
item.prompt.name,
|
|
165
|
+
args,
|
|
166
|
+
ctx.signal,
|
|
167
|
+
);
|
|
168
|
+
current(owner);
|
|
169
|
+
ensureSelectedPromptCurrent();
|
|
170
|
+
const formatted = await formatPromptResult(result);
|
|
171
|
+
current(owner);
|
|
172
|
+
ensureSelectedPromptCurrent();
|
|
173
|
+
ctx.signal?.throwIfAborted();
|
|
174
|
+
if (editBeforeSending) {
|
|
175
|
+
const draft = await formatEditableContent(formatted.content);
|
|
176
|
+
try {
|
|
177
|
+
current(owner);
|
|
178
|
+
ensureSelectedPromptCurrent();
|
|
179
|
+
ctx.signal?.throwIfAborted();
|
|
180
|
+
ctx.ui.pasteToEditor(draft.text);
|
|
181
|
+
owner.promptDraftCleanups.add(draft.cleanup);
|
|
182
|
+
if (draft.guard) owner.promptDraftGuards.add(draft.guard);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
await draft.cleanup();
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
pi.sendUserMessage(
|
|
189
|
+
formatted.content,
|
|
190
|
+
ctx.isIdle() ? undefined : { deliverAs: "followUp" },
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
} finally {
|
|
194
|
+
if (ctx.mode === "tui") ctx.ui.setStatus("mcp-prompt", undefined);
|
|
195
|
+
}
|
|
196
|
+
} catch (error) {
|
|
197
|
+
const message =
|
|
198
|
+
error instanceof Error ? error.message : "MCP prompt failed.";
|
|
199
|
+
if (!ctx.hasUI) throw new Error(message);
|
|
200
|
+
ctx.ui.notify(message, "error");
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
async function choosePrompt(
|
|
206
|
+
owner: State,
|
|
207
|
+
ctx: ExtensionCommandContext,
|
|
208
|
+
): Promise<PromptSelection | undefined> {
|
|
209
|
+
const entries = [...owner.prompts.values()].sort(
|
|
210
|
+
(a, b) =>
|
|
211
|
+
a.server.localeCompare(b.server, "en") ||
|
|
212
|
+
a.prompt.name.localeCompare(b.prompt.name, "en"),
|
|
213
|
+
);
|
|
214
|
+
if (entries.length === 0) {
|
|
215
|
+
ctx.ui.notify("No MCP prompts are available.", "warning");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const item = await pickPrompt(entries, ctx);
|
|
220
|
+
if (!item) return;
|
|
221
|
+
ctx.signal?.throwIfAborted();
|
|
222
|
+
|
|
223
|
+
const key = promptKey(item.server, item.prompt.name);
|
|
224
|
+
const ensureCurrent = () => {
|
|
225
|
+
current(owner);
|
|
226
|
+
if (owner.prompts.get(key) !== item)
|
|
227
|
+
throw new Error("MCP prompt catalog changed. Open the picker again.");
|
|
228
|
+
};
|
|
229
|
+
ensureCurrent();
|
|
230
|
+
|
|
231
|
+
const dialogOptions = ctx.signal ? { signal: ctx.signal } : undefined;
|
|
232
|
+
const argumentTokens: PromptArgumentToken[] = [];
|
|
233
|
+
for (const argument of item.prompt.arguments ?? []) {
|
|
234
|
+
const value = await ctx.ui.input(
|
|
235
|
+
`${argument.name} (${argument.required ? "required" : "optional"})`,
|
|
236
|
+
argument.required ? "Enter a value" : "Leave empty to omit",
|
|
237
|
+
dialogOptions,
|
|
238
|
+
);
|
|
239
|
+
if (value === undefined) return;
|
|
240
|
+
ctx.signal?.throwIfAborted();
|
|
241
|
+
ensureCurrent();
|
|
242
|
+
if (argument.required || value !== "")
|
|
243
|
+
argumentTokens.push({
|
|
244
|
+
value: `${argument.name}=${value}`,
|
|
245
|
+
separator: argument.name.length,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
command: {
|
|
251
|
+
action: "run",
|
|
252
|
+
server: item.server,
|
|
253
|
+
name: item.prompt.name,
|
|
254
|
+
argumentTokens,
|
|
255
|
+
},
|
|
256
|
+
item,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
65
259
|
|
|
66
260
|
function deactivate(names: Iterable<string>) {
|
|
67
261
|
const removed = new Set(names);
|
|
@@ -230,6 +424,23 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
230
424
|
if (active) pi.setActiveTools(active);
|
|
231
425
|
}
|
|
232
426
|
|
|
427
|
+
function synchronizePrompts(
|
|
428
|
+
owner: State,
|
|
429
|
+
config: ServerConfig,
|
|
430
|
+
prompts: Prompt[],
|
|
431
|
+
) {
|
|
432
|
+
if (!owner.alive || state !== owner) return;
|
|
433
|
+
for (const [key, item] of owner.prompts) {
|
|
434
|
+
if (item.server === config.name) owner.prompts.delete(key);
|
|
435
|
+
}
|
|
436
|
+
for (const prompt of prompts) {
|
|
437
|
+
owner.prompts.set(promptKey(config.name, prompt.name), {
|
|
438
|
+
server: config.name,
|
|
439
|
+
prompt,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
233
444
|
function synchronize(owner: State, config: ServerConfig, tools: Tool[]) {
|
|
234
445
|
if (!owner.alive || state !== owner) return;
|
|
235
446
|
const previous = new Map(
|
|
@@ -340,9 +551,15 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
340
551
|
owner.alive = false;
|
|
341
552
|
state = undefined;
|
|
342
553
|
deactivate(owned);
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
);
|
|
554
|
+
const promptDraftCleanups = [...owner.promptDraftCleanups];
|
|
555
|
+
owner.promptDraftCleanups.clear();
|
|
556
|
+
owner.promptDraftGuards.clear();
|
|
557
|
+
await Promise.all([
|
|
558
|
+
...[...owner.connections.values()].map((connection) =>
|
|
559
|
+
connection.close(),
|
|
560
|
+
),
|
|
561
|
+
...promptDraftCleanups.map((cleanup) => cleanup()),
|
|
562
|
+
]);
|
|
346
563
|
}
|
|
347
564
|
|
|
348
565
|
async function start(ctx: ExtensionContext) {
|
|
@@ -351,10 +568,13 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
351
568
|
alive: true,
|
|
352
569
|
status: "No MCP servers configured.",
|
|
353
570
|
entries: new Map(),
|
|
571
|
+
prompts: new Map(),
|
|
354
572
|
loaded: new Map(),
|
|
355
573
|
connections: new Map(),
|
|
356
574
|
rejected: new Map(),
|
|
357
575
|
configIssues: [],
|
|
576
|
+
promptDraftCleanups: new Set(),
|
|
577
|
+
promptDraftGuards: new Set(),
|
|
358
578
|
};
|
|
359
579
|
state = owner;
|
|
360
580
|
try {
|
|
@@ -382,7 +602,11 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
382
602
|
for (const server of config.servers) {
|
|
383
603
|
owner.connections.set(
|
|
384
604
|
server.name,
|
|
385
|
-
new Connection(
|
|
605
|
+
new Connection(
|
|
606
|
+
server,
|
|
607
|
+
(tools) => synchronize(owner, server, tools),
|
|
608
|
+
(prompts) => synchronizePrompts(owner, server, prompts),
|
|
609
|
+
),
|
|
386
610
|
);
|
|
387
611
|
}
|
|
388
612
|
// Bound startup concurrency without letting one broken server hide healthy ones.
|
|
@@ -405,6 +629,35 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
405
629
|
}
|
|
406
630
|
}
|
|
407
631
|
|
|
632
|
+
// Keep each invisible editor guard through Pi's command/template dispatch,
|
|
633
|
+
// then remove only guards created by this session before model context.
|
|
634
|
+
pi.on("message_end", (event) => {
|
|
635
|
+
const owner = state;
|
|
636
|
+
if (!owner || event.message.role !== "user") return;
|
|
637
|
+
const consumed = new Set<string>();
|
|
638
|
+
const removeKnownGuards = (text: string) => {
|
|
639
|
+
let next = text;
|
|
640
|
+
for (const guard of owner.promptDraftGuards) {
|
|
641
|
+
if (!next.includes(guard)) continue;
|
|
642
|
+
next = removeEditablePromptGuard(next, guard);
|
|
643
|
+
consumed.add(guard);
|
|
644
|
+
}
|
|
645
|
+
return next;
|
|
646
|
+
};
|
|
647
|
+
let content: typeof event.message.content;
|
|
648
|
+
if (typeof event.message.content === "string") {
|
|
649
|
+
content = removeKnownGuards(event.message.content);
|
|
650
|
+
} else {
|
|
651
|
+
content = event.message.content.map((block) =>
|
|
652
|
+
block.type === "text"
|
|
653
|
+
? { ...block, text: removeKnownGuards(block.text) }
|
|
654
|
+
: block,
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
for (const guard of consumed) owner.promptDraftGuards.delete(guard);
|
|
658
|
+
if (consumed.size > 0) return { message: { ...event.message, content } };
|
|
659
|
+
});
|
|
660
|
+
|
|
408
661
|
// Any extension's registerTool can reactivate allowlisted tools in Pi 0.87.
|
|
409
662
|
// Independently track loaded fingerprints, enforce them in execute, and scrub
|
|
410
663
|
// request-local declarations at the last provider-neutral context boundary.
|
package/src/output.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
3
|
import { tmpdir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
|
5
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
CallToolResult,
|
|
8
|
+
ContentBlock,
|
|
9
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
6
10
|
|
|
7
11
|
export const MAX_TEXT_BYTES = 24 * 1024;
|
|
8
12
|
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
|
@@ -20,12 +24,27 @@ function preview(text: string): string {
|
|
|
20
24
|
.join("\n");
|
|
21
25
|
}
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
interface ContentOptions {
|
|
28
|
+
artifact: unknown;
|
|
29
|
+
structuredContent?: unknown;
|
|
30
|
+
preserveErrorImages?: boolean;
|
|
31
|
+
preserveOrder?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function formatContent(
|
|
35
|
+
blocks: ContentBlock[],
|
|
36
|
+
options: ContentOptions,
|
|
37
|
+
) {
|
|
24
38
|
const text: string[] = [];
|
|
25
39
|
const images: ImageContent[] = [];
|
|
40
|
+
const ordered: (TextContent | ImageContent)[] = [];
|
|
41
|
+
const addText = (value: string) => {
|
|
42
|
+
text.push(value);
|
|
43
|
+
ordered.push({ type: "text", text: value });
|
|
44
|
+
};
|
|
26
45
|
let omitted = false;
|
|
27
|
-
for (const block of
|
|
28
|
-
if (block.type === "text")
|
|
46
|
+
for (const block of blocks) {
|
|
47
|
+
if (block.type === "text") addText(block.text);
|
|
29
48
|
else if (
|
|
30
49
|
block.type === "image" &&
|
|
31
50
|
images.length < MAX_IMAGES &&
|
|
@@ -35,53 +54,80 @@ export async function formatResult(result: CallToolResult) {
|
|
|
35
54
|
// Padding can give three decoded sizes the same encoded length.
|
|
36
55
|
Buffer.byteLength(block.data, "base64") <= MAX_IMAGE_BYTES
|
|
37
56
|
) {
|
|
38
|
-
|
|
57
|
+
const image: ImageContent = {
|
|
39
58
|
type: "image",
|
|
40
59
|
data: block.data,
|
|
41
60
|
mimeType: block.mimeType,
|
|
42
|
-
}
|
|
61
|
+
};
|
|
62
|
+
images.push(image);
|
|
63
|
+
ordered.push(image);
|
|
43
64
|
} else {
|
|
44
|
-
|
|
65
|
+
addText(`[MCP ${block.type} content omitted; see full result.]`);
|
|
45
66
|
omitted = true;
|
|
46
67
|
}
|
|
47
68
|
}
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
`Structured content:\n${JSON.stringify(
|
|
69
|
+
if (options.structuredContent !== undefined) {
|
|
70
|
+
addText(
|
|
71
|
+
`Structured content:\n${JSON.stringify(options.structuredContent)}`,
|
|
51
72
|
);
|
|
52
73
|
}
|
|
53
|
-
const fullText =
|
|
74
|
+
const fullText =
|
|
75
|
+
text.join("\n\n") || (options.preserveOrder ? "" : "(No text output)");
|
|
54
76
|
const bounded = preview(fullText);
|
|
55
|
-
const structured =
|
|
77
|
+
const structured = options.structuredContent;
|
|
56
78
|
const largeDetails =
|
|
57
79
|
structured !== undefined &&
|
|
58
80
|
Buffer.byteLength(JSON.stringify(structured)) > MAX_DETAILS_BYTES;
|
|
59
|
-
|
|
60
|
-
// the full-result artifact before the native execution wrapper throws.
|
|
81
|
+
const textTruncated = bounded !== fullText;
|
|
61
82
|
const truncated =
|
|
62
|
-
|
|
83
|
+
textTruncated ||
|
|
63
84
|
omitted ||
|
|
64
85
|
largeDetails ||
|
|
65
|
-
(
|
|
86
|
+
(options.preserveErrorImages === true && images.length > 0);
|
|
66
87
|
let fullOutputPath: string | undefined;
|
|
67
88
|
if (truncated) {
|
|
68
89
|
const directory = await mkdtemp(join(tmpdir(), "pix-mcp-"));
|
|
69
90
|
fullOutputPath = join(directory, "result.json");
|
|
70
|
-
await writeFile(fullOutputPath, JSON.stringify(
|
|
91
|
+
await writeFile(fullOutputPath, JSON.stringify(options.artifact, null, 2), {
|
|
71
92
|
mode: 0o600,
|
|
72
93
|
});
|
|
73
94
|
}
|
|
74
|
-
const
|
|
75
|
-
{
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
95
|
+
const artifactNotice = fullOutputPath
|
|
96
|
+
? `Full MCP result: ${fullOutputPath}\nUse read with offset/limit to inspect it.`
|
|
97
|
+
: undefined;
|
|
98
|
+
let content: (TextContent | ImageContent)[];
|
|
99
|
+
if (options.preserveOrder) {
|
|
100
|
+
content = [];
|
|
101
|
+
let offset = 0;
|
|
102
|
+
let textIndex = 0;
|
|
103
|
+
for (const block of ordered) {
|
|
104
|
+
if (block.type === "image") {
|
|
105
|
+
if (!textTruncated || offset < bounded.length) content.push(block);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const value = `${textIndex++ > 0 ? "\n\n" : ""}${block.text}`;
|
|
109
|
+
// Slice the already byte/line-bounded preview itself; value supplies only
|
|
110
|
+
// the original block boundary needed to preserve image ordering.
|
|
111
|
+
const visible = bounded.slice(offset, offset + value.length);
|
|
112
|
+
offset += visible.length;
|
|
113
|
+
if (!visible) continue;
|
|
114
|
+
const previous = content.at(-1);
|
|
115
|
+
if (previous?.type === "text") previous.text += visible;
|
|
116
|
+
else content.push({ type: "text", text: visible });
|
|
117
|
+
}
|
|
118
|
+
if (textIndex === 0 && bounded)
|
|
119
|
+
content.unshift({ type: "text", text: bounded });
|
|
120
|
+
if (artifactNotice)
|
|
121
|
+
content.push({ type: "text", text: `\n\n${artifactNotice}` });
|
|
122
|
+
} else {
|
|
123
|
+
content = [
|
|
124
|
+
{
|
|
125
|
+
type: "text",
|
|
126
|
+
text: bounded + (artifactNotice ? `\n\n${artifactNotice}` : ""),
|
|
127
|
+
},
|
|
128
|
+
...images,
|
|
129
|
+
];
|
|
130
|
+
}
|
|
85
131
|
return {
|
|
86
132
|
content,
|
|
87
133
|
details: {
|
|
@@ -94,3 +140,90 @@ export async function formatResult(result: CallToolResult) {
|
|
|
94
140
|
},
|
|
95
141
|
};
|
|
96
142
|
}
|
|
143
|
+
|
|
144
|
+
function createEditablePromptGuard(): string {
|
|
145
|
+
const bytes = randomBytes(8);
|
|
146
|
+
let guard = "\u2060\u2063";
|
|
147
|
+
for (const byte of bytes)
|
|
148
|
+
for (let bit = 7; bit >= 0; bit--)
|
|
149
|
+
guard += byte & (1 << bit) ? "\u2063" : "\u2060";
|
|
150
|
+
return guard;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function removeEditablePromptGuard(text: string, guard: string): string {
|
|
154
|
+
return text.replaceAll(guard, "");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function prepareEditablePrompt(text: string): {
|
|
158
|
+
text: string;
|
|
159
|
+
guard?: string;
|
|
160
|
+
} {
|
|
161
|
+
// The TUI interprets leading / and ! as commands. An invisible guard keeps
|
|
162
|
+
// untrusted prompt text inert in the editor and is removed at message_end.
|
|
163
|
+
const sanitized = text
|
|
164
|
+
.replace(/\r\n?/gu, "\n")
|
|
165
|
+
.replace(/\p{Cc}/gu, (character) =>
|
|
166
|
+
character === "\n" || character === "\t" ? character : "",
|
|
167
|
+
)
|
|
168
|
+
.replace(/[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu, "");
|
|
169
|
+
if (!/^[!/]/u.test(sanitized.trimStart())) return { text: sanitized };
|
|
170
|
+
const guard = createEditablePromptGuard();
|
|
171
|
+
return { text: `${guard}${sanitized}`, guard };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const imageExtensions: Record<string, string> = {
|
|
175
|
+
"image/png": "png",
|
|
176
|
+
"image/jpeg": "jpg",
|
|
177
|
+
"image/gif": "gif",
|
|
178
|
+
"image/webp": "webp",
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export async function formatEditableContent(
|
|
182
|
+
content: (TextContent | ImageContent)[],
|
|
183
|
+
): Promise<{ text: string; guard?: string; cleanup: () => Promise<void> }> {
|
|
184
|
+
let text = "";
|
|
185
|
+
let imageDirectory: string | undefined;
|
|
186
|
+
let imageIndex = 0;
|
|
187
|
+
try {
|
|
188
|
+
for (const block of content) {
|
|
189
|
+
if (block.type === "text") {
|
|
190
|
+
text += block.text;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
imageDirectory ??= await mkdtemp(join(tmpdir(), "pix-mcp-prompt-"));
|
|
194
|
+
const extension = imageExtensions[block.mimeType] ?? "img";
|
|
195
|
+
const path = join(
|
|
196
|
+
imageDirectory,
|
|
197
|
+
`prompt-image-${++imageIndex}.${extension}`,
|
|
198
|
+
);
|
|
199
|
+
await writeFile(path, Buffer.from(block.data, "base64"), { mode: 0o600 });
|
|
200
|
+
const separator = text.endsWith("\n\n")
|
|
201
|
+
? ""
|
|
202
|
+
: text.endsWith("\n")
|
|
203
|
+
? "\n"
|
|
204
|
+
: "\n\n";
|
|
205
|
+
text += `${separator}@${JSON.stringify(path.replaceAll("\\", "/"))}`;
|
|
206
|
+
}
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (imageDirectory)
|
|
209
|
+
await rm(imageDirectory, { recursive: true, force: true });
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
...prepareEditablePrompt(text),
|
|
214
|
+
cleanup: async () => {
|
|
215
|
+
if (imageDirectory)
|
|
216
|
+
await rm(imageDirectory, { recursive: true, force: true });
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function formatResult(result: CallToolResult) {
|
|
222
|
+
// Pi 0.87 turns thrown tool errors into text only. Preserve error images in
|
|
223
|
+
// the full-result artifact before the native execution wrapper throws.
|
|
224
|
+
return formatContent(result.content, {
|
|
225
|
+
artifact: result,
|
|
226
|
+
structuredContent: result.structuredContent,
|
|
227
|
+
preserveErrorImages: result.isError === true,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DynamicBorder,
|
|
3
|
+
type ExtensionCommandContext,
|
|
4
|
+
type Theme,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import {
|
|
7
|
+
Container,
|
|
8
|
+
SelectList,
|
|
9
|
+
Text,
|
|
10
|
+
type Component,
|
|
11
|
+
type SelectItem,
|
|
12
|
+
type TUI,
|
|
13
|
+
type TuiMouseEvent,
|
|
14
|
+
} from "@earendil-works/pi-tui";
|
|
15
|
+
import { compact } from "./catalog.ts";
|
|
16
|
+
import { promptKey, type PromptEntry } from "./prompts.ts";
|
|
17
|
+
|
|
18
|
+
function details(entry: PromptEntry, theme: Theme): string {
|
|
19
|
+
const title = compact(entry.prompt.title ?? "", 120);
|
|
20
|
+
const description = compact(entry.prompt.description ?? "", 320);
|
|
21
|
+
return [
|
|
22
|
+
...(title ? [theme.fg("accent", theme.bold(title))] : []),
|
|
23
|
+
theme.fg(
|
|
24
|
+
description ? "muted" : "dim",
|
|
25
|
+
description || "No description provided.",
|
|
26
|
+
),
|
|
27
|
+
].join("\n");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createPromptPicker(
|
|
31
|
+
entries: PromptEntry[],
|
|
32
|
+
tui: TUI,
|
|
33
|
+
theme: Theme,
|
|
34
|
+
done: (result: string | undefined) => void,
|
|
35
|
+
signal?: AbortSignal,
|
|
36
|
+
): Component & { dispose(): void } {
|
|
37
|
+
const items: SelectItem[] = entries.map((entry) => ({
|
|
38
|
+
value: promptKey(entry.server, entry.prompt.name),
|
|
39
|
+
label: `${entry.server} / ${entry.prompt.name}`,
|
|
40
|
+
}));
|
|
41
|
+
const entriesByKey = new Map(
|
|
42
|
+
entries.map((entry) => [promptKey(entry.server, entry.prompt.name), entry]),
|
|
43
|
+
);
|
|
44
|
+
const container = new Container();
|
|
45
|
+
container.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
|
|
46
|
+
container.addChild(
|
|
47
|
+
new Text(theme.fg("accent", theme.bold("Select MCP prompt"))),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const list = new SelectList(items, Math.min(items.length, 10), {
|
|
51
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
52
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
53
|
+
description: (text) => theme.fg("muted", text),
|
|
54
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
55
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
56
|
+
});
|
|
57
|
+
container.addChild(list);
|
|
58
|
+
|
|
59
|
+
const focusedDescription = new Text("", 1, 1);
|
|
60
|
+
const updateDescription = (item: SelectItem) => {
|
|
61
|
+
const entry = entriesByKey.get(item.value);
|
|
62
|
+
if (entry) focusedDescription.setText(details(entry, theme));
|
|
63
|
+
};
|
|
64
|
+
const initialItem = items[0];
|
|
65
|
+
if (initialItem) updateDescription(initialItem);
|
|
66
|
+
list.onSelectionChange = updateDescription;
|
|
67
|
+
container.addChild(focusedDescription);
|
|
68
|
+
container.addChild(
|
|
69
|
+
new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel")),
|
|
70
|
+
);
|
|
71
|
+
container.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
|
|
72
|
+
|
|
73
|
+
let finished = false;
|
|
74
|
+
const finish = (result: string | undefined) => {
|
|
75
|
+
if (finished) return;
|
|
76
|
+
finished = true;
|
|
77
|
+
done(result);
|
|
78
|
+
};
|
|
79
|
+
list.onSelect = (item) => finish(item.value);
|
|
80
|
+
list.onCancel = () => finish(undefined);
|
|
81
|
+
const onAbort = () => finish(undefined);
|
|
82
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
83
|
+
if (signal?.aborted) onAbort();
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
render(width: number) {
|
|
87
|
+
return container.render(width);
|
|
88
|
+
},
|
|
89
|
+
invalidate() {
|
|
90
|
+
container.invalidate();
|
|
91
|
+
},
|
|
92
|
+
handleInput(data: string) {
|
|
93
|
+
list.handleInput(data);
|
|
94
|
+
tui.requestRender();
|
|
95
|
+
},
|
|
96
|
+
handleMouse(event: TuiMouseEvent) {
|
|
97
|
+
const result = container.handleMouse(event);
|
|
98
|
+
if (result?.render) tui.requestRender();
|
|
99
|
+
return result;
|
|
100
|
+
},
|
|
101
|
+
dispose() {
|
|
102
|
+
signal?.removeEventListener("abort", onAbort);
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function pickPrompt(
|
|
108
|
+
entries: PromptEntry[],
|
|
109
|
+
ctx: ExtensionCommandContext,
|
|
110
|
+
): Promise<PromptEntry | undefined> {
|
|
111
|
+
if (ctx.signal?.aborted) return;
|
|
112
|
+
const selected = await ctx.ui.custom<string | undefined>(
|
|
113
|
+
(tui, theme, _keybindings, done) =>
|
|
114
|
+
createPromptPicker(entries, tui, theme, done, ctx.signal),
|
|
115
|
+
);
|
|
116
|
+
if (selected === undefined) return;
|
|
117
|
+
return entries.find(
|
|
118
|
+
(entry) => promptKey(entry.server, entry.prompt.name) === selected,
|
|
119
|
+
);
|
|
120
|
+
}
|