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';
8 import Badge from '$lib/components/ui/badge/badge.svelte';
10 ChatFormPickerPopover,
12 ChatFormPickerListItem,
13 ChatFormPickerItemHeader,
14 ChatFormPickerListItemSkeleton,
15 ChatFormPromptPickerArgumentForm
16 } from '$lib/components/app/chat';
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 let promptError = $state<string | null>(null);
49 let selectedIndexBeforeArgumentForm = $state<number | null>(null);
51 let suggestions = $state<Record<string, string[]>>({});
52 let loadingSuggestions = $state<Record<string, boolean>>({});
53 let activeAutocomplete = $state<string | null>(null);
54 let autocompleteIndex = $state(0);
56 let serverSettingsMap = $derived.by(() => {
57 const servers = mcpStore.getServers();
58 const map = new SvelteMap<string, MCPServerSettingsEntry>();
60 for (const server of servers) {
61 map.set(server.id, server);
72 selectedPrompt = null;
79 if (filteredPrompts.length > 0 && selectedIndex >= filteredPrompts.length) {
84 async function loadPrompts() {
88 const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
90 const initialized = await mcpStore.ensureInitialized(perChatOverrides);
98 prompts = await mcpStore.getAllPrompts();
100 console.error('[ChatFormPromptPicker] Failed to load prompts:', error);
107 function handlePromptClick(prompt: MCPPromptInfo) {
108 const args = prompt.arguments ?? [];
110 if (args.length > 0) {
111 selectedIndexBeforeArgumentForm = selectedIndex;
112 selectedPrompt = prompt;
116 requestAnimationFrame(() => {
117 const firstInput = document.querySelector(`#arg-${args[0].name}`) as HTMLInputElement;
123 executePrompt(prompt, {});
127 async function executePrompt(prompt: MCPPromptInfo, args: Record<string, string>) {
130 const placeholderId = uuid();
132 const nonEmptyArgs = Object.fromEntries(
133 Object.entries(args).filter(([, value]) => value.trim() !== '')
135 const argsToPass = Object.keys(nonEmptyArgs).length > 0 ? nonEmptyArgs : undefined;
137 onPromptLoadStart?.(placeholderId, prompt, argsToPass);
141 const result = await mcpStore.getPrompt(prompt.serverName, prompt.name, args);
142 onPromptLoadComplete?.(placeholderId, result);
145 error instanceof Error ? error.message : 'Unknown error executing prompt';
146 onPromptLoadError?.(placeholderId, errorMessage);
150 function handleArgumentSubmit(event: SubmitEvent) {
151 event.preventDefault();
153 if (selectedPrompt) {
154 executePrompt(selectedPrompt, promptArgs);
158 const fetchCompletions = debounce(async (argName: string, value: string) => {
159 if (!selectedPrompt || value.length < 1) {
160 suggestions[argName] = [];
165 if (import.meta.env.DEV) {
166 console.log('[ChatFormPromptPicker] Fetching completions for:', {
167 serverName: selectedPrompt.serverName,
168 promptName: selectedPrompt.name,
174 loadingSuggestions[argName] = true;
177 const result = await mcpStore.getPromptCompletions(
178 selectedPrompt.serverName,
184 if (import.meta.env.DEV) {
185 console.log('[ChatFormPromptPicker] Autocomplete result:', {
189 suggestionsCount: result?.values.length ?? 0
193 if (result && result.values.length > 0) {
194 // Filter out empty strings from suggestions
195 const filteredValues = result.values.filter((v) => v.trim() !== '');
197 if (filteredValues.length > 0) {
198 suggestions[argName] = filteredValues;
199 activeAutocomplete = argName;
200 autocompleteIndex = 0;
202 suggestions[argName] = [];
205 suggestions[argName] = [];
208 console.error('[ChatFormPromptPicker] Failed to fetch completions:', error);
209 suggestions[argName] = [];
211 loadingSuggestions[argName] = false;
215 function handleArgInput(argName: string, value: string) {
216 promptArgs[argName] = value;
217 fetchCompletions(argName, value);
220 function selectSuggestion(argName: string, value: string) {
221 promptArgs[argName] = value;
222 suggestions[argName] = [];
223 activeAutocomplete = null;
226 function handleArgKeydown(event: KeyboardEvent, argName: string) {
227 const argSuggestions = suggestions[argName] ?? [];
229 // Handle Escape - return to prompt selection list
230 if (event.key === KeyboardKey.ESCAPE) {
231 event.preventDefault();
232 event.stopPropagation();
233 handleCancelArgumentForm();
237 if (argSuggestions.length === 0 || activeAutocomplete !== argName) return;
239 if (event.key === KeyboardKey.ARROW_DOWN) {
240 event.preventDefault();
241 autocompleteIndex = Math.min(autocompleteIndex + 1, argSuggestions.length - 1);
242 } else if (event.key === KeyboardKey.ARROW_UP) {
243 event.preventDefault();
244 autocompleteIndex = Math.max(autocompleteIndex - 1, 0);
245 } else if (event.key === KeyboardKey.ENTER && argSuggestions[autocompleteIndex]) {
246 event.preventDefault();
247 event.stopPropagation();
248 selectSuggestion(argName, argSuggestions[autocompleteIndex]);
252 function handleArgBlur(argName: string) {
253 // Delay to allow click on suggestion
255 if (activeAutocomplete === argName) {
256 suggestions[argName] = [];
257 activeAutocomplete = null;
262 function handleArgFocus(argName: string) {
263 if ((suggestions[argName]?.length ?? 0) > 0) {
264 activeAutocomplete = argName;
268 function handleCancelArgumentForm() {
269 // Restore the previously selected prompt index
270 if (selectedIndexBeforeArgumentForm !== null) {
271 selectedIndex = selectedIndexBeforeArgumentForm;
272 selectedIndexBeforeArgumentForm = null;
274 selectedPrompt = null;
279 export function handleKeydown(event: KeyboardEvent): boolean {
280 if (!isOpen) return false;
282 if (event.key === KeyboardKey.ESCAPE) {
283 event.preventDefault();
284 if (selectedPrompt) {
285 // Return to prompt selection list, keeping the selected prompt active
286 handleCancelArgumentForm();
294 if (event.key === KeyboardKey.ARROW_DOWN) {
295 event.preventDefault();
296 if (filteredPrompts.length > 0) {
297 selectedIndex = (selectedIndex + 1) % filteredPrompts.length;
303 if (event.key === KeyboardKey.ARROW_UP) {
304 event.preventDefault();
305 if (filteredPrompts.length > 0) {
306 selectedIndex = selectedIndex === 0 ? filteredPrompts.length - 1 : selectedIndex - 1;
312 if (event.key === KeyboardKey.ENTER && !selectedPrompt) {
313 event.preventDefault();
314 if (filteredPrompts[selectedIndex]) {
315 handlePromptClick(filteredPrompts[selectedIndex]);
324 let filteredPrompts = $derived.by(() => {
325 const sortedServers = mcpStore.getServersSorted();
326 const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
328 const sortedPrompts = [...prompts].sort((a, b) => {
329 const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER;
330 const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER;
331 return orderA - orderB;
334 const query = (searchQuery || internalSearchQuery).toLowerCase();
335 if (!query) return sortedPrompts;
337 return sortedPrompts.filter(
339 prompt.name.toLowerCase().includes(query) ||
340 prompt.title?.toLowerCase().includes(query) ||
341 prompt.description?.toLowerCase().includes(query)
345 let showSearchInput = $derived(prompts.length > 3);
348 <ChatFormPickerPopover
351 srLabel="Open prompt picker"
353 onKeydown={handleKeydown}
356 {@const prompt = selectedPrompt}
357 {@const server = serverSettingsMap.get(prompt.serverName)}
358 {@const serverLabel = server ? mcpStore.getServerLabel(server) : prompt.serverName}
361 <ChatFormPickerItemHeader
364 title={prompt.title || prompt.name}
365 description={prompt.description}
367 {#snippet titleExtra()}
368 {#if prompt.arguments?.length}
369 <Badge variant="secondary">
370 {prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''}
374 </ChatFormPickerItemHeader>
376 <ChatFormPromptPickerArgumentForm
377 prompt={selectedPrompt}
384 onArgInput={handleArgInput}
385 onArgKeydown={handleArgKeydown}
386 onArgBlur={handleArgBlur}
387 onArgFocus={handleArgFocus}
388 onSelectSuggestion={selectSuggestion}
389 onSubmit={handleArgumentSubmit}
390 onCancel={handleCancelArgumentForm}
395 items={filteredPrompts}
398 bind:searchQuery={internalSearchQuery}
400 searchPlaceholder="Search prompts..."
401 emptyMessage="No MCP prompts available"
402 itemKey={(prompt) => prompt.serverName + ':' + prompt.name}
404 {#snippet item(prompt, index, isSelected)}
405 {@const server = serverSettingsMap.get(prompt.serverName)}
406 {@const serverLabel = server ? mcpStore.getServerLabel(server) : prompt.serverName}
408 <ChatFormPickerListItem
411 onClick={() => handlePromptClick(prompt)}
413 <ChatFormPickerItemHeader
416 title={prompt.title || prompt.name}
417 description={prompt.description}
419 {#snippet titleExtra()}
420 {#if prompt.arguments?.length}
421 <Badge variant="secondary">
422 {prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''}
426 </ChatFormPickerItemHeader>
427 </ChatFormPickerListItem>
430 {#snippet skeleton()}
431 <ChatFormPickerListItemSkeleton titleWidth="w-32" showBadge />
433 </ChatFormPickerList>
435 </ChatFormPickerPopover>