@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/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 { formatResult } from "./output.ts";
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
- await Promise.all(
344
- [...owner.connections.values()].map((connection) => connection.close()),
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(server, (tools) => synchronize(owner, server, tools)),
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 { mkdtemp, writeFile } from "node:fs/promises";
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 { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
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
- export async function formatResult(result: CallToolResult) {
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 result.content) {
28
- if (block.type === "text") text.push(block.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
- images.push({
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
- text.push(`[MCP ${block.type} content omitted; see full result.]`);
65
+ addText(`[MCP ${block.type} content omitted; see full result.]`);
45
66
  omitted = true;
46
67
  }
47
68
  }
48
- if (result.structuredContent !== undefined) {
49
- text.push(
50
- `Structured content:\n${JSON.stringify(result.structuredContent)}`,
69
+ if (options.structuredContent !== undefined) {
70
+ addText(
71
+ `Structured content:\n${JSON.stringify(options.structuredContent)}`,
51
72
  );
52
73
  }
53
- const fullText = text.join("\n\n") || "(No text output)";
74
+ const fullText =
75
+ text.join("\n\n") || (options.preserveOrder ? "" : "(No text output)");
54
76
  const bounded = preview(fullText);
55
- const structured = result.structuredContent;
77
+ const structured = options.structuredContent;
56
78
  const largeDetails =
57
79
  structured !== undefined &&
58
80
  Buffer.byteLength(JSON.stringify(structured)) > MAX_DETAILS_BYTES;
59
- // Pi 0.87 turns thrown tool errors into text only. Preserve error images in
60
- // the full-result artifact before the native execution wrapper throws.
81
+ const textTruncated = bounded !== fullText;
61
82
  const truncated =
62
- bounded !== fullText ||
83
+ textTruncated ||
63
84
  omitted ||
64
85
  largeDetails ||
65
- (result.isError === true && images.length > 0);
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(result, null, 2), {
91
+ await writeFile(fullOutputPath, JSON.stringify(options.artifact, null, 2), {
71
92
  mode: 0o600,
72
93
  });
73
94
  }
74
- const content: (TextContent | ImageContent)[] = [
75
- {
76
- type: "text",
77
- text:
78
- bounded +
79
- (fullOutputPath
80
- ? `\n\nFull MCP result: ${fullOutputPath}\nUse read with offset/limit to inspect it.`
81
- : ""),
82
- },
83
- ...images,
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
+ }