3 ChatFormPickerItemHeader,
5 ChatFormPickerListItem,
6 ChatFormPickerListItemSkeleton,
8 ChatFormPromptPickerArgumentForm
9 } from '$lib/components/app/chat';
10 import Badge from '$lib/components/ui/badge/badge.svelte';
11 import { KeyboardKey } from '$lib/enums';
12 import { conversationsStore } from '$lib/stores/conversations.svelte';
13 import { mcpStore } from '$lib/stores/mcp.svelte';
14 import type { GetPromptResult, MCPPromptInfo, MCPServerSettingsEntry } from '$lib/types';
15 import { debounce, uuid } from '$lib/utils';
16 import { SvelteMap } from 'svelte/reactivity';
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();
92 const initialized = await mcpStore.ensureInitialized(perChatOverrides);
100 prompts = await mcpStore.getAllPrompts();
102 console.error('[ChatFormPickerMcpPrompts] Failed to load prompts:', error);
109 function handlePromptClick(prompt: MCPPromptInfo) {
110 const args = prompt.arguments ?? [];
112 if (args.length > 0) {
113 selectedIndexBeforeArgumentForm = selectedIndex;
114 selectedPrompt = prompt;
118 requestAnimationFrame(() => {
119 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();
134 const nonEmptyArgs = Object.fromEntries(
135 Object.entries(args).filter(([, value]) => value.trim() !== '')
137 const argsToPass = Object.keys(nonEmptyArgs).length > 0 ? nonEmptyArgs : undefined;
139 onPromptLoadStart?.(placeholderId, prompt, argsToPass);
143 const result = await mcpStore.getPrompt(prompt.serverName, prompt.name, args);
145 onPromptLoadComplete?.(placeholderId, result);
148 error instanceof Error ? error.message : 'Unknown error executing prompt';
150 onPromptLoadError?.(placeholderId, errorMessage);
154 function handleArgumentSubmit(event: SubmitEvent) {
155 event.preventDefault();
157 if (selectedPrompt) {
158 executePrompt(selectedPrompt, promptArgs);
162 const fetchCompletions = debounce(async (argName: string, value: string) => {
163 if (!selectedPrompt || value.length < 1) {
164 suggestions[argName] = [];
169 if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
170 console.log('[ChatFormPickerMcpPrompts] Fetching completions for:', {
172 promptName: selectedPrompt.name,
173 serverName: selectedPrompt.serverName,
178 loadingSuggestions[argName] = true;
181 const result = await mcpStore.getPromptCompletions(
182 selectedPrompt.serverName,
188 if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
189 console.log('[ChatFormPickerMcpPrompts] Autocomplete result:', {
192 suggestionsCount: result?.values.length ?? 0,
197 if (result && result.values.length > 0) {
198 // Filter out empty strings from suggestions
199 const filteredValues = result.values.filter((v) => v.trim() !== '');
201 if (filteredValues.length > 0) {
202 suggestions[argName] = filteredValues;
203 activeAutocomplete = argName;
204 autocompleteIndex = 0;
206 suggestions[argName] = [];
209 suggestions[argName] = [];
212 console.error('[ChatFormPickerMcpPrompts] Failed to fetch completions:', error);
213 suggestions[argName] = [];
215 loadingSuggestions[argName] = false;
219 function handleArgInput(argName: string, value: string) {
220 promptArgs[argName] = value;
221 fetchCompletions(argName, value);
224 function selectSuggestion(argName: string, value: string) {
225 promptArgs[argName] = value;
226 suggestions[argName] = [];
227 activeAutocomplete = null;
230 function handleArgKeydown(event: KeyboardEvent, argName: string) {
231 const argSuggestions = suggestions[argName] ?? [];
233 // Handle Escape - return to prompt selection list
234 if (event.key === KeyboardKey.ESCAPE) {
235 event.preventDefault();
236 event.stopPropagation();
237 handleCancelArgumentForm();
242 if (argSuggestions.length === 0 || activeAutocomplete !== argName) return;
244 if (event.key === KeyboardKey.ARROW_DOWN) {
245 event.preventDefault();
246 autocompleteIndex = Math.min(autocompleteIndex + 1, argSuggestions.length - 1);
247 } else if (event.key === KeyboardKey.ARROW_UP) {
248 event.preventDefault();
249 autocompleteIndex = Math.max(autocompleteIndex - 1, 0);
250 } else if (event.key === KeyboardKey.ENTER && argSuggestions[autocompleteIndex]) {
251 event.preventDefault();
252 event.stopPropagation();
253 selectSuggestion(argName, argSuggestions[autocompleteIndex]);
257 function handleArgBlur(argName: string) {
258 // Delay to allow click on suggestion
260 if (activeAutocomplete === argName) {
261 suggestions[argName] = [];
262 activeAutocomplete = null;
267 function handleArgFocus(argName: string) {
268 if ((suggestions[argName]?.length ?? 0) > 0) {
269 activeAutocomplete = argName;
273 function handleCancelArgumentForm() {
274 // Restore the previously selected prompt index
275 if (selectedIndexBeforeArgumentForm !== null) {
276 selectedIndex = selectedIndexBeforeArgumentForm;
277 selectedIndexBeforeArgumentForm = null;
280 selectedPrompt = null;
285 export function handleKeydown(event: KeyboardEvent): boolean {
286 if (!isOpen) return false;
288 if (event.key === KeyboardKey.ESCAPE) {
289 event.preventDefault();
291 if (selectedPrompt) {
292 // Return to prompt selection list, keeping the selected prompt active
293 handleCancelArgumentForm();
301 if (event.key === KeyboardKey.ARROW_DOWN) {
302 event.preventDefault();
304 if (filteredPrompts.length > 0) {
305 selectedIndex = (selectedIndex + 1) % filteredPrompts.length;
312 if (event.key === KeyboardKey.ARROW_UP) {
313 event.preventDefault();
315 if (filteredPrompts.length > 0) {
316 selectedIndex = selectedIndex === 0 ? filteredPrompts.length - 1 : selectedIndex - 1;
323 if (event.key === KeyboardKey.ENTER && !selectedPrompt) {
324 event.preventDefault();
326 if (filteredPrompts[selectedIndex]) {
327 handlePromptClick(filteredPrompts[selectedIndex]);
336 let filteredPrompts = $derived.by(() => {
337 const sortedServers = mcpStore.getServers();
338 const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
339 const sortedPrompts = [...prompts].sort((a, b) => {
340 const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER;
341 const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER;
343 return orderA - orderB;
345 const query = (searchQuery || internalSearchQuery).toLowerCase();
347 if (!query) return sortedPrompts;
349 return sortedPrompts.filter(
351 prompt.name.toLowerCase().includes(query) ||
352 prompt.title?.toLowerCase().includes(query) ||
353 prompt.description?.toLowerCase().includes(query)
357 let showSearchInput = $derived(prompts.length > 3);
360 <ChatFormPickerPopover
363 srLabel="Open prompt picker"
365 onKeydown={handleKeydown}
368 {@const prompt = selectedPrompt}
369 {@const server = serverSettingsMap.get(prompt.serverName)}
370 {@const serverLabel = server ? mcpStore.getServerLabel(server) : prompt.serverName}
373 <ChatFormPickerItemHeader
376 title={prompt.title || prompt.name}
377 description={prompt.description}
379 {#snippet titleExtra()}
380 {#if prompt.arguments?.length}
381 <Badge variant="secondary">
382 {prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''}
386 </ChatFormPickerItemHeader>
388 <ChatFormPromptPickerArgumentForm
389 prompt={selectedPrompt}
396 onArgInput={handleArgInput}
397 onArgKeydown={handleArgKeydown}
398 onArgBlur={handleArgBlur}
399 onArgFocus={handleArgFocus}
400 onSelectSuggestion={selectSuggestion}
401 onSubmit={handleArgumentSubmit}
402 onCancel={handleCancelArgumentForm}
407 items={filteredPrompts}
410 bind:searchQuery={internalSearchQuery}
412 searchPlaceholder="Search prompts..."
413 emptyMessage="No MCP prompts available"
414 itemKey={(prompt) => prompt.serverName + ':' + prompt.name}
417 {#snippet item(prompt, index, isSelected)}
418 {@const server = serverSettingsMap.get(prompt.serverName)}
419 {@const serverLabel = server ? mcpStore.getServerLabel(server) : prompt.serverName}
421 <ChatFormPickerListItem
424 onclick={() => handlePromptClick(prompt)}
426 <ChatFormPickerItemHeader
429 title={prompt.title || prompt.name}
430 description={prompt.description}
432 {#snippet titleExtra()}
433 {#if prompt.arguments?.length}
434 <Badge variant="secondary">
435 {prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''}
439 </ChatFormPickerItemHeader>
440 </ChatFormPickerListItem>
443 {#snippet skeleton()}
444 <ChatFormPickerListItemSkeleton titleWidth="w-32" showBadge />
446 </ChatFormPickerList>
448 </ChatFormPickerPopover>