@ikuma.cloud/pix-mcp 0.0.7 → 0.0.9

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.
@@ -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
@@ -4,6 +4,12 @@ import type {
4
4
  Prompt,
5
5
  } from "@modelcontextprotocol/sdk/types.js";
6
6
  import { compact } from "./catalog.ts";
7
+ import {
8
+ quoteCommandArgument,
9
+ renderCommandToken,
10
+ tokenizeCommand,
11
+ type CommandToken,
12
+ } from "./command.ts";
7
13
  import { formatContent } from "./output.ts";
8
14
 
9
15
  export interface PromptEntry {
@@ -11,10 +17,7 @@ export interface PromptEntry {
11
17
  prompt: Prompt;
12
18
  }
13
19
 
14
- export interface PromptArgumentToken {
15
- value: string;
16
- separator?: number;
17
- }
20
+ export type PromptArgumentToken = CommandToken;
18
21
 
19
22
  export type PromptCommand =
20
23
  | { action: "list"; server?: string }
@@ -30,7 +33,7 @@ export function promptKey(server: string, name: string): string {
30
33
  }
31
34
 
32
35
  export function parsePromptCommand(input: string): PromptCommand {
33
- const tokens = tokenize(input);
36
+ const tokens = tokenizeCommand(input, "prompt");
34
37
  const action = tokens.shift()?.value;
35
38
  if (action === "list") {
36
39
  if (tokens.length > 1) throw new Error("Usage: /mcp-prompt list [server]");
@@ -120,15 +123,15 @@ export function formatPromptList(
120
123
  const argumentsHint = (entry.prompt.arguments ?? [])
121
124
  .map((argument) =>
122
125
  argument.required
123
- ? `<${quote(argument.name)}>`
124
- : `[${quote(argument.name)}]`,
126
+ ? `<${quoteCommandArgument(argument.name)}>`
127
+ : `[${quoteCommandArgument(argument.name)}]`,
125
128
  )
126
129
  .join(" ");
127
130
  const description = compact(
128
131
  entry.prompt.description ?? entry.prompt.title ?? "",
129
132
  160,
130
133
  );
131
- const line = `${entry.server} ${quote(entry.prompt.name)}${argumentsHint ? ` ${argumentsHint}` : ""}${description ? ` — ${description}` : ""}`;
134
+ const line = `${entry.server} ${quoteCommandArgument(entry.prompt.name)}${argumentsHint ? ` ${argumentsHint}` : ""}${description ? ` — ${description}` : ""}`;
132
135
  if (shown >= 100 || lines.join("\n").length + line.length > 15_000) {
133
136
  lines.push(`${selected.length - shown} additional prompts omitted.`);
134
137
  break;
@@ -149,7 +152,7 @@ export function promptCompletions(
149
152
  const trailingSpace = /\s$/.test(prefix);
150
153
  let tokens: PromptArgumentToken[];
151
154
  try {
152
- tokens = tokenize(prefix);
155
+ tokens = tokenizeCommand(prefix);
153
156
  } catch {
154
157
  return null;
155
158
  }
@@ -191,7 +194,7 @@ export function promptCompletions(
191
194
  )
192
195
  .sort((a, b) => a.prompt.name.localeCompare(b.prompt.name, "en"));
193
196
  return prompts.map((entry) => ({
194
- value: `run ${quote(server)} ${quote(entry.prompt.name)}`,
197
+ value: `run ${quoteCommandArgument(server)} ${quoteCommandArgument(entry.prompt.name)}`,
195
198
  label: entry.prompt.name,
196
199
  ...(entry.prompt.description || entry.prompt.title
197
200
  ? {
@@ -237,9 +240,9 @@ export function promptCompletions(
237
240
  }
238
241
  const base = [
239
242
  "run",
240
- quote(server),
241
- quote(selected.prompt.name),
242
- ...supplied.map((token) => quote(token.value)),
243
+ quoteCommandArgument(server),
244
+ quoteCommandArgument(selected.prompt.name),
245
+ ...supplied.map(renderCommandToken),
243
246
  ].join(" ");
244
247
  const arguments_ = definitions.filter(
245
248
  (argument) =>
@@ -247,7 +250,7 @@ export function promptCompletions(
247
250
  );
248
251
  return arguments_.length > 0
249
252
  ? arguments_.map((argument) => ({
250
- value: `${base} ${quote(argument.name)}=`,
253
+ value: `${base} ${quoteCommandArgument(argument.name)}=`,
251
254
  label: `${argument.name}=`,
252
255
  ...(argument.description
253
256
  ? { description: compact(argument.description, 100) }
@@ -262,81 +265,34 @@ function completionItems(
262
265
  label: (value: string) => string,
263
266
  ) {
264
267
  return values.map((value) => ({
265
- value: `${prefix}${quote(value)}`,
268
+ value: `${prefix}${quoteCommandArgument(value)}`,
266
269
  label: label(value),
267
270
  }));
268
271
  }
269
272
 
270
- function quote(value: string): string {
271
- return /^[A-Za-z0-9_.:/-]+$/.test(value)
272
- ? value
273
- : `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
273
+ function hasUsablePromptContent(
274
+ content: GetPromptResult["messages"][number]["content"],
275
+ ): boolean {
276
+ if (content.type === "text") return content.text.trim().length > 0;
277
+ if (content.type === "resource")
278
+ return "text" in content.resource
279
+ ? content.resource.text.trim().length > 0
280
+ : content.resource.blob.length > 0;
281
+ if (content.type === "resource_link") return content.uri.trim().length > 0;
282
+ return content.data.length > 0;
274
283
  }
275
284
 
276
- function tokenize(input: string): PromptArgumentToken[] {
277
- const tokens: PromptArgumentToken[] = [];
278
- let token = "";
279
- let separator: number | undefined;
280
- let quote: '"' | "'" | undefined;
281
- let escaped = false;
282
- let started = false;
283
- for (const character of input) {
284
- if (escaped) {
285
- token += character;
286
- escaped = false;
287
- started = true;
288
- } else if (character === "\\" && quote !== "'") {
289
- escaped = true;
290
- started = true;
291
- } else if (quote) {
292
- if (character === quote) quote = undefined;
293
- else token += character;
294
- started = true;
295
- } else if (character === '"' || character === "'") {
296
- quote = character;
297
- started = true;
298
- } else if (/\s/u.test(character)) {
299
- if (started) {
300
- tokens.push({
301
- value: token,
302
- ...(separator !== undefined ? { separator } : {}),
303
- });
304
- token = "";
305
- separator = undefined;
306
- started = false;
307
- }
308
- } else {
309
- if (character === "=" && separator === undefined)
310
- separator = token.length;
311
- token += character;
312
- started = true;
313
- }
314
- }
315
- if (quote) throw new Error("Unterminated quote in prompt arguments.");
316
- if (escaped) throw new Error("Trailing escape in prompt arguments.");
317
- if (started)
318
- tokens.push({
319
- value: token,
320
- ...(separator !== undefined ? { separator } : {}),
321
- });
322
- return tokens;
323
- }
324
-
325
- export async function formatPromptResult(
326
- result: GetPromptResult,
327
- server: string,
328
- name: string,
329
- ) {
285
+ export async function formatPromptResult(result: GetPromptResult) {
330
286
  if (result.messages.length === 0)
331
287
  throw new Error("MCP prompt returned no messages.");
332
- const blocks: ContentBlock[] = [
333
- { type: "text", text: `[MCP prompt from ${server}/${name}]` },
334
- ];
288
+ const blocks: ContentBlock[] = [];
289
+ let hasSourceContent = false;
335
290
  const showRoles =
336
291
  result.messages.length > 1 || result.messages[0]?.role !== "user";
337
292
  for (const message of result.messages) {
338
293
  if (showRoles) blocks.push({ type: "text", text: `[${message.role}]` });
339
294
  const content = message.content;
295
+ if (hasUsablePromptContent(content)) hasSourceContent = true;
340
296
  if (content.type === "resource") {
341
297
  if ("text" in content.resource) {
342
298
  blocks.push({
@@ -362,5 +318,13 @@ export async function formatPromptResult(
362
318
  });
363
319
  } else blocks.push(content);
364
320
  }
365
- return formatContent(blocks, { artifact: result, preserveOrder: true });
321
+ if (!hasSourceContent)
322
+ throw new Error("MCP prompt returned no usable content.");
323
+ const formatted = await formatContent(blocks, {
324
+ artifact: result,
325
+ preserveOrder: true,
326
+ });
327
+ if (formatted.content.length === 0)
328
+ throw new Error("MCP prompt returned no usable content.");
329
+ return formatted;
366
330
  }
@@ -0,0 +1,157 @@
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 { resourceEntryKey, type ResourceEntry } from "./resources.ts";
17
+
18
+ function metadata(entry: ResourceEntry) {
19
+ return entry.kind === "resource" ? entry.resource : entry.template;
20
+ }
21
+
22
+ function identifier(entry: ResourceEntry) {
23
+ return entry.kind === "resource"
24
+ ? entry.resource.uri
25
+ : entry.template.uriTemplate;
26
+ }
27
+
28
+ function details(entry: ResourceEntry, theme: Theme): string {
29
+ const item = metadata(entry);
30
+ const title = compact(item.title ?? item.name, 120);
31
+ const description = compact(item.description ?? "", 320);
32
+ const uri = compact(identifier(entry), 500);
33
+ const annotations = item.annotations;
34
+ const hints = [
35
+ ...(item.mimeType ? [`Type: ${compact(item.mimeType, 120)}`] : []),
36
+ ...(entry.kind === "resource" && entry.resource.size !== undefined
37
+ ? [`Size: ${entry.resource.size.toLocaleString("en-US")} bytes`]
38
+ : []),
39
+ ...(entry.kind === "template" && entry.variables.length > 0
40
+ ? [
41
+ `Variables: ${entry.variables.map((value) => compact(value, 80)).join(", ")}`,
42
+ ]
43
+ : []),
44
+ ...(annotations?.audience?.length
45
+ ? [`Audience: ${annotations.audience.slice(0, 2).join(", ")}`]
46
+ : []),
47
+ ...(annotations?.priority !== undefined
48
+ ? [`Priority: ${annotations.priority}`]
49
+ : []),
50
+ ...(annotations?.lastModified
51
+ ? [`Modified: ${compact(annotations.lastModified, 80)}`]
52
+ : []),
53
+ ];
54
+ return [
55
+ theme.fg("accent", theme.bold(title)),
56
+ theme.fg(
57
+ "muted",
58
+ `${entry.kind === "template" ? "Template" : "Resource"}: ${uri}`,
59
+ ),
60
+ ...(description ? [theme.fg("muted", description)] : []),
61
+ ...(hints.length > 0 ? [theme.fg("dim", hints.join(" • "))] : []),
62
+ ].join("\n");
63
+ }
64
+
65
+ export function createResourcePicker(
66
+ entries: ResourceEntry[],
67
+ tui: TUI,
68
+ theme: Theme,
69
+ done: (result: string | undefined) => void,
70
+ signal?: AbortSignal,
71
+ ): Component & { dispose(): void } {
72
+ const items: SelectItem[] = entries.map((entry) => {
73
+ const item = metadata(entry);
74
+ return {
75
+ value: resourceEntryKey(entry),
76
+ label: `${entry.server} / ${compact(item.title ?? item.name, 120)}`,
77
+ description: compact(identifier(entry), 180),
78
+ };
79
+ });
80
+ const entriesByKey = new Map(
81
+ entries.map((entry) => [resourceEntryKey(entry), entry]),
82
+ );
83
+ const container = new Container();
84
+ container.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
85
+ container.addChild(
86
+ new Text(theme.fg("accent", theme.bold("Select MCP resource"))),
87
+ );
88
+
89
+ const list = new SelectList(items, Math.min(items.length, 10), {
90
+ selectedPrefix: (text) => theme.fg("accent", text),
91
+ selectedText: (text) => theme.fg("accent", text),
92
+ description: (text) => theme.fg("muted", text),
93
+ scrollInfo: (text) => theme.fg("dim", text),
94
+ noMatch: (text) => theme.fg("warning", text),
95
+ });
96
+ container.addChild(list);
97
+
98
+ const focusedDescription = new Text("", 1, 1);
99
+ const updateDescription = (item: SelectItem) => {
100
+ const entry = entriesByKey.get(item.value);
101
+ if (entry) focusedDescription.setText(details(entry, theme));
102
+ };
103
+ const initialItem = items[0];
104
+ if (initialItem) updateDescription(initialItem);
105
+ list.onSelectionChange = updateDescription;
106
+ container.addChild(focusedDescription);
107
+ container.addChild(
108
+ new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel")),
109
+ );
110
+ container.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
111
+
112
+ let finished = false;
113
+ const finish = (result: string | undefined) => {
114
+ if (finished) return;
115
+ finished = true;
116
+ done(result);
117
+ };
118
+ list.onSelect = (item) => finish(item.value);
119
+ list.onCancel = () => finish(undefined);
120
+ const onAbort = () => finish(undefined);
121
+ signal?.addEventListener("abort", onAbort, { once: true });
122
+ if (signal?.aborted) onAbort();
123
+
124
+ return {
125
+ render(width: number) {
126
+ return container.render(width);
127
+ },
128
+ invalidate() {
129
+ container.invalidate();
130
+ },
131
+ handleInput(data: string) {
132
+ list.handleInput(data);
133
+ tui.requestRender();
134
+ },
135
+ handleMouse(event: TuiMouseEvent) {
136
+ const result = container.handleMouse(event);
137
+ if (result?.render) tui.requestRender();
138
+ return result;
139
+ },
140
+ dispose() {
141
+ signal?.removeEventListener("abort", onAbort);
142
+ },
143
+ };
144
+ }
145
+
146
+ export async function pickResource(
147
+ entries: ResourceEntry[],
148
+ ctx: ExtensionCommandContext,
149
+ ): Promise<ResourceEntry | undefined> {
150
+ if (ctx.signal?.aborted) return;
151
+ const selected = await ctx.ui.custom<string | undefined>(
152
+ (tui, theme, _keybindings, done) =>
153
+ createResourcePicker(entries, tui, theme, done, ctx.signal),
154
+ );
155
+ if (selected === undefined) return;
156
+ return entries.find((entry) => resourceEntryKey(entry) === selected);
157
+ }