]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
allozaur/feat/chat slash commands (#26716)
authorAleksander Grygier <redacted>
Fri, 7 Aug 2026 18:20:01 +0000 (20:20 +0200)
committerGitHub <redacted>
Fri, 7 Aug 2026 18:20:01 +0000 (20:20 +0200)
* base : slash-command/misc foundation - model icon and focus-selector constants

* feat : slash-command picker and command parsing helpers

* refactor : wire command and @-mention pickers into the chat form

* ui : improve model selector keyboard navigation and load/dismiss

* feat: Unify markdown/raw-text rendering under one setting with migration

* fix: Misc fixes - tool-call subtitle, assistant wrap, progress guards

* feat: Clamp and style numeric settings inputs from registry bounds

41 files changed:
tools/ui/src/app.d.ts
tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormCommandPicker.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectory.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryChip.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ToolCallBlock.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserBubble.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageReasoningBlock.svelte
tools/ui/src/lib/components/app/chat/index.ts
tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte
tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte
tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte
tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte
tools/ui/src/lib/constants/chat-commands.ts [new file with mode: 0644]
tools/ui/src/lib/constants/css-classes.ts
tools/ui/src/lib/constants/index.ts
tools/ui/src/lib/constants/settings-keys.ts
tools/ui/src/lib/constants/settings-registry.ts
tools/ui/src/lib/constants/ui.ts
tools/ui/src/lib/constants/working-directory.ts
tools/ui/src/lib/enums/chat.enums.ts
tools/ui/src/lib/enums/index.ts
tools/ui/src/lib/hooks/use-chat-form-pickers.svelte.ts [new file with mode: 0644]
tools/ui/src/lib/hooks/use-models-selector.svelte.ts
tools/ui/src/lib/stores/settings.svelte.ts
tools/ui/src/lib/types/chat.d.ts
tools/ui/src/lib/types/index.ts
tools/ui/src/lib/utils/command-token.ts [new file with mode: 0644]
tools/ui/src/lib/utils/index.ts
tools/ui/src/lib/utils/progress.ts
tools/ui/tests/client/chat-form-mention-picker-gate.svelte.test.ts [new file with mode: 0644]
tools/ui/tests/client/chat-form-slash-commands.svelte.test.ts [new file with mode: 0644]
tools/ui/tests/client/components/ChatFormPickersHarness.svelte [new file with mode: 0644]
tools/ui/tests/client/settings-raw-text-migration.svelte.test.ts [new file with mode: 0644]
tools/ui/tests/unit/command-token.test.ts [new file with mode: 0644]

index b9484d95031cb07c3ed8878ca517403b83bd8ec8..814fcf5231d4c69882755b38891e368b7bf1fef7 100644 (file)
@@ -143,10 +143,8 @@ declare global {
                idxThemeStyle?: number;
                idxCodeBlock?: number;
 
-               // File System Access API - missing from older DOM lib versions.
-               // Used by ChatFormWorkingDirectory's native folder picker. Feature availability
-               // is gated at runtime via `typeof window.showDirectoryPicker === 'function'`.
-               showDirectoryPicker: (options?: {
+               // File System Access API - not in the DOM lib and unavailable in some browsers
+               showDirectoryPicker?: (options?: {
                        id?: string;
                        mode?: 'read' | 'readwrite';
                        startIn?: FileSystemHandle | string;
index 22406e0722573cf7885fd9c04ebd55252fa038c1..fa68765efbf52f4010fea86c762402a2e5c8e4fd 100644 (file)
@@ -14,8 +14,7 @@
                INPUT_CLASSES,
                SETTING_CONFIG_DEFAULT,
                INITIAL_FILE_SIZE,
-               PROMPT_CONTENT_SEPARATOR,
-               PROMPT_TRIGGER_PREFIX
+               PROMPT_CONTENT_SEPARATOR
        } from '$lib/constants';
        import {
                ContentPartType,
        } from '$lib/types';
        import {
                buildMentionInsertion,
+               findCommandToken,
                findMentionToken,
                isIMEComposing,
                mentionLinkEndingAt,
                parseClipboardContent,
-               takeMentionDismissSnapshot,
-               type MentionDismissSnapshot,
                uuid
        } from '$lib/utils';
+       import { useChatFormPickers } from '$lib/hooks/use-chat-form-pickers.svelte';
        import {
                AudioRecorder,
                convertToWav,
                onValueChange
        }: Props = $props();
 
-       // Component References
        let audioRecorder: AudioRecorder | undefined;
        let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
        let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
        let pickersRef: { handleKeydown: (event: KeyboardEvent) => boolean } | undefined =
                $state(undefined);
-       let textareaRef: ChatFormTextarea | undefined = $state(undefined);
+       let inputRef: ChatFormTextarea | undefined = $state(undefined);
 
        // Audio Recording State
        let isRecording = $state(false);
        let recordingSupported = $state(false);
 
-       // Invisible anchor at the form's top edge so the mention popover floats above the box.
+       // Invisible anchor at the form's top edge so the mention/WD popovers
+       // float above the box.
        let mentionAnchor: HTMLDivElement | null = $state(null);
 
-       // Picker State
-       let isPromptPickerOpen = $state(false);
-       let promptSearchQuery = $state('');
-       let isMentionPickerOpen = $state(false);
-       let mentionQuery = $state('');
-
-       // Last dismissed `@`-mention token; while intact the picker does not
-       // reopen, so an escaped `@<query>` stays literal until edited.
-       let mentionDismissedSnapshot: MentionDismissSnapshot | null = null;
-
        let cwd = $derived(activeConversation()?.cwd ?? pendingCwd());
 
-       async function handleWorkingDirectoryChange(value: string | null) {
-               await conversationsStore.setCwd(value);
+       const pickers = useChatFormPickers({
+               getValue: () => value,
+               setValue: (v) => {
+                       value = v;
+                       onValueChange?.(v);
+               },
+               getCaretOffset: () => inputRef?.getCaretOffset(),
+               setCaretOffset: (offset) => inputRef?.setCaretOffset(offset),
+               focusInput: refocusInput,
+               getShowModelSelector: () => showModelSelector,
+               hasPrompts: () => mcpStore.hasPromptsCapability(conversationsStore.getAllMcpServerOverrides()),
+               hasBuiltinTools: () => toolsStore.builtinTools.length > 0,
+               getCwd: () => cwd,
+               getServerHome: () => toolsStore.serverHome ?? null,
+               openModelSelector: () => chatFormActionsRef?.openModelSelector(),
+               getPickersRef: () => pickersRef
+       });
+
+       async function handleWorkingDirectoryChange(newDir: string | null) {
+               // Committing a directory consumes the `/cwd` token; the chip's
+               // clear-X path has no token to consume.
+               const token = findCommandToken(value);
+               if (token && token.name === 'cwd') {
+                       value = '';
+                       onValueChange?.('');
+               }
+               await conversationsStore.setCwd(newDir);
                if (conversationsStore.activeConversation) {
-                       await chatStore.recordCwdChange(value?.trim() || null);
+                       await chatStore.recordCwdChange(newDir?.trim() || null);
                }
        }
 
                audioRecorder = new AudioRecorder();
        });
 
-       // Defer so the closing popover's focus scope tears down first - bits-ui
-       // yanks a synchronous focus() back into the still-mounted popover.
-       function refocusInput() {
-               queueMicrotask(() => textareaRef?.focus());
-       }
-
        export function focus() {
-               textareaRef?.focus();
+               inputRef?.focus();
        }
 
        export function resetTextareaHeight() {
-               textareaRef?.resetHeight();
+               inputRef?.resetHeight();
        }
 
        export function openModelSelector() {
                }
        }
 
-       function handleInput() {
-               const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
-               const hasServers = mcpStore.hasEnabledServers(perChatOverrides);
-               const cursor = textareaRef?.getCaretOffset() ?? value.length;
-               const mentionToken = findMentionToken(value, cursor);
-
-               // A `@` mention takes precedence; typing one switches from any other open picker.
-               if (mentionToken && mentionToken.query.length > 0) {
-                       isPromptPickerOpen = false;
-                       promptSearchQuery = '';
-
-                       const isDismissedSticky =
-                               mentionDismissedSnapshot !== null &&
-                               mentionDismissedSnapshot.start === mentionToken.start &&
-                               mentionDismissedSnapshot.query === mentionToken.query;
-
-                       if (!isDismissedSticky) {
-                               mentionDismissedSnapshot = null;
-                               isMentionPickerOpen = true;
-                               mentionQuery = mentionToken.query;
-                               return;
-                       }
-
-                       isMentionPickerOpen = false;
-                       mentionQuery = '';
-                       return;
-               }
-
-               isMentionPickerOpen = false;
-               mentionQuery = '';
-               // Token gone or changed: reset the snapshot so a fresh `@` reopens.
-               if (mentionDismissedSnapshot !== null && !mentionToken) {
-                       mentionDismissedSnapshot = null;
-               }
-
-               if (value.startsWith(PROMPT_TRIGGER_PREFIX) && hasServers) {
-                       isPromptPickerOpen = true;
-                       promptSearchQuery = value.slice(1);
-               } else {
-                       isPromptPickerOpen = false;
-                       promptSearchQuery = '';
-               }
-       }
-
        function handleKeydown(event: KeyboardEvent) {
-               if (pickersRef?.handleKeydown(event)) {
+               // Pickers consume navigation/escape keys first; when consumed, skip
+               // the enter-to-submit logic below.
+               if (pickers.handleKeydown(event)) {
                        return;
                }
 
                // Backspace at a mention link's end deletes the whole token at once.
                if (event.key === KeyboardKey.BACKSPACE && !event.ctrlKey && !event.metaKey && !event.altKey) {
-                       const el = textareaRef?.getElement();
+                       const el = inputRef?.getElement();
                        if (el instanceof HTMLTextAreaElement && el.selectionStart === el.selectionEnd) {
                                const link = mentionLinkEndingAt(value, el.selectionStart);
                                if (link) {
                                        event.preventDefault();
                                        value = value.slice(0, link.start) + value.slice(link.end);
                                        onValueChange?.(value);
-                                       queueMicrotask(() => textareaRef?.setCaretOffset(link.start));
+                                       queueMicrotask(() => inputRef?.setCaretOffset(link.start));
                                        return;
                                }
                        }
                }
 
-               if (event.key === KeyboardKey.ESCAPE && isPromptPickerOpen) {
-                       isPromptPickerOpen = false;
-                       promptSearchQuery = '';
-                       return;
-               }
-
-               if (event.key === KeyboardKey.ESCAPE && isMentionPickerOpen) {
-                       isMentionPickerOpen = false;
-                       mentionQuery = '';
-                       return;
-               }
-
                if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) {
                        const isModifier = event.ctrlKey || event.metaKey;
                        const sendOnEnter = currentConfig.sendOnEnter !== false;
                                }
 
                                setTimeout(() => {
-                                       textareaRef?.focus();
+                                       inputRef?.focus();
                                }, 10);
 
                                return;
                promptInfo: MCPPromptInfo,
                args?: Record<string, string>
        ) {
-               // Only clear the value if the prompt was triggered by typing '/'
-               if (value.startsWith(PROMPT_TRIGGER_PREFIX)) {
-                       value = '';
-                       onValueChange?.('');
-               }
-               isPromptPickerOpen = false;
-               promptSearchQuery = '';
+               pickers.closePromptPicker();
 
                const promptName = promptInfo.title || promptInfo.name;
                const placeholder: ChatUploadedFile = {
 
                uploadedFiles = [...uploadedFiles, placeholder];
                onUploadedFilesChange?.(uploadedFiles);
-               textareaRef?.focus();
+               inputRef?.focus();
        }
 
        function handlePromptLoadComplete(placeholderId: string, result: GetPromptResult) {
                onUploadedFilesChange?.(uploadedFiles);
        }
 
-       function handlePromptPickerClose() {
-               isPromptPickerOpen = false;
-               promptSearchQuery = '';
-               textareaRef?.focus();
-       }
-
-       function handleMentionPickerClose() {
-               if (isMentionPickerOpen) {
-                       const cursor = textareaRef?.getCaretOffset() ?? value.length;
-                       mentionDismissedSnapshot = takeMentionDismissSnapshot(value, cursor);
-               }
-               isMentionPickerOpen = false;
-               mentionQuery = '';
-               refocusInput();
+       // Deferred so the closing popover's focus scope tears down first -
+       // bits-ui yanks a synchronous focus() back into the still-mounted popover.
+       function refocusInput() {
+               queueMicrotask(() => inputRef?.focus());
        }
 
-       // Splice the `[name](file:///<abs path>)` link in place of the `@<query>`
-       // token, restoring the caret after the bindable value settles.
+       // Splice the mention link in place of the `@<query>` token. Uses the
+       // live cursor, not a stale snapshot - the token may have been edited.
        function handleMentionSelect(entry: FileMentionEntry) {
-               const cursor = textareaRef?.getCaretOffset() ?? value.length;
+               const cursor = inputRef?.getCaretOffset() ?? value.length;
                const token = findMentionToken(value, cursor);
                if (!token) return;
 
                value = built.newValue;
                onValueChange?.(built.newValue);
 
+               // bind:value applies on the next microtask; restore the caret after.
                queueMicrotask(() => {
-                       textareaRef?.focus();
-                       textareaRef?.setCaretOffset(built.caretOffset);
+                       inputRef?.focus();
+                       inputRef?.setCaretOffset(built.caretOffset);
                });
        }
 
 >
        <ChatFormPickers
                bind:this={pickersRef}
-               {isPromptPickerOpen}
-               {promptSearchQuery}
-               {isMentionPickerOpen}
-               {mentionQuery}
+               isCommandPickerOpen={pickers.isCommandPickerOpen}
+               commandQuery={pickers.commandQuery}
+               commands={pickers.availableCommands}
+               onCommandPickerClose={pickers.handleCommandPickerClose}
+               onCommandSelect={pickers.handleCommandSelect}
+               isPromptPickerOpen={pickers.isPromptPickerOpen}
+               promptSearchQuery={pickers.promptSearchQuery}
+               isMentionPickerOpen={pickers.isMentionPickerOpen}
+               mentionQuery={pickers.mentionQuery}
                {mentionAnchor}
-               scopePath={cwd}
-               onPromptPickerClose={handlePromptPickerClose}
-               onMentionPickerClose={handleMentionPickerClose}
-               onMentionOpened={() => textareaRef?.focus()}
+               scopePath={pickers.mentionScopePath}
+               onPromptPickerClose={pickers.handlePromptPickerClose}
+               onMentionPickerClose={pickers.handleMentionPickerClose}
+               onMentionOpened={() => inputRef?.focus()}
                onMentionSelect={handleMentionSelect}
                onPromptLoadStart={handlePromptLoadStart}
                onPromptLoadComplete={handlePromptLoadComplete}
 
                <div
                        class="flex-column relative min-h-12 items-center rounded-4xl md:rounded-3xl py-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:py-3!"
-                       onpaste={handlePaste}
                >
                        <ChatFormTextarea
                                class="px-5 py-1.5 md:pt-0"
-                               bind:this={textareaRef}
+                               bind:this={inputRef}
                                bind:value
                                onKeydown={handleKeydown}
                                onInput={() => {
-                                       handleInput();
+                                       pickers.handleInput();
                                        onValueChange?.(value);
                                }}
+                               onPaste={handlePaste}
                                {disabled}
                                {placeholder}
                        />
                                onMicClick={handleMicClick}
                                {onStop}
                                onSystemPromptClick={() => onSystemPromptClick?.({ message: value, files: uploadedFiles })}
-                               onMcpPromptClick={showMcpPromptButton ? () => (isPromptPickerOpen = true) : undefined}
+                               onMcpPromptClick={showMcpPromptButton ? () => pickers.openPromptPicker() : undefined}
                                onMcpResourcesClick={() => (isResourceDialogOpen = true)}
                        />
                </div>
        {#if toolsStore.builtinTools.length > 0}
                <ChatFormWorkingDirectory
                        directory={cwd}
+                       isOpen={pickers.isWorkingDirectoryPickerOpen}
+                       bind:query={pickers.workingDirectoryQuery}
+                       customAnchor={mentionAnchor}
                        onChange={handleWorkingDirectoryChange}
-                       onClose={refocusInput}
+                       onClose={pickers.handleWorkingDirectoryClose}
+                       onOpen={pickers.handleWorkingDirectoryOpen}
                        {disabled}
                />
        {/if}
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormCommandPicker.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormCommandPicker.svelte
new file mode 100644 (file)
index 0000000..5feaaf3
--- /dev/null
@@ -0,0 +1,135 @@
+<script lang="ts">
+       import { FolderOpen, Sparkles } from '@lucide/svelte';
+       import { MODEL_SELECTOR_ICON } from '$lib/constants';
+       import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
+       import { ChatFormCommandAction } from '$lib/enums';
+       import type { ChatFormCommand } from '$lib/types';
+       import {
+               ChatFormPickerList,
+               ChatFormPickerListItem,
+               ChatFormPickerPopover
+       } from '$lib/components/app/chat';
+
+       /**
+        * Slash-command picker; `query` (typed after `/`) filters the commands.
+        * The parent owns the "dismissed token, don't act until it changes"
+        * snapshot, so this picker just renders and reports selection.
+        */
+       interface Props {
+               class?: string;
+               isOpen: boolean;
+               query: string;
+               commands: ChatFormCommand[];
+               onClose: () => void;
+               onSelect: (command: ChatFormCommand) => void;
+       }
+
+       let { class: className = '', isOpen, query, commands, onClose, onSelect }: Props = $props();
+
+       const commandIcon: Record<ChatFormCommandAction, typeof Sparkles> = {
+               [ChatFormCommandAction.PROMPT]: Sparkles,
+               [ChatFormCommandAction.CWD]: FolderOpen,
+               [ChatFormCommandAction.MODEL]: MODEL_SELECTOR_ICON
+       };
+
+       const trimmedQuery = $derived((query ?? '').trim().toLowerCase());
+
+       const filteredCommands = $derived(
+               trimmedQuery
+                       ? commands.filter(
+                                       (c) =>
+                                               c.name.toLowerCase().includes(trimmedQuery) ||
+                                               c.description.toLowerCase().includes(trimmedQuery) ||
+                                               (c.keywords ?? []).some((k) => k.toLowerCase().includes(trimmedQuery))
+                               )
+                       : commands
+       );
+
+       function firstEnabledIndex(): number {
+               return filteredCommands.findIndex((c) => !c.disabled);
+       }
+
+       function stepEnabled(from: number, dir: number): number {
+               const n = filteredCommands.length;
+               if (n === 0) return -1;
+               for (let i = 1; i <= n; i++) {
+                       const idx = (from + dir * i + n) % n;
+                       if (!filteredCommands[idx].disabled) return idx;
+               }
+               return -1;
+       }
+
+       const nav = usePickerNavigation({
+               isOpen: () => isOpen,
+               count: () => filteredCommands.length,
+               step: (from, dir) => (from < 0 ? firstEnabledIndex() : stepEnabled(from, dir)),
+               onClose: () => onClose(),
+               onSelect: (index) => handleSelect(filteredCommands[index])
+       });
+
+       $effect(() => {
+               if (isOpen) {
+                       nav.reset(firstEnabledIndex());
+               }
+       });
+
+       $effect(() => {
+               if (nav.hoveredIndex < 0 || nav.hoveredIndex >= filteredCommands.length) {
+                       nav.reset(firstEnabledIndex());
+                       return;
+               }
+               if (filteredCommands[nav.hoveredIndex].disabled) {
+                       nav.reset(firstEnabledIndex());
+               }
+       });
+
+       function handleSelect(command: ChatFormCommand) {
+               if (command.disabled) return;
+               onSelect(command);
+               onClose();
+       }
+
+       export function handleKeydown(event: KeyboardEvent): boolean {
+               return nav.handleKeydown(event);
+       }
+</script>
+
+<ChatFormPickerPopover
+       bind:isOpen
+       class={className}
+       srLabel="Open command picker"
+       {onClose}
+       onKeydown={handleKeydown}
+>
+       <ChatFormPickerList
+               items={filteredCommands}
+               isLoading={false}
+               selectedIndex={nav.hoveredIndex}
+               showSearchInput={false}
+               searchQuery={query ?? ''}
+               emptyMessage="No matching command"
+               itemKey={(command) => command.name}
+               scrollTrigger={nav.scrollTrigger}
+       >
+               {#snippet item(command, index, isSelected)}
+                       {@const Icon = commandIcon[command.action]}
+                       <ChatFormPickerListItem
+                               dataIndex={index}
+                               {isSelected}
+                               disabled={command.disabled}
+                               onclick={() => handleSelect(command)}
+                               onmouseenter={() => {
+                                       if (!command.disabled) nav.setHover(index);
+                               }}
+                       >
+                               <Icon class="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
+                               <div class="flex min-w-0 flex-1 flex-col">
+                                       <span class="font-mono text-sm font-medium">/{command.name}</span>
+                                       <span class="min-w-0 flex-1 truncate text-left text-xs text-muted-foreground">
+                                               {command.description}
+                                       </span>
+                               </div>
+                       </ChatFormPickerListItem>
+               {/snippet}
+       </ChatFormPickerList>
+</ChatFormPickerPopover>
index f35d816de92928037e7241a3b1d273631d149317..22ec5311947a1cda3c40848e5c42201ab6587af1 100644 (file)
@@ -45,6 +45,9 @@
        let promptArgs = $state<Record<string, string>>({});
        let selectedIndex = $state(0);
        let internalSearchQuery = $state('');
+       // Bumped on ArrowUp/ArrowDown only, so the list scrolls on keyboard
+       // nav but not on hover or result changes.
+       let scrollTrigger = $state(0);
        let promptError = $state<string | null>(null);
        let selectedIndexBeforeArgumentForm = $state<number | null>(null);
 
                        event.preventDefault();
                        if (filteredPrompts.length > 0) {
                                selectedIndex = (selectedIndex + 1) % filteredPrompts.length;
+                               scrollTrigger++;
                        }
 
                        return true;
                        event.preventDefault();
                        if (filteredPrompts.length > 0) {
                                selectedIndex = selectedIndex === 0 ? filteredPrompts.length - 1 : selectedIndex - 1;
+                               scrollTrigger++;
                        }
 
                        return true;
                        searchPlaceholder="Search prompts..."
                        emptyMessage="No MCP prompts available"
                        itemKey={(prompt) => prompt.serverName + ':' + prompt.name}
+                       {scrollTrigger}
                >
                        {#snippet item(prompt, index, isSelected)}
                                {@const server = serverSettingsMap.get(prompt.serverName)}
index 3fc29f1771631d5cfaf988aedbf9d804f6e6b004..0d6cdcbfd680d269648c66c569b391667e89daf4 100644 (file)
@@ -1,15 +1,26 @@
 <script lang="ts">
+       import ChatFormCommandPicker from './ChatFormCommandPicker.svelte';
        import ChatFormMentionPicker from './ChatFormMentionPicker.svelte';
        import ChatFormPickerMcpPrompts from './ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte';
-       import type { FileMentionEntry, GetPromptResult, MCPPromptInfo } from '$lib/types';
+       import type {
+               ChatFormCommand,
+               FileMentionEntry,
+               GetPromptResult,
+               MCPPromptInfo
+       } from '$lib/types';
 
        interface Props {
+               isCommandPickerOpen?: boolean;
+               commandQuery?: string;
+               commands?: ChatFormCommand[];
                isPromptPickerOpen?: boolean;
                promptSearchQuery?: string;
                isMentionPickerOpen?: boolean;
                mentionQuery?: string;
                mentionAnchor?: HTMLElement | null;
                scopePath?: string | null;
+               onCommandPickerClose?: () => void;
+               onCommandSelect?: (command: ChatFormCommand) => void;
                onPromptPickerClose?: () => void;
                onMentionPickerClose?: () => void;
                onMentionOpened?: () => void;
        }
 
        let {
+               isCommandPickerOpen,
+               commandQuery,
+               commands = [],
+               onCommandPickerClose,
+               onCommandSelect,
                isPromptPickerOpen,
                promptSearchQuery,
                isMentionPickerOpen,
                onPromptLoadError
        }: Props = $props();
 
+       let commandPickerRef: ChatFormCommandPicker | undefined = $state(undefined);
        let promptPickerRef: ChatFormPickerMcpPrompts | undefined = $state(undefined);
        let mentionPickerRef: ChatFormMentionPicker | undefined = $state(undefined);
 
-       /**
-        * Delegates keyboard events to the active picker child.
-        * Returns true if the event was handled.
-        */
+       /** Delegate keyboard events to the active picker child; true if handled. */
        export function handleKeydown(event: KeyboardEvent): boolean {
+               if (isCommandPickerOpen && commandPickerRef?.handleKeydown(event)) {
+                       return true;
+               }
+
                if (isPromptPickerOpen && promptPickerRef?.handleKeydown(event)) {
                        return true;
                }
        }
 </script>
 
+<ChatFormCommandPicker
+       bind:this={commandPickerRef}
+       isOpen={isCommandPickerOpen ?? false}
+       query={commandQuery ?? ''}
+       {commands}
+       onClose={onCommandPickerClose ?? (() => {})}
+       onSelect={onCommandSelect ?? (() => {})}
+/>
+
 <ChatFormPickerMcpPrompts
        bind:this={promptPickerRef}
        isOpen={isPromptPickerOpen}
index 9fd3143e6abd7e0579d372a83f97961022f184c9..ab297af4f0283cff9b41dba5ec3edd01f1fe15d0 100644 (file)
        onMount(() => {
                if (textareaElement) {
                        autoResizeTextarea(textareaElement);
-                       textareaElement.focus();
+                       textareaElement.focus({ preventScroll: true });
                }
        });
 
-       // Expose the textarea element for external access
        export function getElement() {
                return textareaElement;
        }
@@ -49,7 +48,7 @@
                }
        }
 
-       // Plain-text caret offsets for the mention-splice flow.
+       // Plain-text caret offsets for the picker/paste/mention-splice flows.
        export function getCaretOffset(): number {
                if (!textareaElement) return 0;
                return textareaElement.selectionStart ?? textareaElement.value.length;
index f6ac9e0e8637e49d038edcc4f1b7b9918f8a036a..f8069c93e5f9ba735c8d6d0e807c5c3dcf3bf2b7 100644 (file)
@@ -1,7 +1,5 @@
 <script lang="ts">
        import { FolderOpen } from '@lucide/svelte';
-       import { untrack } from 'svelte';
-       import { SvelteMap } from 'svelte/reactivity';
        import { ToolsService } from '$lib/services/tools.service';
        import { toolsStore } from '$lib/stores/tools.svelte';
        import { BuiltInTool, GlobSearchType, KeyboardKey } from '$lib/enums';
                buildCaseInsensitiveGlob,
                joinPath,
                lastPathSegment,
-               rankEntries,
-               splitPathQuery,
+               runGlobSearchWithChildren,
                type GlobEntry
        } from '$lib/utils';
-       import { debounce } from '$lib/utils/debounce';
        import * as Popover from '$lib/components/ui/popover';
        import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
+       import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
+       import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
+       import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
        import ChatFormWorkingDirectoryChip from './ChatFormWorkingDirectoryChip.svelte';
        import ChatFormWorkingDirectoryResultsList from './ChatFormWorkingDirectoryResultsList.svelte';
        import {
                DEFAULT_MOBILE_BREAKPOINT,
-               GLOB_WILDCARD,
                HOME_TILDE,
                MAX_RESULTS_SHOWN,
                NATIVE_LIMIT,
                NATIVE_MAX_DEPTH,
-               PATH_NAV_MAX_DEPTH,
                SEARCH_DEBOUNCE_MS,
                SEARCH_LIMIT,
                SEARCH_MAX_DEPTH
                class?: string;
                disabled?: boolean;
                directory?: string | null;
+               /** Controlled open state; the host owns it so the chip click and the
+                * `/cwd` slash command open the picker through the same path. */
+               isOpen: boolean;
+               /** Two-way bound query, kept in sync with the text after `/cwd `. */
+               query: string;
+               /** Anchor at the form's top edge so the popover floats above the box. */
+               customAnchor?: HTMLElement | null;
                onChange?: (directory: string | null) => void;
-               /**
-                * Lets the host refocus the chat input so typing can resume without
-                * an extra click after the popover closes.
-                */
+               /** Lets the host refocus the chat input after the popover closes. */
                onClose?: () => void;
+               /** Fired when the chip is clicked so the host can open the picker. */
+               onOpen?: () => void;
        }
 
        let {
                class: className = '',
                disabled = false,
-               directory = $bindable(null),
+               directory = null,
+               isOpen,
+               query = $bindable(''),
+               customAnchor = null,
                onChange,
-               onClose
+               onClose,
+               onOpen
        }: Props = $props();
 
-       // File System Access API is opt-in: when available (Chrome / Edge / Opera) the popover
-       // exposes a "Browse" button that opens the native folder picker. When unavailable the
-       // popover still works via the text input - no alerts, no upload semantics.
+       // File System Access API is opt-in (Chrome / Edge / Opera): the popover
+       // exposes a "Browse" button only when available.
        const pickerSupported =
                typeof window !== 'undefined' && typeof window.showDirectoryPicker === 'function';
 
-       // Popover open state; the element handles outside-click and Escape.
-       let isOpen = $state(false);
-       let inputValue = $state('');
        let searchInputRef: HTMLInputElement | null = $state(null);
 
        let queryResults = $state<string[]>([]);
-       let isSearching = $state(false);
        let searchError = $state<string | null>(null);
-       let hoveredIndex = $state(-1);
-       // Bumped only by ArrowUp/ArrowDown handlers; the list scrolls the
-       // highlighted row into view only via this trigger, never on hover.
-       let scrollTrigger = $state(0);
        let listContainer = $state<HTMLDivElement | null>(null);
 
-       // Absolute home directory on the server, resolved once per session by
-       // the tools store. Anchors both the search scope and the chip's `~`
-       // abbreviation.
+       const nav = usePickerNavigation({
+               isOpen: () => isOpen,
+               count: () => queryResults.length,
+               onClose: closePicker,
+               onSelect: (index) => commit(queryResults[index])
+       });
+
        let homeBase = $derived(toolsStore.serverHome);
 
-       // AbortController + sequence counter to discard stale responses when the user
-       // keeps typing; a newer call aborts the previous one. The sequence counter
-       // also covers the gap between abort and the catch handler.
-       let searchController: AbortController | null = null;
-       let searchSeq = 0;
-
-       // Cache of the last file_glob_search result per (parent, include, max_depth),
-       // so repeated queries in the same directory don't re-walk the tree. Entering
-       // a directory hits it every time: the children listed for an exactly typed
-       // segment are what the next keystroke, the trailing slash, asks for again.
-       const SEARCH_CACHE_TTL_MS = 2000;
-       const searchCache = new SvelteMap<string, { results: GlobEntry[]; base: string; at: number }>();
-
-       const runSearch = debounce((query: string) => {
-               void doSearch(query);
-       }, SEARCH_DEBOUNCE_MS);
-
-       // Resolve home eagerly on mount so the chip can abbreviate before the
-       // user opens the picker. resolveServerHome() is cached, so repeat calls
-       // (e.g. from handleOpenChange) are no-ops.
+       // Resolve home eagerly so the chip can abbreviate before the picker opens.
        $effect(() => {
                if (typeof window === 'undefined') return;
                void toolsStore.resolveServerHome();
        });
 
-       // Auto-focus the search input when the popover opens.
-       // HTML `autofocus` is unreliable on dynamically shown elements, so we
-       // use a microtask (0ms setTimeout) after the effect flushes.
+       // HTML `autofocus` is unreliable on dynamically shown elements.
        $effect(() => {
                if (!isOpen) return;
                setTimeout(() => searchInputRef?.focus(), FOCUS_DELAY_MS);
        });
 
-       let lastScrollTrigger: number | null = null;
-
-       // hoveredIndex/queryResults are untracked so hover and result replacement
-       // never re-fire the scroll; keyboard nav is the only path that bumps the trigger
        $effect(() => {
-               if (scrollTrigger === lastScrollTrigger) return;
-               lastScrollTrigger = scrollTrigger;
-               untrack(() => {
-                       if (!listContainer) return;
-                       if (hoveredIndex < 0 || hoveredIndex >= queryResults.length) return;
-                       const selectedElement = listContainer.querySelector(
-                               `[data-result-index="${hoveredIndex}"]`
-                       ) as HTMLElement | null;
-                       selectedElement?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
-               });
-       });
-
-       function cancelSearch() {
-               searchController?.abort();
-               searchSeq++;
-               isSearching = false;
-       }
-
-       // Effective directory the current search runs against (shown in the
-       // footer); updated by doSearch, including when an exactly-typed
-       // directory is "entered".
-       let searchScope = $state(HOME_TILDE);
-
-       // Runs a directory listing through the cache, so a repeated query in the
-       // same directory does not re-walk the tree on the server.
-       async function searchDirs(
-               path: string,
-               include: string,
-               maxDepth: number,
-               signal: AbortSignal
-       ): Promise<{ base: string; entries: GlobEntry[]; error?: string }> {
-               const key = `${path}\u0000${include}\u0000${maxDepth}`;
-               const cached = searchCache.get(key);
-               if (cached && Date.now() - cached.at < SEARCH_CACHE_TTL_MS) {
-                       return { base: cached.base, entries: cached.results };
-               }
-               const res = await ToolsService.executeToolRaw(
-                       BuiltInTool.FILE_GLOB_SEARCH,
-                       { path, type: GlobSearchType.DIR, include, max_depth: maxDepth, limit: SEARCH_LIMIT },
-                       signal
-               );
-               if (typeof res.error === 'string') return { base: '', entries: [], error: res.error };
-               const base = typeof res.base === 'string' ? res.base : '';
-               const entries = Array.isArray(res.entries) ? (res.entries as GlobEntry[]) : [];
-               const now = Date.now();
-               for (const [k, v] of searchCache) {
-                       if (now - v.at >= SEARCH_CACHE_TTL_MS) searchCache.delete(k);
-               }
-               searchCache.set(key, { results: entries, base, at: now });
-               return { base, entries };
-       }
-
-       async function doSearch(query: string) {
-               const trimmed = query.trim();
-               if (!trimmed) {
+               if (!isOpen) return;
+               const q = query.trim();
+               nav.reset(-1);
+               if (q) {
+                       search.run(q);
+               } else {
+                       search.cancel();
                        queryResults = [];
                        searchError = null;
-                       isSearching = false;
-                       hoveredIndex = -1;
+                       nav.reset(-1);
                        searchScope = homeBase ?? HOME_TILDE;
-                       return;
                }
+       });
 
-               cancelSearch();
-               const controller = new AbortController();
-               searchController = controller;
-               const mySeq = ++searchSeq;
+       useScrollActiveRow({
+               getTrigger: () => nav.scrollTrigger,
+               getContainer: () => listContainer,
+               getIndex: () => nav.hoveredIndex,
+               getCount: () => queryResults.length,
+               dataIndex: 'result'
+       });
 
-               const pathQuery = splitPathQuery(trimmed);
+       let searchScope = $state(HOME_TILDE);
 
-               isSearching = true;
-               try {
-                       // A generous limit is requested because ranking happens
-                       // client-side; only the top 20 are shown.
-                       const searchPath = pathQuery ? pathQuery.parent : (homeBase ?? HOME_TILDE);
-                       const include = pathQuery
-                               ? pathQuery.last
-                                       ? buildCaseInsensitiveGlob(pathQuery.last)
-                                       : GLOB_WILDCARD
-                               : buildCaseInsensitiveGlob(trimmed);
-                       const maxDepth = pathQuery ? PATH_NAV_MAX_DEPTH : SEARCH_MAX_DEPTH;
-                       const res = await searchDirs(searchPath, include, maxDepth, controller.signal);
-                       if (mySeq !== searchSeq) return;
-                       if (res.error) {
+       // An exactly-typed directory is "entered": the shared search lists its
+       // children too, so path navigation does not require a trailing slash.
+       const search = useDebouncedSearch({
+               debounceMs: SEARCH_DEBOUNCE_MS,
+               canRun: () => isOpen,
+               getQuery: () => query.trim(),
+               run: async (q, signal, isCurrent) => {
+                       const trimmed = q.trim();
+                       if (!trimmed) {
                                queryResults = [];
-                               hoveredIndex = -1;
-                               searchError = res.error;
+                               searchError = null;
+                               nav.reset(-1);
+                               searchScope = homeBase ?? HOME_TILDE;
                                return;
                        }
-                       const { base, entries } = res;
-                       const ranked = rankEntries(entries, pathQuery?.last ?? trimmed);
-                       let results = ranked.map((e) => joinPath(base, e.path));
-                       searchScope = pathQuery ? pathQuery.parent : (homeBase ?? HOME_TILDE);
-
-                       // An exactly-typed directory is "entered": list its children too,
-                       // so path navigation doesn't require a trailing slash.
-                       const last = pathQuery?.last;
-                       const exact = last
-                               ? ranked.find((e) => lastPathSegment(e.path).toLowerCase() === last.toLowerCase())
-                               : undefined;
-                       if (exact) {
-                               const exactDir = joinPath(base, exact.path);
-                               const childRes = await searchDirs(
-                                       exactDir,
-                                       GLOB_WILDCARD,
-                                       PATH_NAV_MAX_DEPTH,
-                                       controller.signal
+
+                       try {
+                               // Generous limit: ranking is client-side, only the top
+                               // MAX_RESULTS_SHOWN are shown.
+                               const res = await runGlobSearchWithChildren(
+                                       trimmed,
+                                       homeBase ?? HOME_TILDE,
+                                       SEARCH_MAX_DEPTH,
+                                       SEARCH_LIMIT,
+                                       signal,
+                                       { type: GlobSearchType.DIR }
                                );
-                               if (mySeq !== searchSeq) return;
-                               if (!childRes.error) {
-                                       const children = childRes.entries
-                                               .map((e) => joinPath(childRes.base, e.path))
-                                               .sort((a, b) => a.localeCompare(b));
-                                       results = [...results, ...children];
-                                       searchScope = exactDir;
+                               if (!isCurrent()) return;
+                               if (res.error) {
+                                       queryResults = [];
+                                       nav.reset(-1);
+                                       searchError = res.error;
+                                       return;
                                }
-                       }
 
-                       queryResults = results.slice(0, MAX_RESULTS_SHOWN);
-                       hoveredIndex = queryResults.length > 0 ? 0 : -1;
-                       // new results: scroll the list back to the top (first item is hovered)
-                       if (hoveredIndex === 0) scrollTrigger++;
-                       searchError = null;
-               } catch (err) {
-                       if (mySeq !== searchSeq) return;
-                       queryResults = [];
-                       hoveredIndex = -1;
-                       if (controller.signal.aborted) return;
-                       searchError = err instanceof Error ? err.message : String(err);
-               } finally {
-                       if (mySeq === searchSeq) isSearching = false;
+                               searchScope = res.exactDir ?? res.args.path;
+                               queryResults = res.entries.map((e) => e.path).slice(0, MAX_RESULTS_SHOWN);
+                               if (queryResults.length > 0) {
+                                       nav.reset(0);
+                                       nav.bumpScroll(); // scroll the list back to the top (first item is hovered)
+                               } else {
+                                       nav.reset(-1);
+                               }
+                               searchError = null;
+                       } catch (err) {
+                               if (!isCurrent() || signal.aborted) return;
+                               queryResults = [];
+                               nav.reset(-1);
+                               searchError = err instanceof Error ? err.message : String(err);
+                       }
                }
-       }
-
-       // Single funnel for every local close so the host refocus fires
-       // regardless of which commit/dismiss path ended the interaction.
+       });
+       // Single funnel for every local close so the host refocus always fires.
        function closePicker() {
-               isOpen = false;
                onClose?.();
        }
 
        function commit(path: string) {
-               directory = path;
                onChange?.(path);
                closePicker();
        }
        function setDirectory(value: string) {
                const trimmed = value.trim();
                if (!trimmed) return;
-               directory = trimmed;
                onChange?.(trimmed);
        }
 
-       // Resolve a folder name picked via the browser-native picker (which exposes
-       // only the leaf name) to a server-side absolute path. Returns null when the
-       // server cannot locate a matching directory, so the caller can fail visibly
-       // instead of committing a bare leaf name that would resolve against the
-       // server process working directory.
+       // Resolve a browser-picked folder name (which exposes only the leaf name)
+       // to a server-side absolute path; null when the server cannot locate it,
+       // so the caller fails visibly instead of committing a bare leaf name.
        async function resolveNativeName(name: string): Promise<string | null> {
                try {
                        const res = await ToolsService.executeToolRaw(BuiltInTool.FILE_GLOB_SEARCH, {
        }
 
        function handleSubmit() {
-               const value = inputValue.trim();
+               const value = query.trim();
                if (!value) {
                        closePicker();
                        return;
        function handleKeydown(event: KeyboardEvent) {
                if (event.key === KeyboardKey.ENTER) {
                        event.preventDefault();
-                       // Commit the highlighted result, falling back to the raw input
-                       // only when the query returned no matches.
-                       if (hoveredIndex >= 0 && queryResults[hoveredIndex]) {
-                               commit(queryResults[hoveredIndex]);
+                       if (nav.hoveredIndex >= 0 && queryResults[nav.hoveredIndex]) {
+                               commit(queryResults[nav.hoveredIndex]);
                        } else if (queryResults.length === 0) {
                                handleSubmit();
                        }
                } else if (event.key === KeyboardKey.ARROW_DOWN) {
                        if (queryResults.length > 0) {
                                event.preventDefault();
-                               hoveredIndex = (hoveredIndex + 1) % queryResults.length;
-                               scrollTrigger++;
+                               nav.move(1);
                        }
                } else if (event.key === KeyboardKey.ARROW_UP) {
                        if (queryResults.length > 0) {
                                event.preventDefault();
-                               hoveredIndex = hoveredIndex <= 0 ? queryResults.length - 1 : hoveredIndex - 1;
-                               scrollTrigger++;
+                               nav.move(-1);
                        }
                }
        }
 
-       function handleInputInput(value: string) {
-               hoveredIndex = -1;
-               if (value.trim().length > 0) {
-                       runSearch(value);
-               }
-       }
-
        function clearDirectory(event?: MouseEvent) {
-               // Stop the click from bubbling into the popover trigger and re-opening
+               // Stop the click from bubbling into the chip button and re-opening
                // the picker on top of the now-cleared state.
                event?.stopPropagation();
                event?.preventDefault();
-               directory = null;
                onChange?.(null);
                closePicker();
        }
 
-       // The chip is always visible; the X clears the directory (no-op when
-       // already empty).
        function handleDismiss(event?: MouseEvent) {
                event?.stopPropagation();
                event?.preventDefault();
        }
 
        function handleOpenChange(open: boolean) {
-               isOpen = open;
                if (open) {
-                       // Seed the search field with the current path so the user can refine it
-                       // (or hit Enter to confirm / clear via the X icon).
-                       inputValue = directory ?? '';
-                       hoveredIndex = -1;
-                       queryResults = [];
-                       searchError = null;
                        void toolsStore.resolveServerHome();
-                       searchScope = homeBase ?? HOME_TILDE;
-                       if (inputValue.trim()) runSearch(inputValue);
                } else {
-                       cancelSearch();
-                       // bits-ui-initiated close (Escape on the content, outside-click,
-                       // trigger toggle) - the only path that bypasses closePicker().
+                       search.cancel();
+                       // bits-ui-initiated close (Escape on the content, outside-click) -
+                       // the only path that bypasses closePicker().
                        onClose?.();
                }
        }
 
-       // Tooltips only on wider viewports - hover surfaces get in the way on
-       // touch / narrow layouts. Mirrors the gate used in ActionIcon.
        let innerWidth = $state(0);
        const showTooltip = $derived(innerWidth > DEFAULT_MOBILE_BREAKPOINT);
 </script>
 
-<div
+<button
+       type="button"
        class={[
                'justify-self-start flex min-w-0 w-auto items-center gap-1 mt-1.5 py-1 px-2 backdrop-blur-2xl rounded-md',
-               className,
-               isOpen && 'w-full'
+               className
        ]}
+       onclick={onOpen}
+       {disabled}
 >
-       <Popover.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
-               <Popover.Trigger {disabled} class="flex justify-start">
-                       <ChatFormWorkingDirectoryChip
-                               {directory}
-                               {homeBase}
-                               {disabled}
-                               {showTooltip}
-                               onClear={handleDismiss}
+       <ChatFormWorkingDirectoryChip
+               {directory}
+               {homeBase}
+               {disabled}
+               {showTooltip}
+               onClear={handleDismiss}
+       />
+</button>
+
+<Popover.Root open={isOpen} onOpenChange={handleOpenChange}>
+       <Popover.Trigger
+               class="pointer-events-none absolute inset-0 opacity-0"
+               tabindex={-1}
+               aria-hidden="true"
+       >
+               <span class="sr-only">Open working directory picker</span>
+       </Popover.Trigger>
+
+       <Popover.Content
+               side="top"
+               align="start"
+               sideOffset={12}
+               {customAnchor}
+               preventScroll={false}
+               onkeydown={handleKeydown}
+               onOpenAutoFocus={(event) => event.preventDefault()}
+               onCloseAutoFocus={(event) => event.preventDefault()}
+               class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl"
+       >
+               <div class="p-2 min-h-22 flex flex-col justify-between">
+                       <SearchInput
+                               bind:ref={searchInputRef}
+                               bind:value={query}
+                               placeholder="Choose working directory"
+                               onClose={closePicker}
+                               class="w-full"
                        />
-               </Popover.Trigger>
-
-               <Popover.Content
-                       side="top"
-                       align="start"
-                       sideOffset={4}
-                       class="md:max-w-3xl w-[calc(100vw-1rem)] rounded-xl border-border/50 p-0 shadow-xl md:-translate-2!"
-                       onkeydown={handleKeydown}
-                       onOpenAutoFocus={(event) => event.preventDefault()}
-               >
-                       <div class="p-2 min-h-28 flex flex-col justify-between">
-                               <SearchInput
-                                       bind:ref={searchInputRef}
-                                       bind:value={inputValue}
-                                       placeholder="Choose working directory"
-                                       onInput={handleInputInput}
-                                       onClose={closePicker}
-                                       class="w-full"
-                               />
 
-                               {#if inputValue.trim() && (isSearching || queryResults.length > 0 || searchError)}
-                                       <ChatFormWorkingDirectoryResultsList
-                                               results={queryResults}
-                                               {hoveredIndex}
-                                               {isSearching}
-                                               error={searchError}
-                                               rawQuery={inputValue}
-                                               bind:container={listContainer}
-                                               onCommit={commit}
-                                               onHover={(index) => (hoveredIndex = index)}
-                                       />
-                               {/if}
-
-                               {#if pickerSupported}
-                                       <button
-                                               type="button"
-                                               class="-mt-1 flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
-                                               onclick={browseNative}
+                       {#if query.trim() && (search.isSearching || queryResults.length > 0 || searchError)}
+                               <ChatFormWorkingDirectoryResultsList
+                                       results={queryResults}
+                                       hoveredIndex={nav.hoveredIndex}
+                                       isSearching={search.isSearching}
+                                       error={searchError}
+                                       rawQuery={query}
+                                       bind:container={listContainer}
+                                       onCommit={commit}
+                                       onHover={(index) => nav.setHover(index)}
+                               />
+                       {/if}
+
+                       {#if pickerSupported}
+                               <button
+                                       type="button"
+                                       class="-mt-1 flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
+                                       onclick={browseNative}
+                               >
+                                       <FolderOpen class="size-4 shrink-0 text-muted-foreground" />
+                                       <span>Browse</span>
+                               </button>
+                       {/if}
+
+                       {#if homeBase}
+                               <div class="-mx-2 my-2 h-px bg-border/20" aria-hidden="true"></div>
+
+                               <span class="px-2 py-1.5 font-mono text-[10px]">
+                                       Searching in:
+
+                                       <span class="truncate text-muted-foreground/70" title={searchScope}
+                                               >{abbreviateHome(searchScope, homeBase)}</span
                                        >
-                                               <FolderOpen class="size-4 shrink-0 text-muted-foreground" />
-                                               <span>Browse</span>
-                                       </button>
-                               {/if}
-
-                               {#if homeBase}
-                                       <div class="-mx-2 my-1 h-px bg-border/20" aria-hidden="true"></div>
-
-                                       <span class="px-2 py-2 font-mono text-[10px]">
-                                               Searching in:
-
-                                               <span class="truncate text-muted-foreground/70" title={searchScope}
-                                                       >{abbreviateHome(searchScope, homeBase)}</span
-                                               >
-                                       </span>
-                               {/if}
-                       </div>
-               </Popover.Content>
-       </Popover.Root>
-</div>
+                               </span>
+                       {/if}
+               </div>
+       </Popover.Content>
+</Popover.Root>
 
 <svelte:window bind:innerWidth />
index 4f8d0f7f7d77b89307174b65c03239573e3dcb26..2882f557c84fe74d0cf20e57cad4ae9628e8847b 100644 (file)
@@ -1,6 +1,7 @@
 <script lang="ts">
        import { Folder, X } from '@lucide/svelte';
        import { abbreviateWorkingDir } from '$lib/utils';
+       import { SET_WORKING_DIRECTORY_LABEL } from '$lib/constants';
        import * as Tooltip from '$lib/components/ui/tooltip';
        import { ActionIcon } from '$lib/components/app/actions';
 
@@ -21,7 +22,7 @@
        }: Props = $props();
 
        const displayLabel = $derived(
-               directory ? abbreviateWorkingDir(directory, homeBase) : 'Select working directory'
+               directory ? abbreviateWorkingDir(directory, homeBase) : SET_WORKING_DIRECTORY_LABEL
        );
        // Full path surface: hover the abbreviated label to recall the exact directory.
        const displayLabelTitle = $derived(directory ?? '');
index 199d75fcec95bc560bda36cb425937f188373c7e..ec1214d180bf4ebf06f60032f3182e7d9701d409 100644 (file)
                <ChatMessageAssistantProcessingInfo {modelLoadingText} {processingState} position="bottom" />
        {/if}
 
-       <div class="info my-6 grid gap-4 tabular-nums">
-               {#if displayedModel}
+       {#if displayedModel}
+               <div class="info my-6 grid gap-4 tabular-nums">
                        <div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
                                <ChatMessageAssistantModel
                                        {displayedModel}
                                        showMessageStats={currentConfig.showMessageStats}
                                />
                        </div>
-               {/if}
-       </div>
+               </div>
+       {/if}
 
        {#if message.timestamp && !editCtx.isEditing}
                <ChatMessageActionIcons
index 24b3be4c5ff774715e5ed055b3a1687c9bd722a1..04185814dbf62e06498b9815ca1519759febdf2e 100644 (file)
                                                                ? `max-height: ${MAX_HEIGHT}px;`
                                                                : 'max-height: none;'}
                                                >
-                                                       {#if currentConfig.renderUserContentAsMarkdown}
+                                                       {#if !currentConfig.renderContentAsRawText}
                                                                <div bind:this={messageElement} class={isExpanded ? 'cursor-text' : ''}>
                                                                        <MarkdownContent class="markdown-system-content" content={message.content} />
                                                                </div>
index a17a74e161266fc5e543e975a58e3535cb3e93b4..3b5e87e894cb7c6b85645ab6c5aa8d60d6c6b4f2 100644 (file)
                showSpinner || (toolUi?.icon ?? null) || !mcpServerFavicon ? null : mcpServerFavicon
        );
 
+       // No subtitle while the call is in flight - the spinner already
+       // signals activity; only terminal states get a pill.
        function subtitleFor(errorMessage?: string): string | undefined {
-               if (extraLiveStreaming) return 'streaming...';
-               if (showSpinner) return 'executing...';
+               if (showSpinner) return undefined;
                if (errorMessage) return 'failed';
                if (isStreamingCall && !isStreaming) return 'incomplete';
                return undefined;
index 04e6715bf058ffb1772dc22a48c8fdb0b9d98d0f..49cc39723a47d3b0323cbdadf141ef6cc8b03012 100644 (file)
@@ -63,7 +63,7 @@
                data-multiline={isMultiline ? '' : undefined}
                style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
        >
-               {#if renderMarkdown && currentConfig.renderUserContentAsMarkdown}
+               {#if renderMarkdown && !currentConfig.renderContentAsRawText}
                        <div bind:this={messageElement}>
                                <MarkdownContent class="markdown-user-content" {content} />
                        </div>
index 751d13756271c61c7951d5a546b9de11d4c1349e..74d5bdfe85075e352a9f400152483fea7ecb1730 100644 (file)
@@ -41,7 +41,6 @@
 
        let expandedStates: Record<number, boolean> = $state({});
 
-       const renderThinkingAsMarkdown = $derived(config().renderThinkingAsMarkdown as boolean);
        const showThoughtInProgress = $derived(Boolean(config().showThoughtInProgress));
        const alwaysShowToolCallContent = $derived(Boolean(config().alwaysShowToolCallContent));
        const showMessageStats = $derived(Boolean(config().showMessageStats));
                        {section}
                        open={isExpanded(index, section)}
                        {isStreaming}
-                       {renderThinkingAsMarkdown}
                        {hasReasoningError}
                        attachments={message?.extra}
                        onToggle={() => toggleExpanded(index, section)}
index 833cae5db529b8bfd6ac9e108585e19a76926900..3ec417327f05fc0ddf23a797044f11197cd27050 100644 (file)
@@ -3,6 +3,7 @@
        import { CollapsibleContentBlock, MarkdownContent } from '$lib/components/app';
        import { AgenticSectionType } from '$lib/enums';
        import { REASONING_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
+       import { config } from '$lib/stores/settings.svelte';
        import type { DatabaseMessageExtra } from '$lib/types';
        import type { AgenticSection } from '$lib/utils';
 
@@ -10,7 +11,6 @@
                section: AgenticSection;
                open: boolean;
                isStreaming: boolean;
-               renderThinkingAsMarkdown: boolean;
                hasReasoningError?: boolean;
                attachments?: DatabaseMessageExtra[];
                onToggle?: () => void;
                section,
                open,
                isStreaming,
-               renderThinkingAsMarkdown,
                hasReasoningError = false,
                attachments,
                onToggle
        }: Props = $props();
 
+       const currentConfig = config();
+
        const REASONING_HEADER = 'Reasoning';
        const REASONING_HEADER_PENDING = 'Reasoning...';
        const REASONING_SUBTITLE_ERROR = 'Error';
                class:is-streaming={isPending}
                onscroll={handleScrollEvent}
        >
-               {#if renderThinkingAsMarkdown}
+               {#if !currentConfig.renderContentAsRawText}
                        <MarkdownContent content={section.content} class="text-muted-foreground" {attachments} />
                {:else}
                        <div
index 19d26cb09e52d34471890cbe0537a1b48b281fcc..b7a2eff5de966876652780a00aa0151f246e166f 100644 (file)
@@ -266,9 +266,9 @@ export { default as ChatFormFileInputInvisible } from './ChatForm/ChatFormFileIn
 export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResourcesList.svelte';
 
 /**
- * Auto-resizing textarea with IME composition support. Automatically adjusts
- * height based on content. Handles IME input correctly (waits for composition
- * end before processing Enter key). Exposes focus() and resetHeight() methods.
+ * Auto-resizing textarea with IME composition support. Mention links stay
+ * plain markdown text in the input; the chip rendering happens in the
+ * message view via the rehype file-badge plugin.
  */
 export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte';
 
@@ -384,11 +384,15 @@ export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPi
 export { default as ChatFormMentionPicker } from './ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte';
 
 /**
- * **ChatFormPickers** - Chat input picker container
- *
- * Container component that hosts the MCP prompt and file mention pickers.
- * Manages shared state, keyboard navigation, and coordination between the two
- * picker interfaces. Used within ChatForm.
+ * `/`-triggered slash-command picker. Lists the available slash commands
+ * (`/prompt`, `/cwd`, `/model`) filtered by the typed query; selection
+ * hands the command to the parent for dispatch.
+ */
+export { default as ChatFormCommandPicker } from './ChatForm/ChatFormPickers/ChatFormCommandPicker.svelte';
+
+/**
+ * Hosts the chat-form pickers (slash-command, MCP prompt, file mention)
+ * and delegates keyboard events to the active one.
  */
 export { default as ChatFormPickers } from './ChatForm/ChatFormPickers/ChatFormPickers.svelte';
 
index 720963a8db9dc45205a2270ae19e4fe1f8de6dc8..415e5164d820135744ca8d87988e92fb6939c748 100644 (file)
@@ -1,8 +1,9 @@
 <script lang="ts">
-       import { ChevronDown, Loader2, Package } from '@lucide/svelte';
+       import { ChevronDown, Loader2 } from '@lucide/svelte';
        import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
        import * as Tooltip from '$lib/components/ui/tooltip';
        import { KeyboardKey, ServerModelStatus } from '$lib/enums';
+       import { MODEL_SELECTOR_ICON } from '$lib/constants';
        import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
        import { modelsStore, routerModels } from '$lib/stores/models.svelte';
        import { modelLoadFraction } from '$lib/utils';
@@ -35,7 +36,7 @@
        }: Props = $props();
 
        let isOpen = $state(false);
-       let highlightedIndex = $state<number>(-1);
+       let highlightedId = $state<string | null>(null);
 
        const ms = useModelsSelector({
                currentModel: () => currentModel,
                onModelChange: () => onModelChange,
                onOpenChange: (open) => {
                        isOpen = open;
-                       highlightedIndex = -1;
+                       highlightedId = null;
                }
        });
 
        $effect(() => {
                void ms.searchTerm;
-               highlightedIndex = -1;
+               highlightedId = null;
        });
 
+       // Focus the dropdown's search box without scrolling the page. bits-ui
+       // auto-focuses the opened content by default, which can yank the page
+       // scroll; we prevent that on the Content and refocus the search here.
+       $effect(() => {
+               if (!isOpen) return;
+
+               requestAnimationFrame(() => {
+                       const search = document.querySelector<HTMLElement>(
+                               '[data-slot="dropdown-menu-content"] input'
+                       );
+
+                       search?.focus({ preventScroll: true });
+               });
+       });
+
+       // Keyboard navigation follows the on-screen row order, not the flat option list order.
+       let visualOrder = $derived.by(() => {
+               const order: string[] = [];
+
+               for (const item of ms.groupedFilteredOptions.loaded) order.push(item.option.id);
+               for (const item of ms.groupedFilteredOptions.favorites) order.push(item.option.id);
+               for (const group of ms.groupedFilteredOptions.available) {
+                       for (const item of group.items) order.push(item.option.id);
+               }
+
+               return order;
+       });
+
+       let highlightedIndex = $derived(highlightedId ? visualOrder.indexOf(highlightedId) : -1);
+
+       function moveHighlight(direction: 1 | -1) {
+               const len = visualOrder.length;
+               if (len === 0) {
+                       highlightedId = null;
+                       return;
+               }
+
+               let index = highlightedIndex;
+               if (index === -1) {
+                       index = direction === 1 ? 0 : len - 1;
+               } else {
+                       index = (index + direction + len) % len;
+               }
+
+               highlightedId = visualOrder[index];
+       }
+
+       // Alt+Enter only unloads and keeps the dropdown open.
+       async function handleModelKeyAction(modelId: string, unload: boolean) {
+               if (!unload) {
+                       void ms.handleSelect(modelId);
+                       return;
+               }
+
+               const model = routerModels().find((m) => m.id === modelId);
+               const status = model?.status?.value as ServerModelStatus | undefined;
+
+               if (status === ServerModelStatus.LOADING) return;
+
+               await modelsStore.unloadModel(modelId);
+       }
+
        export function open() {
                ms.handleOpenChange(true);
        }
 
                if (event.key === KeyboardKey.ARROW_DOWN) {
                        event.preventDefault();
-
-                       if (ms.filteredOptions.length === 0) return;
-
-                       if (highlightedIndex === -1 || highlightedIndex === ms.filteredOptions.length - 1) {
-                               highlightedIndex = 0;
-                       } else {
-                               highlightedIndex += 1;
-                       }
+                       moveHighlight(1);
                } else if (event.key === KeyboardKey.ARROW_UP) {
                        event.preventDefault();
-
-                       if (ms.filteredOptions.length === 0) return;
-
-                       if (highlightedIndex === -1 || highlightedIndex === 0) {
-                               highlightedIndex = ms.filteredOptions.length - 1;
-                       } else {
-                               highlightedIndex -= 1;
-                       }
+                       moveHighlight(-1);
                } else if (event.key === KeyboardKey.ENTER) {
                        event.preventDefault();
 
-                       if (highlightedIndex >= 0 && highlightedIndex < ms.filteredOptions.length) {
-                               const option = ms.filteredOptions[highlightedIndex];
-
-                               ms.handleSelect(option.id);
-                       } else if (ms.filteredOptions.length > 0) {
-                               highlightedIndex = 0;
+                       if (highlightedId) {
+                               void handleModelKeyAction(highlightedId, event.altKey);
+                       } else if (visualOrder.length > 0) {
+                               highlightedId = visualOrder[0];
                        }
                }
        }
                                ]}
                                style="max-width: min(calc(100cqw - 10rem), 20rem)"
                        >
-                               <Package class="h-3.5 w-3.5 shrink-0" />
+                               <MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
                        </span>
                {:else}
                        <p class="text-xs text-muted-foreground">No models available.</p>
                                                                ]}
                                                                disabled={disabled || ms.updating}
                                                        >
-                                                               <Package class="h-3.5 w-3.5 shrink-0" />
+                                                               <MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
 
                                                                {#if selectedOption}
                                                                        <ModelId
                                <DropdownMenu.Content
                                        align="end"
                                        class="w-full max-w-[100vw] pt-0 sm:w-max sm:max-w-[calc(100vw-2rem)]"
+                                       onOpenAutoFocus={(event) => event.preventDefault()}
                                >
                                        <DropdownMenuSearchable
                                                searchValue={ms.searchTerm}
                                                        {/if}
 
                                                        {#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
-                                                               {@const { option, flatIndex } = item}
+                                                               {@const { option } = item}
                                                                {@const isSelected = currentModel === option.model || ms.activeId === option.id}
-                                                               {@const isHighlighted = flatIndex === highlightedIndex}
+                                                               {@const isHighlighted = option.id === highlightedId}
                                                                {@const isFav = ms.isFavorite(option.model)}
 
                                                                <ModelsSelectorOption
                                                                        {hideOrgName}
                                                                        onSelect={ms.handleSelect}
                                                                        onInfoClick={ms.handleInfoClick}
-                                                                       onMouseEnter={() => (highlightedIndex = flatIndex)}
+                                                                       onMouseEnter={() => (highlightedId = option.id)}
                                                                        onKeyDown={(event) => {
                                                                                if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
                                                                                        event.preventDefault();
-                                                                                       ms.handleSelect(option.id);
+                                                                                       void handleModelKeyAction(option.id, event.altKey);
                                                                                }
                                                                        }}
                                                                />
                                                        onclick={() => ms.handleOpenChange(true)}
                                                        disabled={disabled || ms.updating}
                                                >
-                                                       <Package class="h-3.5 w-3.5 shrink-0" />
+                                                       <MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
 
                                                        {#if selectedOption}
                                                                <ModelId
index 9671615a4f846f6d46493950bdaf378dffe9c622..a1b00e03e8875c5314d322481b1b494e3ca77fef 100644 (file)
 <div
        class={[
                'group relative flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
-               'cursor-pointer hover:bg-muted focus:bg-muted',
-               (isSelected || isHighlighted) && 'bg-accent text-accent-foreground',
-               !(isSelected || isHighlighted) && 'hover:bg-accent hover:text-accent-foreground',
+               'cursor-pointer',
+               isSelected && 'bg-accent/50 text-accent-foreground',
+               isHighlighted && 'bg-accent',
+               !isSelected && !isHighlighted && 'hover:bg-muted',
                isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
        ]}
        role="option"
index 5d772359a6d18fb572a748f121c9a7ddec2ea749..8dfb04530cda3cba1eb135816a75d942e0be0d82 100644 (file)
                                const numValue = Number(processedConfig[field]);
                                if (!isNaN(numValue)) {
                                        if ((POSITIVE_INTEGER_FIELDS as readonly string[]).includes(field)) {
-                                               processedConfig[field] = Math.max(1, Math.round(numValue));
+                                               const entryByMinMax = SETTINGS_CHAT_SECTIONS.flatMap(
+                                                       (section) => section.fields ?? []
+                                               ).find((entry) => entry.key === field);
+                                               const lo = entryByMinMax?.min ?? 1;
+                                               const hi = entryByMinMax?.max ?? Number.POSITIVE_INFINITY;
+                                               processedConfig[field] = Math.max(lo, Math.min(hi, Math.round(numValue)));
                                        } else {
                                                processedConfig[field] = numValue;
                                        }
index fa871cc98350720603414390d16111907d1f7de5..a54c61556ce71bdea9b9e1222a2fa193b3e13717 100644 (file)
                                        <Input
                                                id={field.key}
                                                type={field.isPositiveInteger ? 'number' : 'text'}
-                                               {...field.isPositiveInteger ? { min: '1', step: '1' } : {}}
+                                               {...field.isPositiveInteger
+                                                       ? {
+                                                                       min: String(field.min ?? 1),
+                                                                       step: '1',
+                                                                       ...(field.max != null ? { max: String(field.max) } : {})
+                                                               }
+                                                       : {}}
                                                value={currentValue}
                                                oninput={(e) => onConfigChange(field.key, e.currentTarget.value)}
                                                placeholder={currentModelParams[field.key] != null
                                                        ? `Default: ${normalizeFloatingPoint(currentModelParams[field.key])}`
-                                                       : ''}
+                                                       : (field.placeholder ?? '')}
                                                class="w-full {isCustomRealTime ? 'pr-8' : ''}"
                                        />
                                        {#if isCustomRealTime}
diff --git a/tools/ui/src/lib/constants/chat-commands.ts b/tools/ui/src/lib/constants/chat-commands.ts
new file mode 100644 (file)
index 0000000..bbe762c
--- /dev/null
@@ -0,0 +1,44 @@
+import { SET_WORKING_DIRECTORY_LABEL } from '$lib/constants/working-directory';
+import { ChatFormCommandAction } from '$lib/enums';
+import type { ChatFormCommand } from '$lib/types';
+
+interface ChatCommandsOptions {
+       /** Gates `/model`. */
+       showModelSelector: boolean;
+       /** Gates `/prompt`. */
+       hasPrompts: () => boolean;
+       /** Gates `/cwd`. */
+       hasBuiltinTools: () => boolean;
+}
+
+/**
+ * The slash commands surfaced by the `/` command picker, in display order.
+ *
+ * Availability is supplied as predicates rather than store imports: this
+ * module is re-exported through the `$lib/constants` barrel, and importing
+ * stores at module load would create a circular dependency (the stores
+ * themselves import from `$lib/constants`).
+ */
+export function getChatCommands(options: ChatCommandsOptions): ChatFormCommand[] {
+       return [
+               {
+                       name: 'prompt',
+                       description: 'Insert an MCP prompt',
+                       action: ChatFormCommandAction.PROMPT,
+                       disabled: !options.hasPrompts()
+               },
+               {
+                       name: 'cwd',
+                       description: SET_WORKING_DIRECTORY_LABEL,
+                       keywords: ['current working directory'],
+                       action: ChatFormCommandAction.CWD,
+                       disabled: !options.hasBuiltinTools()
+               },
+               {
+                       name: 'model',
+                       description: 'Select model',
+                       action: ChatFormCommandAction.MODEL,
+                       disabled: !options.showModelSelector
+               }
+       ];
+}
index 3acf16938d8b58885eb63317317aafd90a99d6bf..095b82e4143df377e2e97c9b58a0c00cbcd4a6ed 100644 (file)
@@ -19,6 +19,9 @@ export const PANEL_CLASSES = `
 export const CHAT_FORM_POPOVER_MAX_HEIGHT = 'max-h-80';
 export const DIALOG_SUBMENU_CONTENT = 'w-60';
 
+/** Selects the chat-form input to restore focus after model actions. */
+export const CHAT_INPUT_FOCUS_SELECTOR = '[data-slot="input-area"] textarea';
+
 /** Default Tailwind size class for inline icon components (lucide, etc.). */
 export const ICON_CLASS_DEFAULT = 'h-4 w-4';
 
index 2fcd39ab2af36be0c4929fda328e58e6e691b745..09006cf1e991c952fc8a586eef39c9f31bb6645f 100644 (file)
@@ -18,6 +18,7 @@ export * from './binary-detection';
 export * from './built-in-tools';
 export * from './cache';
 export * from './chat-form';
+export * from './chat-commands';
 export * from './cli-flags';
 export * from './code-blocks';
 export * from './icons';
index db4581201ca261e3d7dfec913fb906ec0dc6a059..c0b5b002ccb3e87cf63685df6b005343b00c70ad 100644 (file)
@@ -23,7 +23,7 @@ export const SETTINGS_KEYS = {
        SHOW_AGENTIC_TURN_STATS: 'showAgenticTurnStats',
        SHOW_THOUGHT_IN_PROGRESS: 'showThoughtInProgress',
        AUTO_MIC_ON_EMPTY: 'autoMicOnEmpty',
-       RENDER_USER_CONTENT_AS_MARKDOWN: 'renderUserContentAsMarkdown',
+       RENDER_CONTENT_AS_RAW_TEXT: 'renderContentAsRawText',
        DISABLE_AUTO_SCROLL: 'disableAutoScroll',
        ALWAYS_SHOW_SIDEBAR_ON_DESKTOP: 'alwaysShowSidebarOnDesktop',
        FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks',
@@ -33,7 +33,6 @@ export const SETTINGS_KEYS = {
        SHOW_BUILD_VERSION: 'showBuildVersion',
        SHOW_FULL_PATH_IN_MENTIONS: 'showFullPathInMentions',
        SHOW_SYSTEM_MESSAGE: 'showSystemMessage',
-       RENDER_THINKING_AS_MARKDOWN: 'renderThinkingAsMarkdown',
        MENTION_SEARCH_MAX_DEPTH: 'mentionSearchMaxDepth',
        // Sampling
        TEMPERATURE: 'temperature',
index 9704413baafc167ebaafa77a4305b7cdc2c495d6..adb2dfb78716aefd8c573d7e433c58b00e67bcd2 100644 (file)
@@ -233,21 +233,13 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
                                section: SETTINGS_SECTION_SLUGS.DISPLAY
                        },
                        {
-                               key: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN,
-                               label: 'Render user content as Markdown',
-                               help: 'Render user messages using markdown formatting in the chat.',
+                               key: SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT,
+                               label: 'Render content as raw text',
+                               help: 'Display user, system and thinking content as plain text instead of formatted Markdown. Markdown is the default so that @-mention badges render in sent messages.',
                                defaultValue: false,
                                type: SettingsFieldType.CHECKBOX,
                                section: SETTINGS_SECTION_SLUGS.DISPLAY
                        },
-                       {
-                               key: SETTINGS_KEYS.RENDER_THINKING_AS_MARKDOWN,
-                               label: 'Render thinking as Markdown',
-                               help: 'Render the reasoning/thinking block content as formatted Markdown instead of plain text.',
-                               defaultValue: true,
-                               type: SettingsFieldType.CHECKBOX,
-                               section: SETTINGS_SECTION_SLUGS.DISPLAY
-                       },
                        {
                                key: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS,
                                label: 'Use full height code blocks',
@@ -724,6 +716,9 @@ export const SETTINGS_CHAT_SECTIONS: SettingsSection[] = [
                        type: s.type,
                        isExperimental: s.isExperimental,
                        isPositiveInteger: s.isPositiveInteger,
+                       placeholder: s.placeholder,
+                       min: s.min,
+                       max: s.max,
                        dependsOn: s.dependsOn,
                        help: s.help,
                        options: s.options,
index 98a074da099568ffbb5211c53895a1de751d99e1..2c18cc518b9caa1352dfd1808f8edbe6071bbe99 100644 (file)
@@ -1,4 +1,4 @@
-import { Search, Settings, SquarePen } from '@lucide/svelte';
+import { Package, Search, Settings, SquarePen } from '@lucide/svelte';
 import McpLogo from '$lib/components/app/mcp/McpLogo.svelte';
 import type { Component } from 'svelte';
 import { ROUTES } from './routes';
@@ -6,6 +6,9 @@ import { ROUTES } from './routes';
 export const FORK_TREE_DEPTH_PADDING = 8;
 export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
 
+/** Icon used for the model selector and the `/model` slash command. */
+export const MODEL_SELECTOR_ICON = Package;
+
 export const ICON_STRIP_TRANSITION_DURATION = 150;
 export const ICON_STRIP_TRANSITION_DELAY_MULTIPLIER = 50;
 
index 2811c16453050b6b4f9c240c60f079f82326a879..20d1d15760213cb1cb8821e4ac65a67653f7710e 100644 (file)
@@ -8,6 +8,9 @@
 
 export const GLOB_WILDCARD = '*';
 
+/** Label shown for the working-directory picker / `/cwd` slash command. */
+export const SET_WORKING_DIRECTORY_LABEL = 'Set working directory';
+
 /** Character that starts and ends a glob character-class fragment. */
 export const GLOB_RANGE_OPEN = '[';
 export const GLOB_RANGE_CLOSE = ']';
index 17a2a4535e6ec1128fb11c34565be77ea0e9fadb..198ff4dd7cbe0f9adfab7a9a2221128d7745601e 100644 (file)
@@ -79,6 +79,12 @@ export enum PdfViewMode {
        PAGES = 'pages'
 }
 
+export enum ChatFormCommandAction {
+       PROMPT = 'prompt',
+       CWD = 'cwd',
+       MODEL = 'model'
+}
+
 export enum FileMentionEntryType {
        FILE = 'file',
        DIRECTORY = 'directory'
index 7323563f3bf9f34835a89e05b1017254a0b46cd9..ebf8b6dbe5b00920b5cb07a71eced331d2cb25ca 100644 (file)
@@ -25,6 +25,7 @@ export {
        MessageType,
        PdfViewMode,
        ReasoningFormat,
+       ChatFormCommandAction,
        FileMentionEntryType
 } from './chat.enums';
 
diff --git a/tools/ui/src/lib/hooks/use-chat-form-pickers.svelte.ts b/tools/ui/src/lib/hooks/use-chat-form-pickers.svelte.ts
new file mode 100644 (file)
index 0000000..f3bc5f7
--- /dev/null
@@ -0,0 +1,355 @@
+import { getChatCommands, PROMPT_TRIGGER_PREFIX } from '$lib/constants';
+import { ChatFormCommandAction, KeyboardKey } from '$lib/enums';
+import type { ChatFormCommand } from '$lib/types';
+import {
+       findCommandToken,
+       findMentionToken,
+       takeCommandDismissSnapshot,
+       takeMentionDismissSnapshot,
+       type CommandDismissSnapshot,
+       type MentionDismissSnapshot
+} from '$lib/utils';
+
+/** Dependencies injected as getters so the hook stays free of store circular imports. */
+export interface UseChatFormPickersOptions {
+       getValue: () => string;
+       /** Also fires the form's onChange. */
+       setValue: (value: string) => void;
+       /** Undefined when unmounted. */
+       getCaretOffset: () => number | undefined;
+       setCaretOffset: (offset: number) => void;
+       focusInput: () => void;
+       /** Gates `/model`. */
+       getShowModelSelector: () => boolean;
+       /** Gates `/prompt`. */
+       hasPrompts: () => boolean;
+       /** Gates `/cwd`. */
+       hasBuiltinTools: () => boolean;
+       getCwd: () => string | null;
+       /** Mention search fallback scope. */
+       getServerHome: () => string | null;
+       openModelSelector: () => void;
+       /** Delegate a keydown to the mounted pickers component, if any. */
+       getPickersRef: () => { handleKeydown(event: KeyboardEvent): boolean } | undefined;
+}
+
+/**
+ * Chat-form picker state and the `/`+`@` routing that drives them.
+ * Owns open/query state, dismiss snapshots and slash-command dispatch;
+ * textarea/caret/attachment handling stays in the chat form.
+ */
+export function useChatFormPickers(opts: UseChatFormPickersOptions) {
+       let isCommandPickerOpen = $state(false);
+       let commandQuery = $state('');
+       let isPromptPickerOpen = $state(false);
+       let promptSearchQuery = $state('');
+       let isMentionPickerOpen = $state(false);
+       let mentionQuery = $state('');
+       let isWorkingDirectoryPickerOpen = $state(false);
+       let workingDirectoryQuery = $state('');
+
+       // Last dismissed `@`-mention token; while intact, the picker does not
+       // reopen, so an escaped `@<query>` stays literal until edited.
+       let mentionDismissedSnapshot: MentionDismissSnapshot | null = null;
+
+       // Same dismissal contract for the `/`-command token.
+       let commandDismissedSnapshot: CommandDismissSnapshot | null = null;
+
+       // Fall back to the server home so the picker still finds matches
+       // before a cwd is set.
+       const mentionScopePath = $derived(opts.getCwd() ?? opts.getServerHome() ?? null);
+
+       const availableCommands = $derived(
+               getChatCommands({
+                       showModelSelector: opts.getShowModelSelector(),
+                       hasPrompts: opts.hasPrompts,
+                       hasBuiltinTools: opts.hasBuiltinTools
+               })
+       );
+
+       // Dispatch a slash command picked from the list: consume the token and
+       // open the target picker, seeding its search with `args`. Runs only on
+       // explicit selection (Enter/click), so the buffer is never cleared
+       // mid-typing.
+       function dispatchCommand(command: ChatFormCommand, args: string) {
+               isCommandPickerOpen = false;
+               commandQuery = '';
+
+               switch (command.action) {
+                       case ChatFormCommandAction.PROMPT:
+                               isWorkingDirectoryPickerOpen = false;
+                               opts.setValue('');
+                               isPromptPickerOpen = true;
+                               promptSearchQuery = args.trim();
+                               break;
+                       case ChatFormCommandAction.CWD: {
+                               // Keep `/cwd <args>` in the input so the search field and the
+                               // token stay two-way bound; normalize partial tokens (`/cw foo`).
+                               const trimmed = args.trim();
+                               const newValue = `/cwd ${trimmed}`;
+                               if (opts.getValue() !== newValue) {
+                                       opts.setValue(newValue);
+                                       queueMicrotask(() => opts.setCaretOffset(newValue.length));
+                               }
+                               workingDirectoryQuery = trimmed;
+                               isWorkingDirectoryPickerOpen = true;
+                               break;
+                       }
+                       case ChatFormCommandAction.MODEL:
+                               isWorkingDirectoryPickerOpen = false;
+                               opts.setValue('');
+                               opts.openModelSelector();
+                               break;
+               }
+       }
+
+       function handleInput() {
+               const value = opts.getValue();
+               const cursor = opts.getCaretOffset() ?? value.length;
+
+               if (value.startsWith(PROMPT_TRIGGER_PREFIX)) {
+                       isMentionPickerOpen = false;
+                       mentionQuery = '';
+                       isPromptPickerOpen = false;
+                       promptSearchQuery = '';
+
+                       const token = findCommandToken(value);
+                       if (!token) {
+                               isCommandPickerOpen = false;
+                               commandQuery = '';
+                               return;
+                       }
+
+                       // While the `/cwd` picker is open the token doubles as its search
+                       // field: keep the two in sync instead of re-dispatching.
+                       if (isWorkingDirectoryPickerOpen) {
+                               isCommandPickerOpen = false;
+                               commandQuery = '';
+                               if (token.name === 'cwd') {
+                                       workingDirectoryQuery = token.args.trim();
+                               } else {
+                                       isWorkingDirectoryPickerOpen = false;
+                                       workingDirectoryQuery = '';
+                               }
+                               return;
+                       }
+
+                       // Dismissed token stays literal until it changes.
+                       const isDismissedSticky =
+                               commandDismissedSnapshot !== null &&
+                               commandDismissedSnapshot.name === token.name &&
+                               commandDismissedSnapshot.args === token.args;
+
+                       if (isDismissedSticky) {
+                               isCommandPickerOpen = false;
+                               commandQuery = '';
+                               return;
+                       }
+
+                       // Commands dispatch only on explicit selection (Enter/click),
+                       // never mid-typing: `/model is broken` is prose until the user
+                       // picks the command from the list.
+                       if (availableCommands.length > 0) {
+                               isCommandPickerOpen = true;
+                               commandQuery = token.name;
+                       } else {
+                               isCommandPickerOpen = false;
+                               commandQuery = '';
+                       }
+                       return;
+               }
+
+               isCommandPickerOpen = false;
+               commandQuery = '';
+               if (commandDismissedSnapshot !== null) {
+                       commandDismissedSnapshot = null;
+               }
+               if (isWorkingDirectoryPickerOpen) {
+                       isWorkingDirectoryPickerOpen = false;
+               }
+
+               const token = findMentionToken(value, cursor);
+
+               if (token) {
+                       // Dismissed token stays literal: don't reopen until it changes.
+                       const isDismissedSticky =
+                               mentionDismissedSnapshot !== null &&
+                               mentionDismissedSnapshot.start === token.start &&
+                               mentionDismissedSnapshot.query === token.query;
+
+                       if (!isDismissedSticky) {
+                               // Only search once a char follows `@`; a bare `@` is a no-op
+                               // (otherwise the picker flashes an empty hint on re-type).
+                               if (token.query.length > 0) {
+                                       mentionDismissedSnapshot = null;
+                                       isMentionPickerOpen = true;
+                                       mentionQuery = token.query;
+                                       isPromptPickerOpen = false;
+                                       promptSearchQuery = '';
+                                       return;
+                               }
+                       }
+               }
+
+               isPromptPickerOpen = false;
+               promptSearchQuery = '';
+               isMentionPickerOpen = false;
+               mentionQuery = '';
+
+               // Token gone or changed: reset the snapshot so a fresh `@` reopens.
+               if (mentionDismissedSnapshot !== null && !token) {
+                       mentionDismissedSnapshot = null;
+               }
+       }
+
+       function handleKeydown(event: KeyboardEvent): boolean {
+               if (opts.getPickersRef()?.handleKeydown(event)) {
+                       return true;
+               }
+
+               if (event.key === KeyboardKey.ESCAPE && isPromptPickerOpen) {
+                       isPromptPickerOpen = false;
+                       promptSearchQuery = '';
+                       return true;
+               }
+
+               return false;
+       }
+
+       function handleCommandSelect(command: ChatFormCommand) {
+               // Dispatch on the live token so typed args seed the target picker.
+               const token = findCommandToken(opts.getValue());
+               dispatchCommand(command, token?.args ?? '');
+       }
+
+       // Picker dismissed: snapshot the live token so it stays literal until
+       // deleted or retyped.
+       function handleCommandPickerClose() {
+               if (isCommandPickerOpen) {
+                       commandDismissedSnapshot = takeCommandDismissSnapshot(opts.getValue());
+               }
+               isCommandPickerOpen = false;
+               commandQuery = '';
+               // Target picker manages its own focus: don't yank it back to the input.
+               if (!isPromptPickerOpen && !isMentionPickerOpen && !isWorkingDirectoryPickerOpen) {
+                       opts.focusInput();
+               }
+       }
+
+       // Same dismissal snapshot for the mention token.
+       function handleMentionPickerClose() {
+               if (isMentionPickerOpen) {
+                       const cursor = opts.getCaretOffset() ?? opts.getValue().length;
+                       mentionDismissedSnapshot = takeMentionDismissSnapshot(opts.getValue(), cursor);
+               }
+               isMentionPickerOpen = false;
+               mentionQuery = '';
+               opts.focusInput();
+       }
+
+       function handlePromptPickerClose() {
+               isPromptPickerOpen = false;
+               promptSearchQuery = '';
+               opts.focusInput();
+       }
+
+       function handleWorkingDirectoryOpen() {
+               workingDirectoryQuery = opts.getCwd() ?? '';
+               isWorkingDirectoryPickerOpen = true;
+       }
+
+       function handleWorkingDirectoryClose() {
+               isWorkingDirectoryPickerOpen = false;
+               workingDirectoryQuery = '';
+               opts.focusInput();
+       }
+
+       // Two-way bind the text after `/cwd ` and the picker search input; the
+       // reverse direction is handled by handleInput.
+       $effect(() => {
+               if (!isWorkingDirectoryPickerOpen) return;
+               const value = opts.getValue();
+               const token = findCommandToken(value);
+               if (!token || token.name !== 'cwd') return;
+               const newValue = `/cwd ${workingDirectoryQuery}`;
+               if (newValue === value) return;
+               opts.setValue(newValue);
+               queueMicrotask(() => opts.setCaretOffset(newValue.length));
+       });
+
+       return {
+               get isCommandPickerOpen() {
+                       return isCommandPickerOpen;
+               },
+               set isCommandPickerOpen(v: boolean) {
+                       isCommandPickerOpen = v;
+               },
+               get commandQuery() {
+                       return commandQuery;
+               },
+               set commandQuery(v: string) {
+                       commandQuery = v;
+               },
+               get isPromptPickerOpen() {
+                       return isPromptPickerOpen;
+               },
+               set isPromptPickerOpen(v: boolean) {
+                       isPromptPickerOpen = v;
+               },
+               get promptSearchQuery() {
+                       return promptSearchQuery;
+               },
+               set promptSearchQuery(v: string) {
+                       promptSearchQuery = v;
+               },
+               get isMentionPickerOpen() {
+                       return isMentionPickerOpen;
+               },
+               set isMentionPickerOpen(v: boolean) {
+                       isMentionPickerOpen = v;
+               },
+               get mentionQuery() {
+                       return mentionQuery;
+               },
+               set mentionQuery(v: string) {
+                       mentionQuery = v;
+               },
+               get isWorkingDirectoryPickerOpen() {
+                       return isWorkingDirectoryPickerOpen;
+               },
+               set isWorkingDirectoryPickerOpen(v: boolean) {
+                       isWorkingDirectoryPickerOpen = v;
+               },
+               get workingDirectoryQuery() {
+                       return workingDirectoryQuery;
+               },
+               set workingDirectoryQuery(v: string) {
+                       workingDirectoryQuery = v;
+               },
+               get availableCommands() {
+                       return availableCommands;
+               },
+               get mentionScopePath() {
+                       return mentionScopePath;
+               },
+               handleInput,
+               // True when a picker consumed the event, so the form skips submit.
+               handleKeydown,
+               dispatchCommand,
+               handleCommandSelect,
+               handleCommandPickerClose,
+               handleMentionPickerClose,
+               handlePromptPickerClose,
+               handleWorkingDirectoryOpen,
+               handleWorkingDirectoryClose,
+               openPromptPicker() {
+                       isPromptPickerOpen = true;
+               },
+               closePromptPicker() {
+                       isPromptPickerOpen = false;
+                       promptSearchQuery = '';
+               }
+       };
+}
+
+export type UseChatFormPickersReturn = ReturnType<typeof useChatFormPickers>;
index 9b3be15c03baa4e7254750a2f6991d8519b4c191..44764ee5edc89068b09e41454b68307b1fab7d14 100644 (file)
@@ -8,6 +8,7 @@ import {
        singleModelName
 } from '$lib/stores/models.svelte';
 import { isRouterMode } from '$lib/stores/server.svelte';
+import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
 import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils';
 import type { ModelOption } from '$lib/types/models';
 
@@ -139,11 +140,9 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
                        handleOpenChange(false);
 
                        requestAnimationFrame(() => {
-                               const textarea = document.querySelector<HTMLTextAreaElement>(
-                                       '[data-slot="chat-form"] textarea'
-                               );
+                               const input = document.querySelector<HTMLElement>(CHAT_INPUT_FOCUS_SELECTOR);
 
-                               textarea?.focus({ preventScroll: true });
+                               input?.focus({ preventScroll: true });
                        });
                }
 
index df45f0503ab76f3cce5323db900211146a30778b..898ab87cb8408007c85c3c04c06361d4ea7d06f2 100644 (file)
@@ -135,6 +135,30 @@ class SettingsStore {
                                ...savedVal
                        };
 
+                       // Migrate the legacy render keys into `renderContentAsRawText`
+                       // (inverted semantics: the old keys opted INTO markdown). Any
+                       // explicit raw-text preference wins when the legacy keys disagree.
+                       const LEGACY_MARKDOWN_KEYS = ['renderUserContentAsMarkdown', 'renderThinkingAsMarkdown'];
+                       const LEGACY_RAW_TEXT_KEY = 'renderUserContentAsRawText'; // this branch's intermediate key
+                       const legacyKeys = [...LEGACY_MARKDOWN_KEYS, LEGACY_RAW_TEXT_KEY].filter(
+                               (key) => key in savedVal
+                       );
+                       if (legacyKeys.length > 0) {
+                               if (!(SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT in savedVal)) {
+                                       if (LEGACY_RAW_TEXT_KEY in savedVal) {
+                                               this.config[SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT] = savedVal[LEGACY_RAW_TEXT_KEY];
+                                       } else {
+                                               this.config[SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT] = LEGACY_MARKDOWN_KEYS.filter(
+                                                       (key) => key in savedVal
+                                               ).some((key) => savedVal[key] === false);
+                                       }
+                               }
+                               for (const key of legacyKeys) {
+                                       delete (this.config as Record<string, unknown>)[key];
+                               }
+                               this.saveConfig();
+                       }
+
                        // Default sendOnEnter to false on mobile when the user has no saved preference
                        if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) {
                                if (isMobile.current) {
index 2c8844c517fc00e983c4244f3248e73c256a966c..58a7e83b80170f743823af5b6440681758f7f935 100644 (file)
@@ -1,4 +1,4 @@
-import type { ErrorDialogType, FileMentionEntryType } from '$lib/enums';
+import type { ChatFormCommandAction, ErrorDialogType, FileMentionEntryType } from '$lib/enums';
 import type { ApiChatCompletionToolCall } from './api';
 import type { DatabaseMessage, DatabaseMessageExtra } from './database';
 
@@ -176,3 +176,17 @@ export interface FileMentionEntry {
        name: string;
        type: FileMentionEntryType;
 }
+
+/**
+ * A slash command surfaced by the `/` command picker. `disabled` marks a
+ * command whose backing capability is unavailable (e.g. `/prompt` when no
+ * MCP server exposes prompts): visible but greyed out and not selectable.
+ */
+export interface ChatFormCommand {
+       name: string;
+       description: string;
+       /** Extra search terms that should match this command in the picker. */
+       keywords?: string[];
+       action: ChatFormCommandAction;
+       disabled: boolean;
+}
index 6cd000a623b8a876e703bb0b537323a3ea173dd5..dbe0fa823dfb074fb2f556102277321fe94a9e7a 100644 (file)
@@ -54,7 +54,8 @@ export type {
        LiveGenerationStats,
        AttachmentDisplayItemsOptions,
        FileProcessingResult,
-       FileMentionEntry
+       FileMentionEntry,
+       ChatFormCommand
 } from './chat.d';
 
 // Database types
diff --git a/tools/ui/src/lib/utils/command-token.ts b/tools/ui/src/lib/utils/command-token.ts
new file mode 100644 (file)
index 0000000..0e7537a
--- /dev/null
@@ -0,0 +1,31 @@
+/**
+ * Slash-command token detection for the chat form. Valid only at offset 0.
+ */
+export function findCommandToken(
+       value: string
+): { name: string; args: string; end: number } | null {
+       if (!value.startsWith('/')) return null;
+
+       const rest = value.slice(1);
+       const spaceIdx = rest.search(/\s/);
+       const name = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
+       const args = spaceIdx === -1 ? '' : rest.slice(spaceIdx + 1);
+
+       return { name, args, end: value.length };
+}
+
+/**
+ * Stable signature of a slash-command token for use as a "dismissed"
+ * marker: while the picker is closed and this exact token is still intact,
+ * the picker does not re-open on in-token edits.
+ */
+export interface CommandDismissSnapshot {
+       name: string;
+       args: string;
+}
+
+export function takeCommandDismissSnapshot(value: string): CommandDismissSnapshot | null {
+       const token = findCommandToken(value);
+       if (!token) return null;
+       return { name: token.name, args: token.args };
+}
index afcf26d35755891c1b58d1be8356feb2773d7f43..41428a1c504bf9aa8558c45b5e68ee97ec38f223 100644 (file)
@@ -198,6 +198,13 @@ export {
        type MentionDismissSnapshot
 } from './mention-token';
 
+// Slash-command token detection (for the `/`-triggered command picker)
+export {
+       findCommandToken,
+       takeCommandDismissSnapshot,
+       type CommandDismissSnapshot
+} from './command-token';
+
 // Mention-chip visual contract shared by the rehype file-badge plugin,
 // plus the `[name](file://...)` link helpers the mention picker splices in
 export {
index 4d7e22388223760dbb7ca9909ce431bbaf24b4d0..a88213904bb906579d02782c757be57644662cb4 100644 (file)
@@ -19,7 +19,9 @@ export function modelLoadStageLabel(stage: ApiModelLoadStage): string {
 export function modelLoadFraction(progress: ModelLoadProgress | null): number {
        if (!progress) return 0;
 
-       const { stages, current, value } = progress;
+       // The server may emit a progress event before the stage plan is known, so
+       // `stages` can be absent. Fall back to the raw value in that case.
+       const { stages = [], current, value } = progress;
        const tailCount = Math.max(stages.length - 1, 0);
        const textCeiling = 1 - tailCount * MODEL_LOAD_TAIL_SHARE;
        const idx = stages.indexOf(current);
@@ -39,5 +41,7 @@ export function modelLoadProgressText(progress: ModelLoadProgress | null): strin
        if (!progress) return null;
 
        const label = modelLoadStageLabel(progress.current);
+       if (!label) return null;
+
        return `${label} ${Math.round(modelLoadFraction(progress) * 100)}%`;
 }
diff --git a/tools/ui/tests/client/chat-form-mention-picker-gate.svelte.test.ts b/tools/ui/tests/client/chat-form-mention-picker-gate.svelte.test.ts
new file mode 100644 (file)
index 0000000..1a5b978
--- /dev/null
@@ -0,0 +1,64 @@
+// Guards the @-mention picker's file_glob_search gate: when the server
+// does not expose the tool (started without --tools) or the user disabled
+// it, the picker still opens but explains why instead of firing searches
+// that would only fail with "Search failed".
+
+import { describe, it, expect, afterEach } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import { tick } from 'svelte';
+import ChatFormMentionPicker from '$lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte';
+import { toolsStore } from '$lib/stores/tools.svelte';
+import { BuiltInTool } from '$lib/enums';
+import { DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY } from '$lib/constants';
+import type { OpenAIToolDefinition } from '$lib/types';
+
+const FILE_SEARCH_DEF: OpenAIToolDefinition = {
+       type: 'function',
+       function: { name: BuiltInTool.FILE_GLOB_SEARCH, description: '', parameters: {} }
+};
+
+const FILE_SEARCH_KEY = `builtin:${BuiltInTool.FILE_GLOB_SEARCH}`;
+
+// The store keeps its builtin tool list private; tests inject it through
+// the reactive field so the derived gates recompute.
+function setBuiltinTools(defs: OpenAIToolDefinition[]) {
+       (toolsStore as unknown as { _builtinTools: OpenAIToolDefinition[] })._builtinTools = defs;
+}
+
+function renderPicker() {
+       return render(ChatFormMentionPicker, {
+               isOpen: true,
+               query: 'main',
+               onClose: () => {},
+               onSelect: () => {}
+       });
+}
+
+afterEach(() => {
+       setBuiltinTools([]);
+       toolsStore.setToolEnabled(FILE_SEARCH_KEY, true);
+       localStorage.removeItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY);
+});
+
+describe('ChatFormMentionPicker file_glob_search gate', () => {
+       it('explains that file search is unavailable when the server has no tools', async () => {
+               setBuiltinTools([]);
+               renderPicker();
+               await tick();
+
+               expect(document.body.textContent).toContain(
+                       'File search is unavailable on this server (started without --tools)'
+               );
+       });
+
+       it('explains that file search must be enabled when the user disabled it', async () => {
+               setBuiltinTools([FILE_SEARCH_DEF]);
+               toolsStore.setToolEnabled(FILE_SEARCH_KEY, false);
+               renderPicker();
+               await tick();
+
+               expect(document.body.textContent).toContain(
+                       'File search is disabled - enable "Search files" in Settings > Tools to use @-mentions'
+               );
+       });
+});
diff --git a/tools/ui/tests/client/chat-form-slash-commands.svelte.test.ts b/tools/ui/tests/client/chat-form-slash-commands.svelte.test.ts
new file mode 100644 (file)
index 0000000..1cd27c2
--- /dev/null
@@ -0,0 +1,122 @@
+// Guards the slash-command dispatch contract: commands dispatch only on
+// explicit selection (Enter/click in the picker), never mid-typing.
+// Typing `/model is broken` is prose until the command is picked - the
+// buffer must survive; only an actual selection consumes the token.
+
+import { describe, it, expect } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import { tick } from 'svelte';
+import ChatFormPickersHarness from './components/ChatFormPickersHarness.svelte';
+
+describe('slash command dispatch', () => {
+       it('does not dispatch or clear the buffer when a space follows the name', async () => {
+               const screen = render(ChatFormPickersHarness);
+               await tick();
+
+               screen.component.type('/model is broken');
+               await tick();
+
+               const pickers = screen.component.getPickers();
+               expect(screen.component.getValue()).toBe('/model is broken');
+               expect(screen.component.getCalls()).not.toContain('openModelSelector');
+               expect(screen.component.getCalls().some((c) => c.startsWith('setValue:'))).toBe(false);
+               expect(pickers.isCommandPickerOpen).toBe(true);
+               expect(pickers.commandQuery).toBe('model');
+       });
+
+       it('dispatches /model on explicit selection and consumes the token', async () => {
+               const screen = render(ChatFormPickersHarness);
+               await tick();
+
+               screen.component.type('/model is broken');
+               await tick();
+
+               const pickers = screen.component.getPickers();
+               const model = pickers.availableCommands.find((c) => c.name === 'model');
+               if (!model) throw new Error('model command missing');
+               pickers.handleCommandSelect(model);
+               await tick();
+
+               expect(screen.component.getValue()).toBe('');
+               expect(screen.component.getCalls()).toContain('openModelSelector');
+               expect(pickers.isCommandPickerOpen).toBe(false);
+       });
+
+       it('seeds the prompt picker search from the token args on selection', async () => {
+               const screen = render(ChatFormPickersHarness);
+               await tick();
+
+               screen.component.type('/prompt weather');
+               await tick();
+
+               const pickers = screen.component.getPickers();
+               expect(pickers.isPromptPickerOpen).toBe(false);
+
+               const prompt = pickers.availableCommands.find((c) => c.name === 'prompt');
+               if (!prompt) throw new Error('prompt command missing');
+               pickers.handleCommandSelect(prompt);
+               await tick();
+
+               expect(screen.component.getValue()).toBe('');
+               expect(pickers.isPromptPickerOpen).toBe(true);
+               expect(pickers.promptSearchQuery).toBe('weather');
+       });
+
+       it('normalizes a partial /cwd token on selection and keeps it in the buffer', async () => {
+               const screen = render(ChatFormPickersHarness);
+               await tick();
+
+               screen.component.type('/cw docs');
+               await tick();
+
+               const pickers = screen.component.getPickers();
+               expect(pickers.isWorkingDirectoryPickerOpen).toBe(false);
+
+               const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
+               if (!cwd) throw new Error('cwd command missing');
+               pickers.handleCommandSelect(cwd);
+               await tick();
+
+               expect(pickers.isWorkingDirectoryPickerOpen).toBe(true);
+               expect(pickers.workingDirectoryQuery).toBe('docs');
+               expect(screen.component.getValue()).toBe('/cwd docs');
+       });
+
+       it('syncs the /cwd token into the picker search while the picker is open', async () => {
+               const screen = render(ChatFormPickersHarness);
+               await tick();
+
+               const pickers = screen.component.getPickers();
+               const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
+               if (!cwd) throw new Error('cwd command missing');
+
+               screen.component.type('/cwd docs');
+               pickers.handleCommandSelect(cwd);
+               await tick();
+
+               screen.component.type('/cwd docs/sub');
+               await tick();
+
+               expect(pickers.isWorkingDirectoryPickerOpen).toBe(true);
+               expect(pickers.workingDirectoryQuery).toBe('docs/sub');
+               expect(pickers.isCommandPickerOpen).toBe(false);
+       });
+
+       it('abandons the /cwd picker when the token is edited away from /cwd', async () => {
+               const screen = render(ChatFormPickersHarness);
+               await tick();
+
+               const pickers = screen.component.getPickers();
+               const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
+               if (!cwd) throw new Error('cwd command missing');
+
+               screen.component.type('/cwd docs');
+               pickers.handleCommandSelect(cwd);
+               await tick();
+
+               screen.component.type('/cwdd docs');
+               await tick();
+
+               expect(pickers.isWorkingDirectoryPickerOpen).toBe(false);
+       });
+});
diff --git a/tools/ui/tests/client/components/ChatFormPickersHarness.svelte b/tools/ui/tests/client/components/ChatFormPickersHarness.svelte
new file mode 100644 (file)
index 0000000..76b2e9f
--- /dev/null
@@ -0,0 +1,51 @@
+<script lang="ts">
+       import {
+               useChatFormPickers,
+               type UseChatFormPickersReturn
+       } from '$lib/hooks/use-chat-form-pickers.svelte';
+
+       let value = $state('');
+       let caretOffset = $state(0);
+       const calls: string[] = [];
+
+       const pickers = useChatFormPickers({
+               getValue: () => value,
+               setValue: (v) => {
+                       value = v;
+                       calls.push(`setValue:${v}`);
+               },
+               getCaretOffset: () => caretOffset,
+               setCaretOffset: (o) => {
+                       caretOffset = o;
+               },
+               focusInput: () => {},
+               getShowModelSelector: () => true,
+               hasPrompts: () => true,
+               hasBuiltinTools: () => true,
+               getCwd: () => null,
+               getServerHome: () => null,
+               openModelSelector: () => {
+                       calls.push('openModelSelector');
+               },
+               getPickersRef: () => undefined
+       });
+
+       // Simulate the user typing: update the buffer and run the input flow.
+       export function type(text: string) {
+               value = text;
+               caretOffset = text.length;
+               pickers.handleInput();
+       }
+
+       export function getValue() {
+               return value;
+       }
+
+       export function getCalls() {
+               return calls;
+       }
+
+       export function getPickers(): UseChatFormPickersReturn {
+               return pickers;
+       }
+</script>
diff --git a/tools/ui/tests/client/settings-raw-text-migration.svelte.test.ts b/tools/ui/tests/client/settings-raw-text-migration.svelte.test.ts
new file mode 100644 (file)
index 0000000..601ebc2
--- /dev/null
@@ -0,0 +1,65 @@
+// Guards the legacy render-key migration: `renderUserContentAsMarkdown`
+// and `renderThinkingAsMarkdown` (opt-INTO markdown) fold into the single
+// `renderContentAsRawText` setting, with any explicit raw-text preference
+// winning when the legacy keys disagree. Legacy keys are removed from the
+// persisted config so they do not stay orphaned in localStorage.
+
+import { beforeEach, describe, expect, it } from 'vitest';
+import { settingsStore, config } from '$lib/stores/settings.svelte';
+import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants/storage';
+
+function seedConfig(stored: Record<string, unknown>) {
+       localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(stored));
+       settingsStore.initialize();
+}
+
+function persisted(): Record<string, unknown> {
+       return JSON.parse(localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}');
+}
+
+describe('renderContentAsRawText migration', () => {
+       beforeEach(() => {
+               localStorage.removeItem(CONFIG_LOCALSTORAGE_KEY);
+               settingsStore.initialize();
+       });
+
+       it('maps renderUserContentAsMarkdown=false to raw text', () => {
+               seedConfig({ renderUserContentAsMarkdown: false });
+               expect(config().renderContentAsRawText).toBe(true);
+       });
+
+       it('maps renderUserContentAsMarkdown=true to markdown', () => {
+               seedConfig({ renderUserContentAsMarkdown: true });
+               expect(config().renderContentAsRawText).toBe(false);
+       });
+
+       it('maps renderThinkingAsMarkdown=false to raw text', () => {
+               seedConfig({ renderThinkingAsMarkdown: false });
+               expect(config().renderContentAsRawText).toBe(true);
+       });
+
+       it('lets any explicit raw-text preference win when the legacy keys disagree', () => {
+               seedConfig({ renderUserContentAsMarkdown: true, renderThinkingAsMarkdown: false });
+               expect(config().renderContentAsRawText).toBe(true);
+       });
+
+       it('honors the intermediate renderUserContentAsRawText key from the PR branch', () => {
+               seedConfig({ renderUserContentAsRawText: true });
+               expect(config().renderContentAsRawText).toBe(true);
+       });
+
+       it('keeps an already-migrated value and cleans up the legacy keys', () => {
+               seedConfig({ renderContentAsRawText: false, renderUserContentAsMarkdown: false });
+               expect(config().renderContentAsRawText).toBe(false);
+
+               const stored = persisted();
+               expect(stored.renderUserContentAsMarkdown).toBeUndefined();
+               expect(stored.renderThinkingAsMarkdown).toBeUndefined();
+               expect(stored.renderUserContentAsRawText).toBeUndefined();
+       });
+
+       it('defaults to markdown when no legacy key exists', () => {
+               seedConfig({});
+               expect(config().renderContentAsRawText).toBe(false);
+       });
+});
diff --git a/tools/ui/tests/unit/command-token.test.ts b/tools/ui/tests/unit/command-token.test.ts
new file mode 100644 (file)
index 0000000..f52097d
--- /dev/null
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest';
+import { findCommandToken, takeCommandDismissSnapshot } from '$lib/utils';
+
+describe('findCommandToken', () => {
+       it('returns null when the value does not start with a slash', () => {
+               expect(findCommandToken('hello /prompt')).toBeNull();
+               expect(findCommandToken('')).toBeNull();
+               expect(findCommandToken('prompt')).toBeNull();
+       });
+
+       it('parses a bare slash', () => {
+               expect(findCommandToken('/')).toEqual({ name: '', args: '', end: 1 });
+       });
+
+       it('parses a command name with no args', () => {
+               expect(findCommandToken('/prompt')).toEqual({ name: 'prompt', args: '', end: 7 });
+       });
+
+       it('parses a command name followed by a space', () => {
+               expect(findCommandToken('/prompt ')).toEqual({ name: 'prompt', args: '', end: 8 });
+       });
+
+       it('parses args after the command name', () => {
+               expect(findCommandToken('/prompt rev')).toEqual({ name: 'prompt', args: 'rev', end: 11 });
+       });
+
+       it('parses multi-word args', () => {
+               expect(findCommandToken('/prompt  review  code ')).toEqual({
+                       name: 'prompt',
+                       args: ' review  code ',
+                       end: 22
+               });
+       });
+
+       it('treats the whole run as the name when there is no space', () => {
+               expect(findCommandToken('/promptx')).toEqual({ name: 'promptx', args: '', end: 8 });
+       });
+});
+
+describe('takeCommandDismissSnapshot', () => {
+       it('returns null when there is no command token', () => {
+               expect(takeCommandDismissSnapshot('hello')).toBeNull();
+       });
+
+       it('captures the name and args', () => {
+               expect(takeCommandDismissSnapshot('/prompt rev')).toEqual({
+                       name: 'prompt',
+                       args: 'rev'
+               });
+       });
+});