2 import { conversationsStore } from '$lib/stores/conversations.svelte';
3 import { mcpStore } from '$lib/stores/mcp.svelte';
4 import { debounce, uuid } from '$lib/utils';
5 import { KeyboardKey } from '$lib/enums';
6 import type { MCPPromptInfo, GetPromptResult, MCPServerSettingsEntry } from '$lib/types';
7 import { SvelteMap } from 'svelte/reactivity';
11 ChatFormPickerListItem,
12 ChatFormPickerItemHeader,
13 ChatFormPickerListItemSkeleton,
14 ChatFormPromptPickerArgumentForm
15 } from '$lib/components/app/chat';
16 import Badge from '$lib/components/ui/badge/badge.svelte';
24 placeholderId: string,
25 promptInfo: MCPPromptInfo,
26 args?: Record<string, string>
28 onPromptLoadComplete?: (placeholderId: string, result: GetPromptResult) => void;
29 onPromptLoadError?: (placeholderId: string, error: string) => void;
33 class: className = '',
42 let prompts = $state<MCPPromptInfo[]>([]);
43 let isLoading = $state(false);
44 let selectedPrompt = $state<MCPPromptInfo | null>(null);
45 let promptArgs = $state<Record<string, string>>({});
46 let selectedIndex = $state(0);
47 let internalSearchQuery = $state('');
48 // Bumped on ArrowUp/ArrowDown only, so the list scrolls on keyboard
49 // nav but not on hover or result changes.
50 let scrollTrigger = $state(0);
51 let promptError = $state<string | null>(null);
52 let selectedIndexBeforeArgumentForm = $state<number | null>(null);
54 let suggestions = $state<Record<string, string[]>>({});
55 let loadingSuggestions = $state<Record<string, boolean>>({});
56 let activeAutocomplete = $state<string | null>(null);
57 let autocompleteIndex = $state(0);
59 let serverSettingsMap = $derived.by(() => {
60 const servers = mcpStore.getServers();
61 const map = new SvelteMap<string, MCPServerSettingsEntry>();
63 for (const server of servers) {
64 map.set(server.id, server);
75 selectedPrompt = null;
82 if (filteredPrompts.length > 0 && selectedIndex >= filteredPrompts.length) {
87 async function loadPrompts() {
91 const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
93 const initialized = await mcpStore.ensureInitialized(perChatOverrides);
101 prompts = await mcpStore.getAllPrompts();
103 console.error('[ChatFormPickerMcpPrompts] Failed to load prompts:', error);
110 function handlePromptClick(prompt: MCPPromptInfo) {
111 const args = prompt.arguments ?? [];
113 if (args.length > 0) {
114 selectedIndexBeforeArgumentForm = selectedIndex;
115 selectedPrompt = prompt;
119 requestAnimationFrame(() => {
120 const firstInput = document.querySelector(`#arg-${args[0].name}`) as HTMLInputElement;
126 executePrompt(prompt, {});
130 async function executePrompt(prompt: MCPPromptInfo, args: Record<string, string>) {
133 const placeholderId = uuid();
135 const nonEmptyArgs = Object.fromEntries(
136 Object.entries(args).filter(([, value]) => value.trim() !== '')
138 const argsToPass = Object.keys(nonEmptyArgs).length > 0 ? nonEmptyArgs : undefined;
140 onPromptLoadStart?.(placeholderId, prompt, argsToPass);
144 const result = await mcpStore.getPrompt(prompt.serverName, prompt.name, args);
145 onPromptLoadComplete?.(placeholderId, result);
148 error instanceof Error ? error.message : 'Unknown error executing prompt';
149 onPromptLoadError?.(placeholderId, errorMessage);
153 function handleArgumentSubmit(event: SubmitEvent) {
154 event.preventDefault();
156 if (selectedPrompt) {
157 executePrompt(selectedPrompt, promptArgs);
161 const fetchCompletions = debounce(async (argName: string, value: string) => {
162 if (!selectedPrompt || value.length < 1) {
163 suggestions[argName] = [];
168 if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
169 console.log('[ChatFormPickerMcpPrompts] Fetching completions for:', {
170 serverName: selectedPrompt.serverName,
171 promptName: selectedPrompt.name,
177 loadingSuggestions[argName] = true;
180 const result = await mcpStore.getPromptCompletions(
181 selectedPrompt.serverName,
187 if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
188 console.log('[ChatFormPickerMcpPrompts] Autocomplete result:', {
192 suggestionsCount: result?.values.length ?? 0
196 if (result && result.values.length > 0) {
197 // Filter out empty strings from suggestions
198 const filteredValues = result.values.filter((v) => v.trim() !== '');
200 if (filteredValues.length > 0) {
201 suggestions[argName] = filteredValues;
202 activeAutocomplete = argName;
203 autocompleteIndex = 0;
205 suggestions[argName] = [];
208 suggestions[argName] = [];
211 console.error('[ChatFormPickerMcpPrompts] Failed to fetch completions:', error);
212 suggestions[argName] = [];
214 loadingSuggestions[argName] = false;
218 function handleArgInput(argName: string, value: string) {
219 promptArgs[argName] = value;
220 fetchCompletions(argName, value);
223 function selectSuggestion(argName: string, value: string) {
224 promptArgs[argName] = value;
225 suggestions[argName] = [];
226 activeAutocomplete = null;
229 function handleArgKeydown(event: KeyboardEvent, argName: string) {
230 const argSuggestions = suggestions[argName] ?? [];
232 // Handle Escape - return to prompt selection list
233 if (event.key === KeyboardKey.ESCAPE) {
234 event.preventDefault();
235 event.stopPropagation();
236 handleCancelArgumentForm();
240 if (argSuggestions.length === 0 || activeAutocomplete !== argName) return;
242 if (event.key === KeyboardKey.ARROW_DOWN) {
243 event.preventDefault();
244 autocompleteIndex = Math.min(autocompleteIndex + 1, argSuggestions.length - 1);
245 } else if (event.key === KeyboardKey.ARROW_UP) {
246 event.preventDefault();
247 autocompleteIndex = Math.max(autocompleteIndex - 1, 0);
248 } else if (event.key === KeyboardKey.ENTER && argSuggestions[autocompleteIndex]) {
249 event.preventDefault();
250 event.stopPropagation();
251 selectSuggestion(argName, argSuggestions[autocompleteIndex]);
255 function handleArgBlur(argName: string) {
256 // Delay to allow click on suggestion
258 if (activeAutocomplete === argName) {
259 suggestions[argName] = [];
260 activeAutocomplete = null;
265 function handleArgFocus(argName: string) {
266 if ((suggestions[argName]?.length ?? 0) > 0) {
267 activeAutocomplete = argName;
271 function handleCancelArgumentForm() {
272 // Restore the previously selected prompt index
273 if (selectedIndexBeforeArgumentForm !== null) {
274 selectedIndex = selectedIndexBeforeArgumentForm;
275 selectedIndexBeforeArgumentForm = null;
277 selectedPrompt = null;
282 export function handleKeydown(event: KeyboardEvent): boolean {
283 if (!isOpen) return false;
285 if (event.key === KeyboardKey.ESCAPE) {
286 event.preventDefault();
287 if (selectedPrompt) {
288 // Return to prompt selection list, keeping the selected prompt active
289 handleCancelArgumentForm();
297 if (event.key === KeyboardKey.ARROW_DOWN) {
298 event.preventDefault();
299 if (filteredPrompts.length > 0) {
300 selectedIndex = (selectedIndex + 1) % filteredPrompts.length;
307 if (event.key === KeyboardKey.ARROW_UP) {
308 event.preventDefault();
309 if (filteredPrompts.length > 0) {
310 selectedIndex = selectedIndex === 0 ? filteredPrompts.length - 1 : selectedIndex - 1;
317 if (event.key === KeyboardKey.ENTER && !selectedPrompt) {
318 event.preventDefault();
319 if (filteredPrompts[selectedIndex]) {
320 handlePromptClick(filteredPrompts[selectedIndex]);
329 let filteredPrompts = $derived.by(() => {
330 const sortedServers = mcpStore.getServers();
331 const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
333 const sortedPrompts = [...prompts].sort((a, b) => {
334 const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER;
335 const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER;
336 return orderA - orderB;
339 const query = (searchQuery || internalSearchQuery).toLowerCase();
340 if (!query) return sortedPrompts;
342 return sortedPrompts.filter(
344 prompt.name.toLowerCase().includes(query) ||
345 prompt.title?.toLowerCase().includes(query) ||
346 prompt.description?.toLowerCase().includes(query)
350 let showSearchInput = $derived(prompts.length > 3);
353 <ChatFormPickerPopover
356 srLabel="Open prompt picker"
358 onKeydown={handleKeydown}
361 {@const prompt = selectedPrompt}
362 {@const server = serverSettingsMap.get(prompt.serverName)}
363 {@const serverLabel = server ? mcpStore.getServerLabel(server) : prompt.serverName}
366 <ChatFormPickerItemHeader
369 title={prompt.title || prompt.name}
370 description={prompt.description}
372 {#snippet titleExtra()}
373 {#if prompt.arguments?.length}
374 <Badge variant="secondary">
375 {prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''}
379 </ChatFormPickerItemHeader>
381 <ChatFormPromptPickerArgumentForm
382 prompt={selectedPrompt}
389 onArgInput={handleArgInput}
390 onArgKeydown={handleArgKeydown}
391 onArgBlur={handleArgBlur}
392 onArgFocus={handleArgFocus}
393 onSelectSuggestion={selectSuggestion}
394 onSubmit={handleArgumentSubmit}
395 onCancel={handleCancelArgumentForm}
400 items={filteredPrompts}
403 bind:searchQuery={internalSearchQuery}
405 searchPlaceholder="Search prompts..."
406 emptyMessage="No MCP prompts available"
407 itemKey={(prompt) => prompt.serverName + ':' + prompt.name}
410 {#snippet item(prompt, index, isSelected)}
411 {@const server = serverSettingsMap.get(prompt.serverName)}
412 {@const serverLabel = server ? mcpStore.getServerLabel(server) : prompt.serverName}
414 <ChatFormPickerListItem
417 onclick={() => handlePromptClick(prompt)}
419 <ChatFormPickerItemHeader
422 title={prompt.title || prompt.name}
423 description={prompt.description}
425 {#snippet titleExtra()}
426 {#if prompt.arguments?.length}
427 <Badge variant="secondary">
428 {prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''}
432 </ChatFormPickerItemHeader>
433 </ChatFormPickerListItem>
436 {#snippet skeleton()}
437 <ChatFormPickerListItemSkeleton titleWidth="w-32" showBadge />
439 </ChatFormPickerList>
441 </ChatFormPickerPopover>