@ikuma.cloud/pix-mcp 0.0.7 → 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 +11 -0
- package/package.json +3 -1
- package/src/catalog.ts +1 -1
- package/src/index.ts +168 -24
- package/src/output.ts +86 -5
- package/src/prompt-picker.ts +120 -0
- package/src/prompts.ts +25 -9
package/README.md
CHANGED
|
@@ -266,10 +266,21 @@ the stable `/mcp-prompt` command so catalog changes do not leave stale slash
|
|
|
266
266
|
commands behind:
|
|
267
267
|
|
|
268
268
|
```text
|
|
269
|
+
/mcp-prompt
|
|
269
270
|
/mcp-prompt list [server]
|
|
270
271
|
/mcp-prompt run <server> <prompt> [name=value ...]
|
|
271
272
|
```
|
|
272
273
|
|
|
274
|
+
In TUI mode, `/mcp-prompt` without arguments opens a native prompt selector
|
|
275
|
+
that shows the focused prompt's title and description, followed by native input
|
|
276
|
+
dialogs. Required and optional arguments are requested in declaration order;
|
|
277
|
+
leaving an optional input empty omits it. The rendered prompt is placed in Pi's
|
|
278
|
+
editor so you can review or modify it before sending. Image blocks are stored in
|
|
279
|
+
private temporary files and inserted as `@` references. Escape cancels without
|
|
280
|
+
retrieving the prompt. The explicit `list` and `run` forms remain available in
|
|
281
|
+
every mode; `run` submits immediately and can pass an intentional empty value as
|
|
282
|
+
`name=`.
|
|
283
|
+
|
|
273
284
|
Arguments use shell-style quoting. Positional values map to the prompt's declared
|
|
274
285
|
argument order; `name=value` selects a declared argument explicitly. Quote or escape
|
|
275
286
|
an equals sign in a positional value (for example, `"a=b"` or `a\=b`) to avoid
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ikuma.cloud/pix-mcp",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -28,11 +28,13 @@
|
|
|
28
28
|
"peerDependencies": {
|
|
29
29
|
"@earendil-works/pi-ai": "*",
|
|
30
30
|
"@earendil-works/pi-coding-agent": "*",
|
|
31
|
+
"@earendil-works/pi-tui": "*",
|
|
31
32
|
"typebox": "*"
|
|
32
33
|
},
|
|
33
34
|
"devDependencies": {
|
|
34
35
|
"@earendil-works/pi-ai": "0.87.1",
|
|
35
36
|
"@earendil-works/pi-coding-agent": "0.87.1",
|
|
37
|
+
"@earendil-works/pi-tui": "0.87.1",
|
|
36
38
|
"typebox": "1.3.27"
|
|
37
39
|
}
|
|
38
40
|
}
|
package/src/catalog.ts
CHANGED
|
@@ -78,7 +78,7 @@ export function summary(item: Entry) {
|
|
|
78
78
|
|
|
79
79
|
export function compact(value: string, limit: number): string {
|
|
80
80
|
const text = value
|
|
81
|
-
.replace(
|
|
81
|
+
.replace(/[\p{C}\p{Zl}\p{Zp}]/gu, " ")
|
|
82
82
|
.replace(/\s+/g, " ")
|
|
83
83
|
.trim();
|
|
84
84
|
return text.length <= limit ? text : `${text.slice(0, limit)}…`;
|
package/src/index.ts
CHANGED
|
@@ -2,13 +2,19 @@ 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
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";
|
|
12
18
|
import {
|
|
13
19
|
formatPromptList,
|
|
14
20
|
formatPromptResult,
|
|
@@ -16,6 +22,8 @@ import {
|
|
|
16
22
|
promptCompletions,
|
|
17
23
|
promptKey,
|
|
18
24
|
resolvePromptArguments,
|
|
25
|
+
type PromptArgumentToken,
|
|
26
|
+
type PromptCommand,
|
|
19
27
|
type PromptEntry,
|
|
20
28
|
} from "./prompts.ts";
|
|
21
29
|
import { prepareSchema } from "./schema.ts";
|
|
@@ -31,6 +39,11 @@ interface Rejections {
|
|
|
31
39
|
}[];
|
|
32
40
|
}
|
|
33
41
|
|
|
42
|
+
interface PromptSelection {
|
|
43
|
+
command: PromptCommand;
|
|
44
|
+
item: PromptEntry;
|
|
45
|
+
}
|
|
46
|
+
|
|
34
47
|
interface State {
|
|
35
48
|
alive: boolean;
|
|
36
49
|
status: string;
|
|
@@ -40,6 +53,8 @@ interface State {
|
|
|
40
53
|
connections: Map<string, Connection>;
|
|
41
54
|
rejected: Map<string, Rejections>;
|
|
42
55
|
configIssues: ConfigIssue[];
|
|
56
|
+
promptDraftCleanups: Set<() => Promise<void>>;
|
|
57
|
+
promptDraftGuards: Set<string>;
|
|
43
58
|
}
|
|
44
59
|
|
|
45
60
|
const discoveryParameters = Type.Object(
|
|
@@ -73,7 +88,7 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
73
88
|
"Read this MCP config file for this session (default: .mcp.json).",
|
|
74
89
|
});
|
|
75
90
|
pi.registerCommand("mcp-prompt", {
|
|
76
|
-
description: "
|
|
91
|
+
description: "Select, list, or run a user-controlled MCP prompt",
|
|
77
92
|
getArgumentCompletions: (prefix) =>
|
|
78
93
|
promptCompletions(prefix, [...(state?.prompts.values() ?? [])]),
|
|
79
94
|
async handler(input, ctx) {
|
|
@@ -81,7 +96,14 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
81
96
|
const owner = state;
|
|
82
97
|
if (!owner) throw new Error("MCP session has not started.");
|
|
83
98
|
current(owner);
|
|
84
|
-
const
|
|
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;
|
|
85
107
|
if (command.action === "list") {
|
|
86
108
|
if (
|
|
87
109
|
command.server &&
|
|
@@ -113,34 +135,64 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
113
135
|
ctx.ui.notify(text, "info");
|
|
114
136
|
return;
|
|
115
137
|
}
|
|
116
|
-
const
|
|
138
|
+
const key = promptKey(command.server, command.name);
|
|
139
|
+
const item = selection?.item ?? owner.prompts.get(key);
|
|
117
140
|
if (!item)
|
|
118
141
|
throw new Error(
|
|
119
142
|
"Unknown or unavailable MCP prompt. List prompts again.",
|
|
120
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();
|
|
121
151
|
const connection = owner.connections.get(command.server);
|
|
122
152
|
if (!connection) throw new Error("MCP connection unavailable.");
|
|
123
153
|
const args = resolvePromptArguments(
|
|
124
154
|
item.prompt,
|
|
125
155
|
command.argumentTokens,
|
|
126
156
|
);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
+
}
|
|
144
196
|
} catch (error) {
|
|
145
197
|
const message =
|
|
146
198
|
error instanceof Error ? error.message : "MCP prompt failed.";
|
|
@@ -150,6 +202,61 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
150
202
|
},
|
|
151
203
|
});
|
|
152
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
|
+
}
|
|
259
|
+
|
|
153
260
|
function deactivate(names: Iterable<string>) {
|
|
154
261
|
const removed = new Set(names);
|
|
155
262
|
pi.setActiveTools(pi.getActiveTools().filter((name) => !removed.has(name)));
|
|
@@ -444,9 +551,15 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
444
551
|
owner.alive = false;
|
|
445
552
|
state = undefined;
|
|
446
553
|
deactivate(owned);
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
);
|
|
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
|
+
]);
|
|
450
563
|
}
|
|
451
564
|
|
|
452
565
|
async function start(ctx: ExtensionContext) {
|
|
@@ -460,6 +573,8 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
460
573
|
connections: new Map(),
|
|
461
574
|
rejected: new Map(),
|
|
462
575
|
configIssues: [],
|
|
576
|
+
promptDraftCleanups: new Set(),
|
|
577
|
+
promptDraftGuards: new Set(),
|
|
463
578
|
};
|
|
464
579
|
state = owner;
|
|
465
580
|
try {
|
|
@@ -514,6 +629,35 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
514
629
|
}
|
|
515
630
|
}
|
|
516
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
|
+
|
|
517
661
|
// Any extension's registerTool can reactivate allowlisted tools in Pi 0.87.
|
|
518
662
|
// Independently track loaded fingerprints, enforce them in execute, and scrub
|
|
519
663
|
// request-local declarations at the last provider-neutral context boundary.
|
package/src/output.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
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";
|
|
@@ -70,14 +71,16 @@ export async function formatContent(
|
|
|
70
71
|
`Structured content:\n${JSON.stringify(options.structuredContent)}`,
|
|
71
72
|
);
|
|
72
73
|
}
|
|
73
|
-
const fullText =
|
|
74
|
+
const fullText =
|
|
75
|
+
text.join("\n\n") || (options.preserveOrder ? "" : "(No text output)");
|
|
74
76
|
const bounded = preview(fullText);
|
|
75
77
|
const structured = options.structuredContent;
|
|
76
78
|
const largeDetails =
|
|
77
79
|
structured !== undefined &&
|
|
78
80
|
Buffer.byteLength(JSON.stringify(structured)) > MAX_DETAILS_BYTES;
|
|
81
|
+
const textTruncated = bounded !== fullText;
|
|
79
82
|
const truncated =
|
|
80
|
-
|
|
83
|
+
textTruncated ||
|
|
81
84
|
omitted ||
|
|
82
85
|
largeDetails ||
|
|
83
86
|
(options.preserveErrorImages === true && images.length > 0);
|
|
@@ -99,7 +102,7 @@ export async function formatContent(
|
|
|
99
102
|
let textIndex = 0;
|
|
100
103
|
for (const block of ordered) {
|
|
101
104
|
if (block.type === "image") {
|
|
102
|
-
content.push(block);
|
|
105
|
+
if (!textTruncated || offset < bounded.length) content.push(block);
|
|
103
106
|
continue;
|
|
104
107
|
}
|
|
105
108
|
const value = `${textIndex++ > 0 ? "\n\n" : ""}${block.text}`;
|
|
@@ -112,7 +115,8 @@ export async function formatContent(
|
|
|
112
115
|
if (previous?.type === "text") previous.text += visible;
|
|
113
116
|
else content.push({ type: "text", text: visible });
|
|
114
117
|
}
|
|
115
|
-
if (textIndex === 0
|
|
118
|
+
if (textIndex === 0 && bounded)
|
|
119
|
+
content.unshift({ type: "text", text: bounded });
|
|
116
120
|
if (artifactNotice)
|
|
117
121
|
content.push({ type: "text", text: `\n\n${artifactNotice}` });
|
|
118
122
|
} else {
|
|
@@ -137,6 +141,83 @@ export async function formatContent(
|
|
|
137
141
|
};
|
|
138
142
|
}
|
|
139
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
|
+
|
|
140
221
|
export function formatResult(result: CallToolResult) {
|
|
141
222
|
// Pi 0.87 turns thrown tool errors into text only. Preserve error images in
|
|
142
223
|
// the full-result artifact before the native execution wrapper throws.
|
|
@@ -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
|
+
}
|
package/src/prompts.ts
CHANGED
|
@@ -322,21 +322,29 @@ function tokenize(input: string): PromptArgumentToken[] {
|
|
|
322
322
|
return tokens;
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
)
|
|
325
|
+
function hasUsablePromptContent(
|
|
326
|
+
content: GetPromptResult["messages"][number]["content"],
|
|
327
|
+
): boolean {
|
|
328
|
+
if (content.type === "text") return content.text.trim().length > 0;
|
|
329
|
+
if (content.type === "resource")
|
|
330
|
+
return "text" in content.resource
|
|
331
|
+
? content.resource.text.trim().length > 0
|
|
332
|
+
: content.resource.blob.length > 0;
|
|
333
|
+
if (content.type === "resource_link") return content.uri.trim().length > 0;
|
|
334
|
+
return content.data.length > 0;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export async function formatPromptResult(result: GetPromptResult) {
|
|
330
338
|
if (result.messages.length === 0)
|
|
331
339
|
throw new Error("MCP prompt returned no messages.");
|
|
332
|
-
const blocks: ContentBlock[] = [
|
|
333
|
-
|
|
334
|
-
];
|
|
340
|
+
const blocks: ContentBlock[] = [];
|
|
341
|
+
let hasSourceContent = false;
|
|
335
342
|
const showRoles =
|
|
336
343
|
result.messages.length > 1 || result.messages[0]?.role !== "user";
|
|
337
344
|
for (const message of result.messages) {
|
|
338
345
|
if (showRoles) blocks.push({ type: "text", text: `[${message.role}]` });
|
|
339
346
|
const content = message.content;
|
|
347
|
+
if (hasUsablePromptContent(content)) hasSourceContent = true;
|
|
340
348
|
if (content.type === "resource") {
|
|
341
349
|
if ("text" in content.resource) {
|
|
342
350
|
blocks.push({
|
|
@@ -362,5 +370,13 @@ export async function formatPromptResult(
|
|
|
362
370
|
});
|
|
363
371
|
} else blocks.push(content);
|
|
364
372
|
}
|
|
365
|
-
|
|
373
|
+
if (!hasSourceContent)
|
|
374
|
+
throw new Error("MCP prompt returned no usable content.");
|
|
375
|
+
const formatted = await formatContent(blocks, {
|
|
376
|
+
artifact: result,
|
|
377
|
+
preserveOrder: true,
|
|
378
|
+
});
|
|
379
|
+
if (formatted.content.length === 0)
|
|
380
|
+
throw new Error("MCP prompt returned no usable content.");
|
|
381
|
+
return formatted;
|
|
366
382
|
}
|