]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
ui: Filesystem `@mentions` for Chat Form (#26715)
authorAleksander Grygier <redacted>
Fri, 7 Aug 2026 16:45:54 +0000 (18:45 +0200)
committerGitHub <redacted>
Fri, 7 Aug 2026 16:45:54 +0000 (18:45 +0200)
* base : @-mention picker foundation - glob search, picker nav, highlight

* feat : @-mention file/folder picker and mention badges in message bubbles

* fix: Imports

* feat : wire the @-mention picker into the chat form

* fix: Bound the glob-search result cache key and prune stale entries

40 files changed:
tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte [deleted file]
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/index.ts
tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte
tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/file-badge.ts [new file with mode: 0644]
tools/ui/src/lib/components/app/forms/HighlightedMatch.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/forms/index.ts
tools/ui/src/lib/constants/chat-form.ts
tools/ui/src/lib/constants/index.ts
tools/ui/src/lib/constants/mention-badge.ts [new file with mode: 0644]
tools/ui/src/lib/constants/settings-keys.ts
tools/ui/src/lib/constants/settings-registry.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/enums/keyboard.enums.ts
tools/ui/src/lib/hooks/use-debounced-search.svelte.ts [new file with mode: 0644]
tools/ui/src/lib/hooks/use-picker-navigation.svelte.ts [new file with mode: 0644]
tools/ui/src/lib/hooks/use-scroll-active-row.svelte.ts [new file with mode: 0644]
tools/ui/src/lib/types/chat.d.ts
tools/ui/src/lib/types/index.ts
tools/ui/src/lib/types/settings.d.ts
tools/ui/src/lib/utils/glob-search.ts [new file with mode: 0644]
tools/ui/src/lib/utils/index.ts
tools/ui/src/lib/utils/mention-badge.ts [new file with mode: 0644]
tools/ui/src/lib/utils/mention-token.ts [new file with mode: 0644]
tools/ui/src/lib/utils/path-display.ts
tools/ui/src/lib/utils/working-directory.ts
tools/ui/tests/client/components/PickerListScrollHarness.svelte [new file with mode: 0644]
tools/ui/tests/client/picker-list-scroll.svelte.test.ts [new file with mode: 0644]
tools/ui/tests/unit/glob-search-children.test.ts [new file with mode: 0644]
tools/ui/tests/unit/mention-badge.test.ts [new file with mode: 0644]
tools/ui/tests/unit/mention-token.test.ts [new file with mode: 0644]
tools/ui/tests/unit/working-directory.test.ts

index 105e414fe78f424429a5adf94aca3211eed2e606..22406e0722573cf7885fd9c04ebd55252fa038c1 100644 (file)
@@ -15,8 +15,7 @@
                SETTING_CONFIG_DEFAULT,
                INITIAL_FILE_SIZE,
                PROMPT_CONTENT_SEPARATOR,
-               PROMPT_TRIGGER_PREFIX,
-               RESOURCE_TRIGGER_PREFIX
+               PROMPT_TRIGGER_PREFIX
        } from '$lib/constants';
        import {
                ContentPartType,
                activeConversation,
                pendingCwd
        } from '$lib/stores/conversations.svelte';
-       import type { GetPromptResult, MCPPromptInfo, MCPResourceInfo, PromptMessage } from '$lib/types';
-       import { isIMEComposing, parseClipboardContent, uuid } from '$lib/utils';
+       import type {
+               FileMentionEntry,
+               GetPromptResult,
+               MCPPromptInfo,
+               MCPResourceInfo,
+               PromptMessage
+       } from '$lib/types';
+       import {
+               buildMentionInsertion,
+               findMentionToken,
+               isIMEComposing,
+               mentionLinkEndingAt,
+               parseClipboardContent,
+               takeMentionDismissSnapshot,
+               type MentionDismissSnapshot,
+               uuid
+       } from '$lib/utils';
        import {
                AudioRecorder,
                convertToWav,
        let isRecording = $state(false);
        let recordingSupported = $state(false);
 
+       // Invisible anchor at the form's top edge so the mention popover floats above the box.
+       let mentionAnchor: HTMLDivElement | null = $state(null);
+
        // Picker State
        let isPromptPickerOpen = $state(false);
        let promptSearchQuery = $state('');
-       let isInlineResourcePickerOpen = $state(false);
-       let resourceSearchQuery = $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());
 
        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);
-                       isInlineResourcePickerOpen = false;
-                       resourceSearchQuery = '';
-               } else if (
-                       value.startsWith(RESOURCE_TRIGGER_PREFIX) &&
-                       hasServers &&
-                       mcpStore.hasResourcesCapability(perChatOverrides)
-               ) {
-                       isInlineResourcePickerOpen = true;
-                       resourceSearchQuery = value.slice(1);
-                       isPromptPickerOpen = false;
-                       promptSearchQuery = '';
                } else {
                        isPromptPickerOpen = false;
                        promptSearchQuery = '';
-                       isInlineResourcePickerOpen = false;
-                       resourceSearchQuery = '';
                }
        }
 
                        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();
+                       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));
+                                       return;
+                               }
+                       }
+               }
+
                if (event.key === KeyboardKey.ESCAPE && isPromptPickerOpen) {
                        isPromptPickerOpen = false;
                        promptSearchQuery = '';
                        return;
                }
 
-               if (event.key === KeyboardKey.ESCAPE && isInlineResourcePickerOpen) {
-                       isInlineResourcePickerOpen = false;
-                       resourceSearchQuery = '';
+               if (event.key === KeyboardKey.ESCAPE && isMentionPickerOpen) {
+                       isMentionPickerOpen = false;
+                       mentionQuery = '';
                        return;
                }
 
                textareaRef?.focus();
        }
 
-       function handleInlineResourcePickerClose() {
-               isInlineResourcePickerOpen = false;
-               resourceSearchQuery = '';
-               textareaRef?.focus();
-       }
-
-       function handleInlineResourceSelect() {
-               if (value.startsWith(RESOURCE_TRIGGER_PREFIX)) {
-                       value = '';
-                       onValueChange?.('');
+       function handleMentionPickerClose() {
+               if (isMentionPickerOpen) {
+                       const cursor = textareaRef?.getCaretOffset() ?? value.length;
+                       mentionDismissedSnapshot = takeMentionDismissSnapshot(value, cursor);
                }
-
-               isInlineResourcePickerOpen = false;
-               resourceSearchQuery = '';
-               textareaRef?.focus();
+               isMentionPickerOpen = false;
+               mentionQuery = '';
+               refocusInput();
        }
 
-       function handleBrowseResources() {
-               isInlineResourcePickerOpen = false;
-               resourceSearchQuery = '';
+       // Splice the `[name](file:///<abs path>)` link in place of the `@<query>`
+       // token, restoring the caret after the bindable value settles.
+       function handleMentionSelect(entry: FileMentionEntry) {
+               const cursor = textareaRef?.getCaretOffset() ?? value.length;
+               const token = findMentionToken(value, cursor);
+               if (!token) return;
 
-               if (value.startsWith(RESOURCE_TRIGGER_PREFIX)) {
-                       value = '';
-                       onValueChange?.('');
-               }
+               const built = buildMentionInsertion(entry, value, token);
+               if (!built) return;
+
+               value = built.newValue;
+               onValueChange?.(built.newValue);
 
-               isResourceDialogOpen = true;
+               queueMicrotask(() => {
+                       textareaRef?.focus();
+                       textareaRef?.setCaretOffset(built.caretOffset);
+               });
        }
 
        async function handleMicClick() {
                bind:this={pickersRef}
                {isPromptPickerOpen}
                {promptSearchQuery}
-               {isInlineResourcePickerOpen}
-               {resourceSearchQuery}
+               {isMentionPickerOpen}
+               {mentionQuery}
+               {mentionAnchor}
+               scopePath={cwd}
                onPromptPickerClose={handlePromptPickerClose}
-               onInlineResourcePickerClose={handleInlineResourcePickerClose}
-               onInlineResourceSelect={handleInlineResourceSelect}
+               onMentionPickerClose={handleMentionPickerClose}
+               onMentionOpened={() => textareaRef?.focus()}
+               onMentionSelect={handleMentionSelect}
                onPromptLoadStart={handlePromptLoadStart}
                onPromptLoadComplete={handlePromptLoadComplete}
                onPromptLoadError={handlePromptLoadError}
-               onInlineResourceBrowse={handleBrowseResources}
        />
 
+       <div
+               bind:this={mentionAnchor}
+               class="pointer-events-none absolute top-0 right-0 left-0 h-px"
+               aria-hidden="true"
+       ></div>
+
        <div
                class="{INPUT_CLASSES} overflow-hidden rounded-4xl md:rounded-3xl backdrop-blur-md {disabled
                        ? 'cursor-not-allowed opacity-60'
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte
new file mode 100644 (file)
index 0000000..1debbe8
--- /dev/null
@@ -0,0 +1,258 @@
+<script lang="ts">
+       import { File, Folder } from '@lucide/svelte';
+       import { abbreviateHome, runGlobSearchWithChildren, type GlobEntryResult } from '$lib/utils';
+       import { toolsStore } from '$lib/stores/tools.svelte';
+       import { BuiltInTool, FileMentionEntryType, GlobSearchType } from '$lib/enums';
+       import { isMobile } from '$lib/stores/viewport.svelte';
+       import { config } from '$lib/stores/settings.svelte';
+       import * as Popover from '$lib/components/ui/popover';
+       import * as Tooltip from '$lib/components/ui/tooltip';
+       import HighlightedMatch from '$lib/components/app/forms/HighlightedMatch.svelte';
+       import { ChatFormPickerList, ChatFormPickerListItem } from '$lib/components/app/chat';
+       import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
+       import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
+       import type { FileMentionEntry } from '$lib/types';
+       import {
+               FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH,
+               HOME_TILDE,
+               SEARCH_DEBOUNCE_MS
+       } from '$lib/constants';
+
+       /**
+        * Floating file/folder mention picker. The chat input is the search
+        * surface: `query` (typed after `@`) drives a `file_glob_search` tool
+        * call scoped to `scopePath`. The parent owns the "dismissed token,
+        * don't re-open until it changes" snapshot.
+        */
+       interface Props {
+               class?: string;
+               isOpen: boolean;
+               query: string;
+               customAnchor?: HTMLElement | null;
+               scopePath?: string | null;
+               onClose: () => void;
+               onSelect: (entry: FileMentionEntry) => void;
+               /** Fired when `isOpen` becomes true, so the host can keep focus on the chat input. */
+               onOpened?: () => void;
+       }
+
+       let {
+               class: className = '',
+               isOpen,
+               query,
+               customAnchor = null,
+               scopePath = null,
+               onClose,
+               onSelect,
+               onOpened
+       }: Props = $props();
+
+       const nav = usePickerNavigation({
+               isOpen: () => isOpen,
+               count: () => displayedItems.length,
+               onClose: () => onClose(),
+               onSelect: (index) => handleSelect(displayedItems[index])
+       });
+
+       // When the server does not expose file_glob_search (started without
+       // --tools) or the user disabled it, the picker still opens but explains
+       // why instead of firing searches that would only fail.
+       const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.FILE_GLOB_SEARCH));
+       const fileSearchEnabled = $derived(
+               fileSearchKey !== null && toolsStore.isToolEnabled(fileSearchKey)
+       );
+
+       let searchResults = $state<FileMentionEntry[]>([]);
+       let searchError = $state<string | null>(null);
+
+       // Coerce the depth setting to a positive integer; an invalid value
+       // would otherwise reach the server as max_depth 0 = unlimited.
+       const searchDepth = $derived.by(() => {
+               const n = Number(config().mentionSearchMaxDepth);
+               return Number.isInteger(n) && n > 0 ? n : FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH;
+       });
+
+       const home = $derived(toolsStore.serverHome);
+
+       // A smaller window than the WD picker suffices: entries are ranked client-side.
+       const MENTION_SEARCH_LIMIT = 50;
+
+       const search = useDebouncedSearch({
+               debounceMs: SEARCH_DEBOUNCE_MS,
+               canRun: () => isOpen && fileSearchEnabled,
+               getQuery: () => trimmedQuery,
+               run: async (query, signal, isCurrent) => {
+                       try {
+                               // A trailing path separator targets a directory, so also list its
+                               // children. Accept both `/` and `\`.
+                               const res = await runGlobSearchWithChildren(
+                                       query,
+                                       scopePath ?? home ?? HOME_TILDE,
+                                       searchDepth,
+                                       MENTION_SEARCH_LIMIT,
+                                       signal,
+                                       { type: GlobSearchType.ALL, descendOnTrailingSeparator: true }
+                               );
+                               if (!isCurrent()) return;
+                               if (res.error) {
+                                       searchResults = [];
+                                       searchError = res.error;
+                                       return;
+                               }
+                               const toEntry = (e: GlobEntryResult): FileMentionEntry => ({
+                                       path: e.path,
+                                       name: e.name,
+                                       type: e.type === 'dir' ? FileMentionEntryType.DIRECTORY : FileMentionEntryType.FILE
+                               });
+                               searchResults = res.entries.map(toEntry);
+                               searchError = null;
+                       } catch (err) {
+                               if (!isCurrent() || signal.aborted) return;
+                               searchResults = [];
+                               searchError = err instanceof Error ? err.message : String(err);
+                       }
+               }
+       });
+
+       const trimmedQuery = $derived((query ?? '').trim());
+       const displayedItems = $derived(searchResults);
+
+       const emptyMessage = $derived.by(() => {
+               if (fileSearchKey === null) {
+                       return 'File search is unavailable on this server (started without --tools)';
+               }
+               if (!fileSearchEnabled) {
+                       return 'File search is disabled - enable "Search files" in Settings > Tools to use @-mentions';
+               }
+               return searchError ? `Search failed - ${searchError}` : 'No matching files or folders';
+       });
+
+       const showTooltip = $derived(!isMobile.current);
+
+       $effect(() => {
+               if (typeof window === 'undefined') return;
+               void toolsStore.resolveServerHome();
+       });
+
+       $effect(() => {
+               if (isOpen) {
+                       nav.reset(0);
+               }
+       });
+
+       $effect(() => {
+               if (isOpen) onOpened?.();
+       });
+
+       $effect(() => {
+               const q = (query ?? '').trim();
+               if (!isOpen || !q || !fileSearchEnabled) {
+                       search.cancel();
+                       searchResults = [];
+                       searchError = null;
+                       return;
+               }
+               search.setLoading(true);
+               search.run(q);
+       });
+
+       function handleSelect(entry: FileMentionEntry) {
+               onSelect(entry);
+               onClose();
+       }
+
+       export function handleKeydown(event: KeyboardEvent): boolean {
+               return nav.handleKeydown(event);
+       }
+</script>
+
+<Popover.Root
+       open={isOpen}
+       onOpenChange={(open) => {
+               if (!open) onClose();
+       }}
+>
+       <!-- Invisible form-wide trigger: stops bits-ui's outside-click detector
+            from closing the picker when the user clicks inside the textarea.
+            We open programmatically via `open={isOpen}`, so it is inert
+            (tabindex=-1 + pointer-events-none + opacity-0 + aria-hidden).
+            Positioning comes from `customAnchor` at the form's top edge. -->
+       <Popover.Trigger
+               class="pointer-events-none absolute inset-0 opacity-0"
+               tabindex={-1}
+               aria-hidden="true"
+       >
+               <span class="sr-only">Open file mention picker</span>
+       </Popover.Trigger>
+
+       <Popover.Content
+               align="start"
+               side="top"
+               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',
+                       className
+               ]}
+       >
+               <ChatFormPickerList
+                       items={displayedItems}
+                       isLoading={search.isSearching}
+                       selectedIndex={nav.hoveredIndex}
+                       showSearchInput={false}
+                       searchQuery={query ?? ''}
+                       {emptyMessage}
+                       itemKey={(entry) => entry.type + ':' + entry.path}
+                       scrollTrigger={nav.scrollTrigger}
+               >
+                       {#snippet item(entry, index, isSelected)}
+                               <ChatFormPickerListItem
+                                       dataIndex={index}
+                                       {isSelected}
+                                       onclick={() => handleSelect(entry)}
+                                       onmouseenter={() => nav.setHover(index)}
+                               >
+                                       {@const Icon = entry.type === FileMentionEntryType.DIRECTORY ? Folder : File}
+                                       <Icon
+                                               class={[
+                                                       'mt-0.5 h-4 w-4 shrink-0',
+                                                       entry.type === FileMentionEntryType.DIRECTORY
+                                                               ? 'text-amber-500'
+                                                               : 'text-muted-foreground'
+                                               ]}
+                                       />
+                                       <div class="flex min-w-0 flex-1 flex-col">
+                                               <div class="flex min-w-0 items-center gap-2">
+                                                       {#if showTooltip}
+                                                               <Tooltip.Root>
+                                                                       <Tooltip.Trigger>
+                                                                               {#snippet child({ props })}
+                                                                                       <span {...props} class="truncate text-sm font-medium">{entry.name}</span>
+                                                                               {/snippet}
+                                                                       </Tooltip.Trigger>
+                                                                       <Tooltip.Content>
+                                                                               <p>{entry.path}</p>
+                                                                       </Tooltip.Content>
+                                                               </Tooltip.Root>
+                                                       {:else}
+                                                               <span class="truncate text-sm font-medium">{entry.name}</span>
+                                                       {/if}
+                                                       <span
+                                                               class="shrink-0 rounded-full bg-muted px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-muted-foreground"
+                                                       >
+                                                               {entry.type}
+                                                       </span>
+                                               </div>
+                                               <span class="min-w-0 flex-1 truncate font-mono text-left text-xs">
+                                                       <HighlightedMatch text={abbreviateHome(entry.path, home)} query={trimmedQuery} />
+                                               </span>
+                                       </div>
+                               </ChatFormPickerListItem>
+                       {/snippet}
+               </ChatFormPickerList>
+       </Popover.Content>
+</Popover.Root>
index 6647928b2bf51f576ff1392f7739a1df3e7b966a..04b85b24b235c993b61c3755ee0650b136ad5420 100644 (file)
@@ -2,6 +2,7 @@
        import type { Snippet } from 'svelte';
        import { SearchInput } from '$lib/components/app';
        import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
+       import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
        import { CHAT_FORM_POPOVER_MAX_HEIGHT } from '$lib/constants';
 
        interface Props {
                searchQuery: string;
                showSearchInput: boolean;
                searchPlaceholder?: string;
+               // Omit to distinguish "haven't searched yet" from "search returned nothing".
                emptyMessage?: string;
+               autofocus?: boolean;
+               inputRef?: HTMLInputElement | null;
+               onSearchClose?: () => void;
                itemKey: (item: T, index: number) => string;
                item: Snippet<[T, number, boolean]>;
                skeleton?: Snippet;
+               skeletonCount?: number;
                footer?: Snippet;
+               // Counter bumped by the picker on keyboard nav; scrolls the selected
+               // row into view without scrolling on hover or result replacement.
+               scrollTrigger?: number;
        }
 
        let {
                searchQuery = $bindable(),
                showSearchInput,
                searchPlaceholder = 'Search...',
-               emptyMessage = 'No items available',
+               emptyMessage,
+               autofocus = false,
+               inputRef = $bindable(null),
+               onSearchClose,
                itemKey,
                item,
                skeleton,
-               footer
+               skeletonCount = 6,
+               footer,
+               scrollTrigger
        }: Props = $props();
 
        let listContainer = $state<HTMLDivElement | null>(null);
 
-       $effect(() => {
-               if (listContainer && selectedIndex >= 0 && selectedIndex < items.length) {
-                       const selectedElement = listContainer.querySelector(
-                               `[data-picker-index="${selectedIndex}"]`
-                       ) as HTMLElement;
+       let listPaddingTop = $derived(
+               showSearchInput ? (isLoading || items.length > 0 ? 'pt-13' : 'pt-10') : ''
+       );
 
-                       if (selectedElement) {
-                               selectedElement.scrollIntoView({
-                                       behavior: 'smooth',
-                                       block: 'center',
-                                       inline: 'nearest'
-                               });
-                       }
-               }
+       // selectedIndex/items.length are untracked so hover and result replacement
+       // never re-fire the scroll; keyboard nav is the only path that bumps the trigger.
+       useScrollActiveRow({
+               getTrigger: () => scrollTrigger,
+               getContainer: () => listContainer,
+               getIndex: () => selectedIndex,
+               getCount: () => items.length,
+               dataIndex: 'picker'
        });
 </script>
 
 <ScrollArea>
        {#if showSearchInput}
                <div class="absolute top-0 right-0 left-0 z-10 p-2 pb-0">
-                       <SearchInput placeholder={searchPlaceholder} bind:value={searchQuery} />
+                       <SearchInput
+                               {autofocus}
+                               placeholder={searchPlaceholder}
+                               bind:value={searchQuery}
+                               bind:ref={inputRef}
+                               onClose={onSearchClose}
+                       />
                </div>
        {/if}
 
-       <div
-               bind:this={listContainer}
-               class={[`${CHAT_FORM_POPOVER_MAX_HEIGHT} p-2`, showSearchInput && 'pt-13']}
-       >
+       <div bind:this={listContainer} class={[`${CHAT_FORM_POPOVER_MAX_HEIGHT} p-2`, listPaddingTop]}>
                {#if isLoading}
                        {#if skeleton}
                                {@render skeleton()}
+                       {:else}
+                               <div aria-busy="true" aria-live="polite" class="flex flex-col">
+                                       {#each { length: skeletonCount } as _, rowIndex (rowIndex)}
+                                               <div class="flex items-start gap-3 rounded-lg px-3 py-2">
+                                                       <div class="mt-0.5 size-4 shrink-0 animate-pulse rounded-md bg-muted/60"></div>
+                                                       <div class="flex min-w-0 flex-1 flex-col">
+                                                               <div class="h-5 w-2/5 animate-pulse rounded-sm bg-muted/60"></div>
+                                                               <div class="h-4 w-1/3 animate-pulse rounded-sm bg-muted/40"></div>
+                                                       </div>
+                                               </div>
+                                       {/each}
+                               </div>
+                       {/if}
+               {:else if items && items.length === 0}
+                       {#if emptyMessage}
+                               <div class="py-6 text-center text-sm text-muted-foreground">{emptyMessage}</div>
                        {/if}
-               {:else if items.length === 0}
-                       <div class="py-6 text-center text-sm text-muted-foreground">{emptyMessage}</div>
                {:else}
                        {#each items as itemData, index (itemKey(itemData, index))}
                                {@render item(itemData, index, index === selectedIndex)}
index 4d82c6b5849ce82755c05392e34cc8befd4d4beb..0fb0ea0c0082c6b141b2a95b4b428be8d3caac74 100644 (file)
@@ -3,21 +3,34 @@
 
        interface Props {
                isSelected?: boolean;
+               disabled?: boolean;
                onclick: () => void;
+               onmouseenter?: () => void;
                dataIndex?: number;
                children: Snippet;
+               class?: string;
        }
 
-       let { isSelected = false, onclick, dataIndex, children }: Props = $props();
+       let {
+               class: className = '',
+               isSelected = false,
+               disabled = false,
+               onclick,
+               onmouseenter,
+               dataIndex,
+               children
+       }: Props = $props();
 </script>
 
 <button
        type="button"
        data-picker-index={dataIndex}
+       {disabled}
        {onclick}
+       {onmouseenter}
        class="flex w-full cursor-pointer items-start gap-3 rounded-lg px-3 py-2 text-left hover:bg-accent/50 {isSelected
                ? 'bg-accent/50'
-               : ''}"
+               : ''} {disabled ? 'cursor-not-allowed opacity-50' : ''} {className}"
 >
        {@render children()}
 </button>
index c43a002e695375132b7adbcc86558e22077e18df..24ea8619f5754251756361e5f71f7b986685b7cf 100644 (file)
@@ -42,6 +42,7 @@
                align="start"
                sideOffset={12}
                class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl {className}"
+               preventScroll={false}
                onkeydown={onKeydown}
                onOpenAutoFocus={(event) => event.preventDefault()}
        >
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte
deleted file mode 100644 (file)
index ed97e1f..0000000
+++ /dev/null
@@ -1,237 +0,0 @@
-<script lang="ts">
-       import { conversationsStore } from '$lib/stores/conversations.svelte';
-       import { mcpStore } from '$lib/stores/mcp.svelte';
-       import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
-       import { KeyboardKey } from '$lib/enums';
-       import type { MCPResourceInfo, MCPServerSettingsEntry } from '$lib/types';
-       import { SvelteMap } from 'svelte/reactivity';
-       import { FolderOpen } from '@lucide/svelte';
-       import { Button } from '$lib/components/ui/button';
-       import {
-               ChatFormPickerPopover,
-               ChatFormPickerList,
-               ChatFormPickerListItem,
-               ChatFormPickerItemHeader,
-               ChatFormPickerListItemSkeleton
-       } from '$lib/components/app/chat';
-
-       interface Props {
-               class?: string;
-               isOpen?: boolean;
-               searchQuery?: string;
-               onClose?: () => void;
-               onResourceSelect?: (resource: MCPResourceInfo) => void;
-               onBrowse?: () => void;
-       }
-
-       let {
-               class: className = '',
-               isOpen = false,
-               searchQuery = '',
-               onClose,
-               onResourceSelect,
-               onBrowse
-       }: Props = $props();
-
-       let resources = $state<MCPResourceInfo[]>([]);
-       let isLoading = $state(false);
-       let selectedIndex = $state(0);
-       let internalSearchQuery = $state('');
-
-       let serverSettingsMap = $derived.by(() => {
-               const servers = mcpStore.getServers();
-               const map = new SvelteMap<string, MCPServerSettingsEntry>();
-
-               for (const server of servers) {
-                       map.set(server.id, server);
-               }
-
-               return map;
-       });
-
-       $effect(() => {
-               if (isOpen) {
-                       loadResources();
-                       selectedIndex = 0;
-               }
-       });
-
-       $effect(() => {
-               if (filteredResources.length > 0 && selectedIndex >= filteredResources.length) {
-                       selectedIndex = 0;
-               }
-       });
-
-       async function loadResources() {
-               isLoading = true;
-
-               try {
-                       const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
-                       const initialized = await mcpStore.ensureInitialized(perChatOverrides);
-
-                       if (!initialized) {
-                               resources = [];
-
-                               return;
-                       }
-
-                       await mcpStore.fetchAllResources();
-                       resources = mcpResourceStore.getAllResourceInfos();
-               } catch (error) {
-                       console.error('[ChatFormPickerMcpResources] Failed to load resources:', error);
-                       resources = [];
-               } finally {
-                       isLoading = false;
-               }
-       }
-
-       function handleResourceClick(resource: MCPResourceInfo) {
-               mcpStore.attachResource(resource.uri);
-
-               onResourceSelect?.(resource);
-               onClose?.();
-       }
-
-       function isResourceAttached(uri: string): boolean {
-               return mcpResourceStore.isAttached(uri);
-       }
-
-       export function handleKeydown(event: KeyboardEvent): boolean {
-               if (!isOpen) return false;
-
-               if (event.key === KeyboardKey.ESCAPE) {
-                       event.preventDefault();
-                       onClose?.();
-
-                       return true;
-               }
-
-               if (event.key === KeyboardKey.ARROW_DOWN) {
-                       event.preventDefault();
-
-                       if (filteredResources.length > 0) {
-                               selectedIndex = (selectedIndex + 1) % filteredResources.length;
-                       }
-
-                       return true;
-               }
-
-               if (event.key === KeyboardKey.ARROW_UP) {
-                       event.preventDefault();
-                       if (filteredResources.length > 0) {
-                               selectedIndex = selectedIndex === 0 ? filteredResources.length - 1 : selectedIndex - 1;
-                       }
-
-                       return true;
-               }
-
-               if (event.key === KeyboardKey.ENTER) {
-                       event.preventDefault();
-                       if (filteredResources[selectedIndex]) {
-                               handleResourceClick(filteredResources[selectedIndex]);
-                       }
-
-                       return true;
-               }
-
-               return false;
-       }
-
-       let filteredResources = $derived.by(() => {
-               const sortedServers = mcpStore.getServers();
-               const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
-
-               const sortedResources = [...resources].sort((a, b) => {
-                       const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER;
-                       const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER;
-
-                       return orderA - orderB;
-               });
-
-               const query = (searchQuery || internalSearchQuery).toLowerCase();
-               if (!query) return sortedResources;
-
-               return sortedResources.filter(
-                       (resource) =>
-                               resource.name.toLowerCase().includes(query) ||
-                               resource.title?.toLowerCase().includes(query) ||
-                               resource.description?.toLowerCase().includes(query) ||
-                               resource.uri.toLowerCase().includes(query)
-               );
-       });
-
-       let showSearchInput = $derived(resources.length > 3);
-</script>
-
-<ChatFormPickerPopover
-       bind:isOpen
-       class={className}
-       srLabel="Open resource picker"
-       {onClose}
-       onKeydown={handleKeydown}
->
-       <ChatFormPickerList
-               items={filteredResources}
-               {isLoading}
-               {selectedIndex}
-               bind:searchQuery={internalSearchQuery}
-               {showSearchInput}
-               searchPlaceholder="Search resources..."
-               emptyMessage="No MCP resources available"
-               itemKey={(resource) => resource.serverName + ':' + resource.uri}
-       >
-               {#snippet item(resource, index, isSelected)}
-                       {@const server = serverSettingsMap.get(resource.serverName)}
-                       {@const serverLabel = server ? mcpStore.getServerLabel(server) : resource.serverName}
-
-                       <ChatFormPickerListItem
-                               dataIndex={index}
-                               {isSelected}
-                               onclick={() => handleResourceClick(resource)}
-                       >
-                               <ChatFormPickerItemHeader
-                                       {server}
-                                       {serverLabel}
-                                       title={resource.title || resource.name}
-                                       description={resource.description}
-                               >
-                                       {#snippet titleExtra()}
-                                               {#if isResourceAttached(resource.uri)}
-                                                       <span
-                                                               class="inline-flex items-center rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary"
-                                                       >
-                                                               attached
-                                                       </span>
-                                               {/if}
-                                       {/snippet}
-
-                                       {#snippet subtitle()}
-                                               <p class="mt-0.5 truncate text-xs text-muted-foreground/60">
-                                                       {resource.uri}
-                                               </p>
-                                       {/snippet}
-                               </ChatFormPickerItemHeader>
-                       </ChatFormPickerListItem>
-               {/snippet}
-
-               {#snippet skeleton()}
-                       <ChatFormPickerListItemSkeleton />
-               {/snippet}
-
-               {#snippet footer()}
-                       {#if onBrowse && resources.length > 3}
-                               <Button
-                                       class="fixed right-3 bottom-3"
-                                       type="button"
-                                       onclick={onBrowse}
-                                       variant="secondary"
-                                       size="sm"
-                               >
-                                       <FolderOpen class="h-3 w-3" />
-
-                                       Browse all
-                               </Button>
-                       {/if}
-               {/snippet}
-       </ChatFormPickerList>
-</ChatFormPickerPopover>
index 7c5dc85b2a095813432e5cd1a87f5477e83c9b9d..3fc29f1771631d5cfaf988aedbf9d804f6e6b004 100644 (file)
@@ -1,16 +1,19 @@
 <script lang="ts">
+       import ChatFormMentionPicker from './ChatFormMentionPicker.svelte';
        import ChatFormPickerMcpPrompts from './ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte';
-       import ChatFormPickerMcpResources from './ChatFormPickerMcpResources.svelte';
-       import type { GetPromptResult, MCPPromptInfo } from '$lib/types';
+       import type { FileMentionEntry, GetPromptResult, MCPPromptInfo } from '$lib/types';
 
        interface Props {
                isPromptPickerOpen?: boolean;
                promptSearchQuery?: string;
-               isInlineResourcePickerOpen?: boolean;
-               resourceSearchQuery?: string;
+               isMentionPickerOpen?: boolean;
+               mentionQuery?: string;
+               mentionAnchor?: HTMLElement | null;
+               scopePath?: string | null;
                onPromptPickerClose?: () => void;
-               onInlineResourcePickerClose?: () => void;
-               onInlineResourceSelect?: () => void;
+               onMentionPickerClose?: () => void;
+               onMentionOpened?: () => void;
+               onMentionSelect?: (entry: FileMentionEntry) => void;
                onPromptLoadStart?: (
                        placeholderId: string,
                        promptInfo: MCPPromptInfo,
                ) => void;
                onPromptLoadComplete?: (placeholderId: string, result: GetPromptResult) => void;
                onPromptLoadError?: (placeholderId: string, error: string) => void;
-               onInlineResourceBrowse?: () => void;
        }
 
        let {
                isPromptPickerOpen,
                promptSearchQuery,
-               isInlineResourcePickerOpen,
-               resourceSearchQuery,
+               isMentionPickerOpen,
+               mentionQuery,
+               mentionAnchor,
+               scopePath,
                onPromptPickerClose,
-               onInlineResourcePickerClose,
-               onInlineResourceSelect,
+               onMentionPickerClose,
+               onMentionOpened,
+               onMentionSelect,
                onPromptLoadStart,
                onPromptLoadComplete,
-               onPromptLoadError,
-               onInlineResourceBrowse
+               onPromptLoadError
        }: Props = $props();
 
        let promptPickerRef: ChatFormPickerMcpPrompts | undefined = $state(undefined);
-       let resourcePickerRef: ChatFormPickerMcpResources | undefined = $state(undefined);
+       let mentionPickerRef: ChatFormMentionPicker | undefined = $state(undefined);
 
        /**
         * Delegates keyboard events to the active picker child.
@@ -47,7 +51,7 @@
                        return true;
                }
 
-               if (isInlineResourcePickerOpen && resourcePickerRef?.handleKeydown(event)) {
+               if (isMentionPickerOpen && mentionPickerRef?.handleKeydown(event)) {
                        return true;
                }
 
        {onPromptLoadError}
 />
 
-<ChatFormPickerMcpResources
-       bind:this={resourcePickerRef}
-       isOpen={isInlineResourcePickerOpen}
-       searchQuery={resourceSearchQuery}
-       onClose={onInlineResourcePickerClose}
-       onResourceSelect={onInlineResourceSelect}
-       onBrowse={onInlineResourceBrowse}
+<ChatFormMentionPicker
+       bind:this={mentionPickerRef}
+       isOpen={isMentionPickerOpen ?? false}
+       query={mentionQuery ?? ''}
+       customAnchor={mentionAnchor}
+       scopePath={scopePath ?? null}
+       onClose={onMentionPickerClose ?? (() => {})}
+       onOpened={onMentionOpened}
+       onSelect={onMentionSelect ?? (() => {})}
 />
index 3e683389f18bb93047da5eda47675657ebd8eee8..9fd3143e6abd7e0579d372a83f97961022f184c9 100644 (file)
                        textareaElement.style.height = '1rem';
                }
        }
+
+       // Plain-text caret offsets for the mention-splice flow.
+       export function getCaretOffset(): number {
+               if (!textareaElement) return 0;
+               return textareaElement.selectionStart ?? textareaElement.value.length;
+       }
+
+       export function setCaretOffset(offset: number) {
+               textareaElement?.setSelectionRange(offset, offset);
+       }
 </script>
 
 <div class="flex-1 {className}">
index 57108f306521f257e7313fd4b8ecb6c2c0f74b6c..19d26cb09e52d34471890cbe0537a1b48b281fcc 100644 (file)
@@ -351,14 +351,14 @@ export { default as ChatFormPickerPopover } from './ChatForm/ChatFormPickers/Cha
  * Generic scrollable list for picker popovers. Provides search input,
  * scroll-into-view for keyboard navigation, loading skeletons, empty state,
  * and optional footer. Uses Svelte 5 snippets for item/skeleton/footer rendering.
- * Shared by ChatFormPickerMcpPrompts and ChatFormPickerMcpResources.
+ * Shared by ChatFormPickerMcpPrompts and ChatFormMentionPicker.
  */
 export { default as ChatFormPickerList } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte';
 
 /**
  * Generic button wrapper for picker list items. Provides consistent styling,
  * hover/selected states, and data-picker-index attribute for scroll-into-view.
- * Shared by ChatFormPickerMcpPrompts and ChatFormPickerMcpResources.
+ * Shared by ChatFormPickerMcpPrompts and ChatFormMentionPicker.
  */
 export { default as ChatFormPickerListItem } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte';
 
@@ -376,30 +376,19 @@ export { default as ChatFormPickerItemHeader } from './ChatForm/ChatFormPickers/
 export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte';
 
 /**
- * **ChatFormPickerMcpResources** - MCP resource selection interface
- *
- * Floating picker for browsing and attaching MCP Server Resources.
- * Triggered by typing `@` in the chat input.
- * Loads resources from connected MCP servers and allows users to attach them to the chat context.
- *
- * **Features:**
- * - Search/filter resources by name, title, description, or URI across all connected servers
- * - Keyboard navigation (↑/↓ to navigate, Enter to select, Esc to close)
- * - Shows attached state for already-attached resources
- * - Loading states with skeleton placeholders
- * - Server information header per resource for visual identification
- *
- * **Exported API:**
- * - `handleKeydown(event): boolean` - Process keyboard events, returns true if handled
+ * `@`-triggered file/folder mention picker. Resolves `@<query>` in the chat
+ * input to a filesystem match via the server's `file_glob_search` built-in
+ * tool, scoped to the conversation cwd (or server home when unset).
+ * Selection splices a `[name](file:///<abs path>)` link into the input.
  */
-export { default as ChatFormPickerMcpResources } from './ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte';
+export { default as ChatFormMentionPicker } from './ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte';
 
 /**
  * **ChatFormPickers** - Chat input picker container
  *
- * Container component that hosts both MCP prompt and MCP resource pickers.
+ * 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 for `@`-triggered pickers.
+ * picker interfaces. Used within ChatForm.
  */
 export { default as ChatFormPickers } from './ChatForm/ChatFormPickers/ChatFormPickers.svelte';
 
index fc7e314122f7662aeffbef30918d846171e73ae1..be6041b63c2d79921cf185a14874ab9549cfdd7a 100644 (file)
@@ -15,6 +15,7 @@
        import { SvelteMap } from 'svelte/reactivity';
        import { rehypeRestoreTableHtml } from './plugins/rehype/table-html-restorer';
        import { rehypeEnhanceLinks } from './plugins/rehype/enhance-links';
+       import { rehypeFileBadge } from './plugins/rehype/file-badge';
        import { rehypeEnhanceCodeBlocks } from './plugins/rehype/enhance-code-blocks';
        import { rehypeEnhanceMermaidBlocks } from './plugins/rehype/enhance-mermaid-blocks';
        import { rehypeMermaidPre } from './plugins/rehype/mermaid-pre';
                        }) // Add syntax highlighting
                        .use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
                        .use(rehypeEnhanceLinks) // Add target="_blank" to links
+                       .use(rehypeFileBadge) // Render file:// anchors as inline badge chips
                        .use(rehypeMermaidPre) // Convert mermaid blocks to <pre class="mermaid">
                        .use(rehypeSvgPre) // Convert svg blocks to <pre class="svg-block">
                        .use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/file-badge.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/file-badge.ts
new file mode 100644 (file)
index 0000000..4459e2b
--- /dev/null
@@ -0,0 +1,100 @@
+/**
+ * Rehype plugin that rewrites `file://` markdown anchors into the inline
+ * @-mention chip, reusing the visual contract from
+ * `$lib/constants/mention-badge`.
+ *
+ * The chip is presentational: `file://` navigation is blocked from
+ * http(s) pages, so the anchor becomes a plain `<span>` (no link role,
+ * no tab stop); the full path stays available on `title`.
+ */
+
+import { decodeFileLinkPath, getMentionBadgeIconPaths, getMentionBadgeLabel } from '$lib/utils';
+import {
+       FILE_URI_PREFIX,
+       MENTION_BADGE_CLASSNAME,
+       MENTION_BADGE_ICON_CLASSNAME,
+       MENTION_BADGE_SVG_ATTRIBUTES,
+       PATH_SEPARATOR,
+       SETTINGS_KEYS
+} from '$lib/constants';
+import { settingsStore } from '$lib/stores/settings.svelte';
+import { toolsStore } from '$lib/stores/tools.svelte';
+import type { Plugin } from 'unified';
+import type { Root, Element } from 'hast';
+import { visit } from 'unist-util-visit';
+
+// Trailing path separators mark a directory and are kept out of the label.
+const TRAILING_SEPARATOR_REGEX = /\/+$/;
+
+function decodeHrefPath(href: string): string {
+       const stripped = href.startsWith(FILE_URI_PREFIX) ? href.slice(FILE_URI_PREFIX.length) : href;
+       return decodeFileLinkPath(stripped);
+}
+
+function labelFromFileUrl(href: string): string {
+       const decoded = decodeHrefPath(href);
+       const trimmed = decoded.replace(TRAILING_SEPARATOR_REGEX, '');
+       const slash = trimmed.lastIndexOf(PATH_SEPARATOR);
+       return slash === -1 ? trimmed : trimmed.slice(slash + 1);
+}
+
+// A trailing `/` in the target marks a directory and selects the folder
+// icon, matching the convention the mention picker inserts with.
+function iconElement(href: string): Element {
+       return {
+               type: 'element',
+               tagName: 'svg',
+               properties: {
+                       ...MENTION_BADGE_SVG_ATTRIBUTES,
+                       className: MENTION_BADGE_ICON_CLASSNAME.split(' ').filter(Boolean)
+               },
+               children: getMentionBadgeIconPaths(href).map((d) => ({
+                       type: 'element',
+                       tagName: 'path',
+                       properties: { d },
+                       children: []
+               }))
+       };
+}
+
+export const rehypeFileBadge: Plugin<[], Root> = () => {
+       return (tree: Root) => {
+               visit(tree, 'element', (node: Element) => {
+                       if (node.tagName !== 'a') return;
+
+                       const props = node.properties ?? {};
+                       const href = typeof props.href === 'string' ? props.href : null;
+
+                       if (!href || !href.startsWith(FILE_URI_PREFIX)) return;
+
+                       const label = labelFromFileUrl(href);
+                       const titleAttr = typeof props.title === 'string' ? props.title : href;
+                       const decodedPath = decodeHrefPath(href);
+
+                       node.tagName = 'span';
+                       node.properties = {
+                               className: MENTION_BADGE_CLASSNAME.split(' ').filter(Boolean),
+                               title: titleAttr.startsWith(FILE_URI_PREFIX) ? decodedPath : titleAttr
+                       };
+                       node.children = [
+                               iconElement(href),
+                               {
+                                       type: 'element',
+                                       tagName: 'span',
+                                       properties: { className: ['shrink-0', 'truncate'] },
+                                       children: [
+                                               {
+                                                       type: 'text',
+                                                       value: getMentionBadgeLabel(
+                                                               label,
+                                                               decodedPath,
+                                                               settingsStore.getConfig(SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS),
+                                                               toolsStore.serverHome
+                                                       )
+                                               }
+                                       ]
+                               }
+                       ];
+               });
+       };
+};
diff --git a/tools/ui/src/lib/components/app/forms/HighlightedMatch.svelte b/tools/ui/src/lib/components/app/forms/HighlightedMatch.svelte
new file mode 100644 (file)
index 0000000..880d21a
--- /dev/null
@@ -0,0 +1,25 @@
+<script lang="ts">
+       import { highlightMatch } from '$lib/utils';
+
+       interface Props {
+               text: string;
+               query: string;
+               matchClass?: string;
+       }
+
+       let {
+               text,
+               query,
+               matchClass = 'rounded bg-yellow-200/60 px-0.5 text-foreground dark:bg-yellow-500/30'
+       }: Props = $props();
+
+       let segments = $derived(highlightMatch(text, query));
+</script>
+
+{#each segments as seg, i (i)}
+       {#if seg.match}
+               <mark class={matchClass}>{seg.text}</mark>
+       {:else}
+               {seg.text}
+       {/if}
+{/each}
index 4cf56cdc9d046e0bb6388c44d97bc79bf41357a5..87594d7e351ef9c6647d213d9c32bb891963bdad 100644 (file)
@@ -42,3 +42,11 @@ export { default as KeyValuePairs } from './KeyValuePairs.svelte';
  * Supports placeholder, autofocus, and change callbacks.
  */
 export { default as SearchInput } from './SearchInput.svelte';
+
+/**
+ * **HighlightedMatch** - Substring-match text highlight
+ *
+ * Renders `text` with each case-insensitive occurrence of `query` wrapped
+ * in `<mark>`.
+ */
+export { default as HighlightedMatch } from './HighlightedMatch.svelte';
index 05ab8c1f821236c715f2eca6faed116d757a724d..6c413ed70a4dcfe17d40c3cf915590f3ab69d2b3 100644 (file)
@@ -2,5 +2,4 @@ export const INITIAL_FILE_SIZE = 0;
 export const PROMPT_CONTENT_SEPARATOR = '\n\n';
 export const CLIPBOARD_CONTENT_QUOTE_PREFIX = '"';
 export const PROMPT_TRIGGER_PREFIX = '/';
-export const RESOURCE_TRIGGER_PREFIX = '@';
 export const NEW_CHAT_DRAFT_KEY = '__new_chat__';
index 1f5ec7f0bab555ae5b581fc98b130597c42c5428..2fcd39ab2af36be0c4929fda328e58e6e691b745 100644 (file)
@@ -39,6 +39,7 @@ export * from './max-bundle-size';
 export * from './mcp';
 export * from './mcp-form';
 export * from './mcp-resource';
+export * from './mention-badge';
 export * from './message-export';
 export * from './path-display';
 export * from './model-id';
diff --git a/tools/ui/src/lib/constants/mention-badge.ts b/tools/ui/src/lib/constants/mention-badge.ts
new file mode 100644 (file)
index 0000000..b0026eb
--- /dev/null
@@ -0,0 +1,39 @@
+/**
+ * Visual contract for message @-mention badges. Svelte cannot be mounted
+ * from a hast tree, so the rehype file-badge plugin emits the shared class
+ * string below; keeping it here as a literal lets Tailwind's source
+ * scanner generate the utility classes.
+ */
+export const MENTION_BADGE_CLASSNAME =
+       'inline-flex w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md border border-border/50 bg-foreground/5 px-1.5 py-0.5 text-xs font-mono text-foreground hover:bg-foreground/10 dark:bg-foreground/10 dark:text-secondary-foreground';
+
+export const MENTION_BADGE_ICON_CLASSNAME = 'h-3 w-3 shrink-0';
+
+/**
+ * SVG attributes shared by the hast-built badge icons; the rehype plugin
+ * spreads them onto the `<svg>` `properties`.
+ */
+export const MENTION_BADGE_SVG_ATTRIBUTES: Readonly<Record<string, string>> = {
+       xmlns: 'http://www.w3.org/2000/svg',
+       viewBox: '0 0 24 24',
+       fill: 'none',
+       stroke: 'currentColor',
+       'stroke-width': '2',
+       'stroke-linecap': 'round',
+       'stroke-linejoin': 'round',
+       'aria-hidden': 'true'
+};
+
+/**
+ * SVG path strings for the badge's inline icon; each entry becomes one
+ * `<path>` child of the wrapper `<svg>`. Paths match `lucide-svelte`'s
+ * current `File` and `Folder` glyphs.
+ */
+export const MENTION_BADGE_FILE_ICON_PATHS: readonly string[] = [
+       'M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z',
+       'M14 2v5a1 1 0 0 0 1 1h5'
+];
+
+export const MENTION_BADGE_FOLDER_ICON_PATHS: readonly string[] = [
+       'M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z'
+];
index 265507a5b7a040caaec03bb56768e107ca90a3b9..db4581201ca261e3d7dfec913fb906ec0dc6a059 100644 (file)
@@ -31,8 +31,10 @@ export const SETTINGS_KEYS = {
        SHOW_MODEL_QUANTIZATION: 'showModelQuantization',
        SHOW_MODEL_TAGS: 'showModelTags',
        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',
        DYNATEMP_RANGE: 'dynatemp_range',
index 19eaef617c49417ffffb9a02a167dd33608c3e59..9704413baafc167ebaafa77a4305b7cdc2c495d6 100644 (file)
@@ -23,7 +23,12 @@ import type {
        SettingsSectionEntry,
        SettingsSection
 } from '$lib/types';
-import { CLI_FLAGS, DEFAULT_MCP_CONFIG } from '$lib/constants';
+import { CLI_FLAGS } from './cli-flags';
+import { DEFAULT_MCP_CONFIG } from './mcp';
+import {
+       FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH,
+       FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH
+} from './working-directory';
 import { SETTINGS_KEYS } from './settings-keys';
 import { ROUTES, SETTINGS_SECTION_SLUGS } from './routes';
 import { TITLE_GENERATION } from './title-generation';
@@ -298,6 +303,14 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
                                defaultValue: false,
                                type: SettingsFieldType.CHECKBOX,
                                section: SETTINGS_SECTION_SLUGS.DISPLAY
+                       },
+                       {
+                               key: SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS,
+                               label: 'Show full path in mentions',
+                               help: 'Display the full file system path inside file and folder @-mention badges instead of just the file or folder name.',
+                               defaultValue: false,
+                               type: SettingsFieldType.CHECKBOX,
+                               section: SETTINGS_SECTION_SLUGS.DISPLAY
                        }
                ]
        },
@@ -555,6 +568,18 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
                                type: SettingsFieldType.INPUT,
                                section: SETTINGS_SECTION_SLUGS.AGENTIC,
                                isPositiveInteger: true
+                       },
+                       {
+                               key: SETTINGS_KEYS.MENTION_SEARCH_MAX_DEPTH,
+                               label: 'Mention search depth',
+                               help: 'How many directory levels below the working directory the @-mention file search descends. Larger values surface deeply nested files but take longer on large trees.',
+                               defaultValue: FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH,
+                               placeholder: `${FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH}`,
+                               min: 1,
+                               max: FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH,
+                               type: SettingsFieldType.INPUT,
+                               section: SETTINGS_SECTION_SLUGS.AGENTIC,
+                               isPositiveInteger: true
                        }
                ]
        },
index 8f4f4fadec1ef1e16203645d497a8b99420bb1dd..2811c16453050b6b4f9c240c60f079f82326a879 100644 (file)
@@ -38,3 +38,9 @@ export const PATH_NAV_MAX_DEPTH = 1;
 // Native folder-picker resolution searches a shallow, bounded window.
 export const NATIVE_MAX_DEPTH = 4;
 export const NATIVE_LIMIT = 20;
+
+/** Upper bound the mention search depth setting accepts. The server itself imposes no depth cap (0 = unlimited); this is a UI sanity bound. */
+export const FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH = 32;
+
+/** Depth the pickers fall back to when the user setting is invalid. */
+export const FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH = 10;
index f4994bb8e94dc91edbcb98a6f3009901b62d75c4..17a2a4535e6ec1128fb11c34565be77ea0e9fadb 100644 (file)
@@ -78,3 +78,8 @@ export enum PdfViewMode {
        TEXT = 'text',
        PAGES = 'pages'
 }
+
+export enum FileMentionEntryType {
+       FILE = 'file',
+       DIRECTORY = 'directory'
+}
index 4da9e6ac8af073c1787bea0b45266fd585f85eff..7323563f3bf9f34835a89e05b1017254a0b46cd9 100644 (file)
@@ -24,7 +24,8 @@ export {
        MessageRole,
        MessageType,
        PdfViewMode,
-       ReasoningFormat
+       ReasoningFormat,
+       FileMentionEntryType
 } from './chat.enums';
 
 export { SessionRecordType } from './conversation-import.enums';
index 735d3e4b4684af5c4424b6f42333363ca3c68680..918bc57c5aad1dea7dcda94bd3a79ca7cedf0892 100644 (file)
@@ -8,6 +8,7 @@ export enum KeyboardKey {
        ARROW_DOWN = 'ArrowDown',
        ARROW_LEFT = 'ArrowLeft',
        ARROW_RIGHT = 'ArrowRight',
+       BACKSPACE = 'Backspace',
        TAB = 'Tab',
        B_LOWER = 'b',
        D_LOWER = 'd',
diff --git a/tools/ui/src/lib/hooks/use-debounced-search.svelte.ts b/tools/ui/src/lib/hooks/use-debounced-search.svelte.ts
new file mode 100644 (file)
index 0000000..cb838ff
--- /dev/null
@@ -0,0 +1,67 @@
+import { debounce } from '$lib/utils/debounce';
+
+/**
+ * Shared debounced async-search machinery for the chat-form pickers:
+ * AbortController + sequence counter to discard stale responses, a
+ * debounce, and a live `isSearching` flag.
+ */
+
+export interface UseDebouncedSearchOptions {
+       debounceMs: number;
+       /** Fire-time guard: a scheduled call that outlives a reset is dropped. */
+       canRun: () => boolean;
+       /** Live query, used to drop a scheduled call whose query changed. */
+       getQuery: () => string;
+       /** Perform the search and commit results; bail out when `isCurrent()` is false. */
+       run: (query: string, signal: AbortSignal, isCurrent: () => boolean) => void | Promise<void>;
+}
+
+export function useDebouncedSearch(opts: UseDebouncedSearchOptions) {
+       let controller: AbortController | null = null;
+       let searchSeq = 0;
+       let isSearching = $state(false);
+
+       function isCurrent(seq: number) {
+               return seq === searchSeq;
+       }
+
+       function cancel() {
+               controller?.abort();
+               searchSeq++;
+               isSearching = false;
+       }
+
+       const schedule = debounce((query: string) => {
+               if (!opts.canRun() || query !== opts.getQuery().trim()) return;
+               void start(query);
+       }, opts.debounceMs);
+
+       async function start(query: string) {
+               cancel();
+               const fresh = new AbortController();
+               controller = fresh;
+               const mySeq = ++searchSeq;
+               isSearching = true;
+               try {
+                       await opts.run(query, fresh.signal, () => isCurrent(mySeq));
+               } finally {
+                       if (isCurrent(mySeq)) isSearching = false;
+               }
+       }
+
+       return {
+               get isSearching() {
+                       return isSearching;
+               },
+               /** Bump the loading flag synchronously (e.g. before the debounce fires). */
+               setLoading(value: boolean) {
+                       isSearching = value;
+               },
+               run(query: string) {
+                       schedule(query);
+               },
+               cancel
+       };
+}
+
+export type UseDebouncedSearchReturn = ReturnType<typeof useDebouncedSearch>;
diff --git a/tools/ui/src/lib/hooks/use-picker-navigation.svelte.ts b/tools/ui/src/lib/hooks/use-picker-navigation.svelte.ts
new file mode 100644 (file)
index 0000000..08a677c
--- /dev/null
@@ -0,0 +1,108 @@
+import { KeyboardKey } from '$lib/enums';
+
+/**
+ * Shared keyboard navigation state for the chat-form pickers: a highlighted
+ * row, a scroll trigger, and Arrow/Escape/Enter handling.
+ */
+export interface UsePickerNavigationOptions {
+       /** Gates all key handling. */
+       isOpen: () => boolean;
+       count: () => number;
+       /**
+        * Resolve the row to highlight for a movement step, or -1 when no move
+        * is possible. Defaults to plain wraparound across `count()`.
+        */
+       step?: (from: number, dir: 1 | -1) => number;
+       onClose: () => void;
+       /** Called on Enter when `hoveredIndex` points at a selectable row. */
+       onSelect: (index: number) => void;
+}
+
+function wrapStep(from: number, dir: 1 | -1, count: number): number {
+       return dir === 1 ? (from + 1) % count : from <= 0 ? count - 1 : from - 1;
+}
+
+export function usePickerNavigation(opts: UsePickerNavigationOptions) {
+       let hoveredIndex = $state(-1);
+       let scrollTrigger = $state(0);
+
+       function resolve(from: number, dir: 1 | -1): number {
+               const n = opts.count();
+               if (n === 0) return -1;
+               if (opts.step) return opts.step(from, dir);
+               return wrapStep(from, dir, n);
+       }
+
+       function move(dir: 1 | -1) {
+               const next = resolve(hoveredIndex, dir);
+               if (next >= 0) {
+                       hoveredIndex = next;
+                       scrollTrigger++;
+               }
+       }
+
+       /** Reset the highlight without bumping the scroll trigger. */
+       function reset(index: number) {
+               hoveredIndex = index;
+       }
+
+       /** Bump the scroll trigger without moving the highlight. */
+       function bumpScroll() {
+               scrollTrigger++;
+       }
+
+       /** Mouse hover highlights a row but must NOT bump the scroll trigger. */
+       function setHover(index: number) {
+               hoveredIndex = index;
+       }
+
+       function handleKeydown(event: KeyboardEvent): boolean {
+               if (!opts.isOpen()) return false;
+
+               if (event.key === KeyboardKey.ESCAPE) {
+                       event.preventDefault();
+                       opts.onClose();
+                       return true;
+               }
+
+               if (event.key === KeyboardKey.ARROW_DOWN) {
+                       event.preventDefault();
+                       move(1);
+                       return true;
+               }
+
+               if (event.key === KeyboardKey.ARROW_UP) {
+                       event.preventDefault();
+                       move(-1);
+                       return true;
+               }
+
+               if (event.key === KeyboardKey.ENTER) {
+                       if (hoveredIndex >= 0 && hoveredIndex < opts.count()) {
+                               event.preventDefault();
+                               opts.onSelect(hoveredIndex);
+                               return true;
+                       }
+                       // No selectable row - let the caller's Enter-to-submit run.
+                       return false;
+               }
+
+               return false;
+       }
+
+       return {
+               get hoveredIndex() {
+                       return hoveredIndex;
+               },
+               get scrollTrigger() {
+                       return scrollTrigger;
+               },
+               reset,
+               setHover,
+               move,
+               bumpScroll,
+               handleKeydown
+       };
+}
+
+export type UsePickerNavigationReturn = ReturnType<typeof usePickerNavigation>;
diff --git a/tools/ui/src/lib/hooks/use-scroll-active-row.svelte.ts b/tools/ui/src/lib/hooks/use-scroll-active-row.svelte.ts
new file mode 100644 (file)
index 0000000..9f44b7b
--- /dev/null
@@ -0,0 +1,47 @@
+import { untrack } from 'svelte';
+
+/**
+ * Scrolls the highlighted row of a picker list into view when the scroll
+ * trigger is bumped, without scrolling on mouse hover or result
+ * replacement.
+ */
+export interface UseScrollActiveRowOptions {
+       /** Counter bumped by keyboard nav; `undefined` disables the effect. */
+       getTrigger: () => number | undefined;
+       getContainer: () => HTMLDivElement | null;
+       getIndex: () => number;
+       getCount: () => number;
+       /** Attribute prefix, e.g. 'picker' for `[data-picker-index="0"]`. */
+       dataIndex: string;
+}
+
+export function useScrollActiveRow(opts: UseScrollActiveRowOptions) {
+       let lastTrigger: number | null = null;
+
+       $effect(() => {
+               const trigger = opts.getTrigger();
+               if (trigger === undefined) return;
+
+               // Skip the initial run on mount: the list opens with the first row
+               // already in view, and scrolling here fires before the popover is
+               // positioned, which would scroll the whole page to the top.
+               if (lastTrigger === null) {
+                       lastTrigger = trigger;
+                       return;
+               }
+
+               if (trigger === lastTrigger) return;
+               lastTrigger = trigger;
+               untrack(() => {
+                       const container = opts.getContainer();
+                       const index = opts.getIndex();
+                       if (!container || index < 0 || index >= opts.getCount()) return;
+                       const row = container.querySelector(
+                               `[data-${opts.dataIndex}-index="${index}"]`
+                       ) as HTMLElement | null;
+                       row?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
+               });
+       });
+}
+
+export type UseScrollActiveRowReturn = ReturnType<typeof useScrollActiveRow>;
index 1b2c18b2c48845f23a8c692aeed59a0301fe14d3..2c8844c517fc00e983c4244f3248e73c256a966c 100644 (file)
@@ -1,4 +1,4 @@
-import type { ErrorDialogType } from '$lib/enums';
+import type { ErrorDialogType, FileMentionEntryType } from '$lib/enums';
 import type { ApiChatCompletionToolCall } from './api';
 import type { DatabaseMessage, DatabaseMessageExtra } from './database';
 
@@ -166,3 +166,13 @@ export interface FileProcessingResult {
        extras: DatabaseMessageExtra[];
        emptyFiles: string[];
 }
+
+/**
+ * A file or folder picked in the @-mention picker. `path` is the absolute
+ * server-side path; `name` is the basename.
+ */
+export interface FileMentionEntry {
+       path: string;
+       name: string;
+       type: FileMentionEntryType;
+}
index 408ac0cbdcbf5963c0bdeaf15692e82cb4a7ac3e..6cd000a623b8a876e703bb0b537323a3ea173dd5 100644 (file)
@@ -53,7 +53,8 @@ export type {
        LiveProcessingStats,
        LiveGenerationStats,
        AttachmentDisplayItemsOptions,
-       FileProcessingResult
+       FileProcessingResult,
+       FileMentionEntry
 } from './chat.d';
 
 // Database types
index c38665de737a123ce51fdfcedc71227a6ec309cf..fbad500f9902a8c99ec97690477186f5c2725c69 100644 (file)
@@ -31,6 +31,9 @@ export interface SettingsEntry {
        radioOptions?: Array<{ value: string; label: string; key: string; isExperimental?: boolean }>;
        isExperimental?: boolean;
        isPositiveInteger?: boolean;
+       placeholder?: string;
+       min?: number;
+       max?: number;
        dependsOn?: string;
        sync?: {
                serverKey: string;
@@ -52,6 +55,9 @@ export interface SettingsFieldConfig {
        type: SettingsFieldType;
        isExperimental?: boolean;
        isPositiveInteger?: boolean;
+       placeholder?: string;
+       min?: number;
+       max?: number;
        dependsOn?: string;
        help?: string;
        options?: Array<{ value: string; label: string; icon?: typeof Icon }>;
diff --git a/tools/ui/src/lib/utils/glob-search.ts b/tools/ui/src/lib/utils/glob-search.ts
new file mode 100644 (file)
index 0000000..66f7a8f
--- /dev/null
@@ -0,0 +1,151 @@
+/**
+ * Shared `file_glob_search` runners with a short-lived result cache, so a
+ * repeated query for the same (type, path, glob, depth) reuses the last
+ * result instead of re-walking the tree.
+ */
+
+import { BuiltInTool, GlobSearchType } from '$lib/enums';
+import { ToolsService } from '$lib/services/tools.service';
+import {
+       GLOB_WILDCARD,
+       PATH_NAV_MAX_DEPTH,
+       PATH_SEPARATOR,
+       WINDOWS_SEPARATOR
+} from '$lib/constants';
+import { lastPathSegment } from './path-display';
+import {
+       buildGlobSearchArgs,
+       joinPath,
+       rankEntries,
+       type GlobEntry,
+       type GlobSearchArgs
+} from './working-directory';
+
+const SEARCH_CACHE_TTL_MS = 2000;
+
+interface CacheEntry {
+       results: GlobEntry[];
+       base: string;
+       at: number;
+}
+
+const searchCache = new Map<string, CacheEntry>();
+
+export interface GlobSearchResult {
+       base: string;
+       entries: GlobEntry[];
+       error?: string;
+}
+
+export async function runGlobSearch(
+       args: GlobSearchArgs,
+       type: GlobSearchType,
+       limit: number,
+       signal: AbortSignal
+): Promise<GlobSearchResult> {
+       const key = `${type}\u0000${args.path}\u0000${args.include}\u0000${args.maxDepth}\u0000${limit}`;
+       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: args.path, type, include: args.include, max_depth: args.maxDepth, 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();
+       // prune stale entries so the short-lived cache cannot grow unbounded
+       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 };
+}
+
+export interface GlobEntryResult {
+       path: string;
+       name: string;
+       type: string;
+}
+
+export interface GlobSearchChildOptions {
+       type?: GlobSearchType;
+       /** Descend only on a trailing path separator (mention picker); off for
+        * the WD picker, which descends on any exact match. */
+       descendOnTrailingSeparator?: boolean;
+       childMaxDepth?: number;
+}
+
+export interface GlobSearchChildResult {
+       base: string;
+       args: GlobSearchArgs;
+       /** Outer ranked entries plus the walked directory's children (absolute). */
+       entries: GlobEntryResult[];
+       /** Absolute path of the directory whose children were appended. */
+       exactDir?: string;
+       error?: string;
+}
+
+function toEntryResult(e: GlobEntry, base: string): GlobEntryResult {
+       return { path: joinPath(base, e.path), name: lastPathSegment(e.path), type: e.type };
+}
+
+/**
+ * One ranked glob search that may also list the matched directory's
+ * children, shared by the WD picker (descend on exact match) and the
+ * mention picker (descend on a trailing `/` or `\`).
+ */
+export async function runGlobSearchWithChildren(
+       query: string,
+       scopePath: string,
+       searchDepth: number,
+       limit: number,
+       signal: AbortSignal,
+       options: GlobSearchChildOptions = {}
+): Promise<GlobSearchChildResult> {
+       const {
+               type = GlobSearchType.ALL,
+               descendOnTrailingSeparator = false,
+               childMaxDepth = PATH_NAV_MAX_DEPTH
+       } = options;
+
+       const args = buildGlobSearchArgs(query, scopePath, searchDepth);
+       const res = await runGlobSearch(args, type, limit, signal);
+       if (res.error) return { base: res.base, args, entries: [], error: res.error };
+
+       const ranked = rankEntries(res.entries, args.rankQuery);
+       const entries = ranked.map((e) => toEntryResult(e, res.base));
+
+       const last = args.last;
+       if (last) {
+               const wantsDescend = descendOnTrailingSeparator
+                       ? query.endsWith(PATH_SEPARATOR) || query.endsWith(WINDOWS_SEPARATOR)
+                       : true;
+               const exact = ranked.find(
+                       (e) => e.type === 'dir' && lastPathSegment(e.path).toLowerCase() === last.toLowerCase()
+               );
+               if (wantsDescend && exact) {
+                       const exactDir = joinPath(res.base, exact.path);
+                       const childRes = await runGlobSearch(
+                               { path: exactDir, include: GLOB_WILDCARD, maxDepth: childMaxDepth, rankQuery: '' },
+                               type,
+                               limit,
+                               signal
+                       );
+                       if (!childRes.error) {
+                               const children = childRes.entries
+                                       .map((e) => toEntryResult(e, childRes.base))
+                                       .sort((a, b) => a.path.localeCompare(b.path));
+                               return { base: res.base, args, entries: [...entries, ...children], exactDir };
+                       }
+               }
+       }
+
+       return { base: res.base, args, entries };
+}
index 4c0257639a2e431ded555b642a331230bd17b640..afcf26d35755891c1b58d1be8356feb2773d7f43 100644 (file)
@@ -174,13 +174,47 @@ export {
 export {
        splitPathQuery,
        buildCaseInsensitiveGlob,
+       buildGlobSearchArgs,
        rankEntries,
        joinPath,
        highlightMatch,
        type GlobEntry,
+       type GlobSearchArgs,
        type PathQuery
 } from './working-directory';
 
+// Shared `file_glob_search` runner with a short-lived result cache
+export {
+       runGlobSearch,
+       runGlobSearchWithChildren,
+       type GlobEntryResult,
+       type GlobSearchResult
+} from './glob-search';
+
+// Mention-token detection (for the `@`-triggered file/folder mention picker)
+export {
+       findMentionToken,
+       takeMentionDismissSnapshot,
+       type MentionDismissSnapshot
+} from './mention-token';
+
+// Mention-chip visual contract shared by the rehype file-badge plugin,
+// plus the `[name](file://...)` link helpers the mention picker splices in
+export {
+       fileMentionLinkRe,
+       encodeFileLinkPath,
+       decodeFileLinkPath,
+       MENTION_BADGE_CLASSNAME,
+       MENTION_BADGE_ICON_CLASSNAME,
+       MENTION_BADGE_SVG_ATTRIBUTES,
+       MENTION_BADGE_FILE_ICON_PATHS,
+       MENTION_BADGE_FOLDER_ICON_PATHS,
+       getMentionBadgeIconPaths,
+       getMentionBadgeLabel,
+       buildMentionInsertion,
+       mentionLinkEndingAt
+} from './mention-badge';
+
 // Agentic content utilities (structured section derivation)
 export {
        deriveAgenticSections,
diff --git a/tools/ui/src/lib/utils/mention-badge.ts b/tools/ui/src/lib/utils/mention-badge.ts
new file mode 100644 (file)
index 0000000..c90f1f9
--- /dev/null
@@ -0,0 +1,101 @@
+import { abbreviateHome, lastPathSegment } from './path-display';
+import {
+       MENTION_BADGE_FILE_ICON_PATHS,
+       MENTION_BADGE_FOLDER_ICON_PATHS
+} from '$lib/constants/mention-badge';
+import { FILE_URI_PREFIX } from '$lib/constants';
+import { FileMentionEntryType } from '$lib/enums';
+import type { FileMentionEntry } from '$lib/types';
+
+export {
+       MENTION_BADGE_CLASSNAME,
+       MENTION_BADGE_ICON_CLASSNAME,
+       MENTION_BADGE_SVG_ATTRIBUTES,
+       MENTION_BADGE_FILE_ICON_PATHS,
+       MENTION_BADGE_FOLDER_ICON_PATHS
+} from '$lib/constants/mention-badge';
+
+// `)` is allowed in a path only when not followed by whitespace or `[`,
+// so macOS paths parse while adjacent badges still terminate the match.
+const FILE_MENTION_LINK_SOURCE = String.raw`\[([^\]\n]+?)\]\(file:\/\/((?:[^)\n]|\)(?![\s[]))+)\)`;
+
+export function fileMentionLinkRe(flags = ''): RegExp {
+       return new RegExp(FILE_MENTION_LINK_SOURCE, flags);
+}
+
+// Escape each path segment for a markdown link destination (spaces/parens
+// break CommonMark); keeps the trailing slash that marks a directory.
+export function encodeFileLinkPath(path: string): string {
+       return path
+               .split('/')
+               .map((segment) => encodeURIComponent(segment))
+               .join('/');
+}
+
+// Malformed escape sequences fall back to the input unchanged.
+export function decodeFileLinkPath(path: string): string {
+       try {
+               return path
+                       .split('/')
+                       .map((segment) => decodeURIComponent(segment))
+                       .join('/');
+       } catch {
+               return path;
+       }
+}
+
+export function getMentionBadgeIconPaths(path: string): readonly string[] {
+       return path.endsWith('/') ? MENTION_BADGE_FOLDER_ICON_PATHS : MENTION_BADGE_FILE_ICON_PATHS;
+}
+
+export function getMentionBadgeLabel(
+       name: string,
+       path: string,
+       showFullPath: boolean,
+       home?: string | null
+): string {
+       if (!showFullPath) return name;
+       const decoded = decodeFileLinkPath(path.replace(/\/+$/, ''));
+       if (!decoded) return name;
+       return abbreviateHome(decoded, home);
+}
+
+/**
+ * Extent of the mention link ending exactly at `caret`, so Backspace there
+ * deletes the whole `[name](file://...)` token in one keystroke instead of
+ * unraveling it character by character. Null when no link ends at `caret`.
+ */
+export function mentionLinkEndingAt(
+       value: string,
+       caret: number
+): { start: number; end: number } | null {
+       const re = fileMentionLinkRe('g');
+       let match: RegExpExecArray | null;
+       while ((match = re.exec(value)) !== null) {
+               const end = match.index + match[0].length;
+               if (end === caret) return { start: match.index, end };
+               if (end > caret) break;
+       }
+       return null;
+}
+
+/**
+ * Build the markdown link that replaces a mention token. Entry `path` is
+ * already rooted, so `file://` + `/abs` yields the canonical `file:///`.
+ * Null when the token is invalid.
+ */
+export function buildMentionInsertion(
+       entry: FileMentionEntry,
+       value: string,
+       token: { start: number; end: number }
+): { newValue: string; caretOffset: number } | null {
+       if (token.start < 0 || token.end > value.length || token.start > token.end) return null;
+       // Strip the entry's directory marker so it is not doubled below.
+       const cleanedPath = entry.path.replace(/\/+$/, '');
+       const pathWithSeparator =
+               entry.type === FileMentionEntryType.DIRECTORY ? `${cleanedPath}/` : cleanedPath;
+       const basename = lastPathSegment(cleanedPath) || entry.name;
+       const insertion = `[${basename}](${FILE_URI_PREFIX}${encodeFileLinkPath(pathWithSeparator)}) `;
+       const newValue = value.slice(0, token.start) + insertion + value.slice(token.end);
+       return { newValue, caretOffset: token.start + insertion.length };
+}
diff --git a/tools/ui/src/lib/utils/mention-token.ts b/tools/ui/src/lib/utils/mention-token.ts
new file mode 100644 (file)
index 0000000..be26143
--- /dev/null
@@ -0,0 +1,73 @@
+// An `@` starts a mention only when preceded by start-of-string or one of
+// these; identifier chars are not delimiters, so a mid-word `@` does not.
+const TOKEN_BOUNDARY_CHARS = new Set([
+       ' ',
+       '\t',
+       '\n',
+       '\r',
+       '(',
+       ')',
+       '[',
+       ']',
+       ',',
+       ';',
+       ':',
+       '"',
+       "'"
+]);
+
+/**
+ * Find the most-recent `@`-mention token whose extent includes `cursor`;
+ * the query covers the whole `@...` token regardless of caret position.
+ */
+export function findMentionToken(
+       value: string,
+       cursor: number
+): { start: number; end: number; query: string } | null {
+       if (cursor <= 0 || cursor > value.length) return null;
+
+       let atIndex = -1;
+       for (let i = cursor - 1; i >= 0; i--) {
+               const ch = value[i];
+               if (ch === '@') {
+                       const prev = i > 0 ? value[i - 1] : '';
+                       if (i === 0 || TOKEN_BOUNDARY_CHARS.has(prev)) {
+                               atIndex = i;
+                       }
+                       break;
+               }
+               if (TOKEN_BOUNDARY_CHARS.has(ch)) break;
+       }
+
+       if (atIndex === -1) return null;
+
+       let end = atIndex + 1;
+       while (end < value.length && !TOKEN_BOUNDARY_CHARS.has(value[end])) {
+               end++;
+       }
+
+       return {
+               start: atIndex,
+               end,
+               query: value.slice(atIndex + 1, end)
+       };
+}
+
+/**
+ * Stable signature of a mention token for use as a "dismissed" marker:
+ * while the picker is closed and this exact token is still intact, the
+ * picker does not silently re-open on in-token edits.
+ */
+export interface MentionDismissSnapshot {
+       start: number;
+       query: string;
+}
+
+export function takeMentionDismissSnapshot(
+       value: string,
+       cursor: number
+): MentionDismissSnapshot | null {
+       const token = findMentionToken(value, cursor);
+       if (!token) return null;
+       return { start: token.start, query: token.query };
+}
index 4663e6df885058fd14358d40d51947d8a65daba3..05820194cbce048912dd2f00c4bea54859f90752 100644 (file)
@@ -9,22 +9,14 @@ import {
        HOME_TILDE_PREFIX
 } from '$lib/constants';
 
-/**
- * Last non-empty slash-delimited segment of `path`, with trailing
- * slashes stripped. Returns the input unchanged when no `/` is present.
- */
 export function lastPathSegment(p: string): string {
        const trimmed = p.replace(TRAILING_SLASHES_REGEX, '');
        const idx = trimmed.lastIndexOf(PATH_SEPARATOR);
        return idx === -1 ? trimmed : trimmed.slice(idx + 1);
 }
 
-/**
- * Abbreviate `path` to `~/...` when it sits under `home`, or to `~` when
- * it equals `home`. Falls back to `lastPathSegment(path)` when home is
- * unknown or the path is outside it. `~` semantics are reserved for the
- * home directory, mirroring how shells render it.
- */
+// `~/...` under `home`; falls back to the basename when home is unknown
+// or the path is outside it.
 export function abbreviateWorkingDir(
        path: string | null | undefined,
        home: string | null | undefined
@@ -37,12 +29,8 @@ export function abbreviateWorkingDir(
        return lastPathSegment(path);
 }
 
-/**
- * Replace a leading `home` prefix in `path` with `~`. Unlike
- * abbreviateWorkingDir, paths outside `home` (or an unknown home) are
- * returned unchanged - used for tool-call path displays where the full
- * path matters.
- */
+// Unlike abbreviateWorkingDir, paths outside `home` are returned
+// unchanged - used where the full path matters.
 export function abbreviateHome(path: string, home: string | null | undefined): string {
        if (!home) return path;
        if (path === home) return HOME_TILDE;
@@ -61,10 +49,9 @@ export interface CwdMessageInfo {
 }
 
 /**
- * Format a synthetic cwd-change message. The text mirrors what the UI
- * renders for it; the path travels as `[file:///abs/path](display)` so
- * both the absolute and the short form are visible to the model and
- * parseable back by the UI.
+ * Format a synthetic cwd-change message. The path travels as
+ * `[file:///abs/path](display)` so both the absolute and short form are
+ * visible to the model and parseable back by the UI.
  */
 export function formatCwdMessage(cwd: string, home: string | null): string {
        const display = abbreviateWorkingDir(cwd, home);
@@ -72,10 +59,9 @@ export function formatCwdMessage(cwd: string, home: string | null): string {
 }
 
 /**
- * Parse a synthetic cwd message back into its parts. The caller must already
- * know the message is synthetic (via the persisted `isSynthetic` flag); this
- * only extracts the path from the message text. Returns null when `content`
- * is not a cwd message.
+ * Parse a synthetic cwd message back into its parts. The caller must
+ * already know the message is synthetic (via the persisted `isSynthetic`
+ * flag); this only extracts the path.
  */
 export function parseCwdMessage(content: string): CwdMessageInfo | null {
        const trimmed = content.trim();
index 55fa6ef271cb4942ffe6b570b74f7ee1456202cd..175a4f9f9201416987b351bfc5f7a6a4c4ffdd68 100644 (file)
@@ -1,13 +1,8 @@
 /**
- * Pure helpers for the working-directory picker search.
- *
- * The picker is backed by the server's `file_glob_search` built-in tool.
- * Queries that start from a root (`/`, `C:\`, `\\host\share`) or from `~`
- * navigate the directory tree (search the parent for the last segment);
- * anything else glob-matches home-relative entries. Paths are carried with
- * `/` separators, which is what the server returns and what Windows accepts.
- * These helpers build the glob, normalize results and rank them
- * client-side; the component owns the network/state plumbing.
+ * Pure helpers for the working-directory picker search, backed by the
+ * server's `file_glob_search` tool. Queries starting from a root (`/`,
+ * `C:\`, `\\host\share`) or `~` navigate the tree (search the parent for
+ * the last segment); anything else glob-matches home-relative entries.
  */
 
 import { PATH_SEPARATOR } from '$lib/constants/mcp-resource';
@@ -21,6 +16,7 @@ import {
        GLOB_WILDCARD,
        HOME_TILDE,
        LEADING_SLASHES_REGEX,
+       PATH_NAV_MAX_DEPTH,
        UNC_ROOT_REGEX,
        WINDOWS_SEPARATOR
 } from '$lib/constants';
@@ -45,10 +41,6 @@ function toPosixSeparators(query: string): string {
        return query.split(WINDOWS_SEPARATOR).join(PATH_SEPARATOR);
 }
 
-/**
- * Length of the root prefix of `path`, or 0 when it has none. Covers the
- * POSIX root, a Windows drive (`C:/`) and a UNC share (`//host/share/`).
- */
 export function rootPrefixLength(path: string): number {
        const unc = path.match(UNC_ROOT_REGEX);
        if (unc) return unc[0].length;
@@ -84,7 +76,6 @@ export function splitPathQuery(query: string): PathQuery | null {
        return { parent: parentOf(rest.slice(0, idx)), last: rest.slice(idx + 1) };
 }
 
-/** Build a case-insensitive glob that matches `query` anywhere within a name. */
 export function buildCaseInsensitiveGlob(query: string): string {
        let out = GLOB_WILDCARD;
        for (const c of query) {
@@ -99,7 +90,33 @@ export function buildCaseInsensitiveGlob(query: string): string {
        return out + GLOB_WILDCARD;
 }
 
-/** Exact basename first, then prefix, then substring; lower is better. */
+export interface GlobSearchArgs {
+       path: string;
+       include: string;
+       maxDepth: number;
+       rankQuery: string;
+       /** Last segment of a path-navigation query (`~/dir/sub`), undefined for
+        * a plain home-relative glob. Lets callers act on the exact targeted
+        * segment (e.g. the WD picker "entering" a directory). */
+       last?: string;
+}
+
+export function buildGlobSearchArgs(
+       query: string,
+       scopePath: string,
+       searchDepth: number
+): GlobSearchArgs {
+       const pathQuery = splitPathQuery(query);
+       const path = pathQuery ? pathQuery.parent : scopePath;
+       const include = pathQuery
+               ? pathQuery.last
+                       ? buildCaseInsensitiveGlob(pathQuery.last)
+                       : GLOB_WILDCARD
+               : buildCaseInsensitiveGlob(query);
+       const maxDepth = pathQuery ? PATH_NAV_MAX_DEPTH : searchDepth;
+       return { path, include, maxDepth, rankQuery: pathQuery?.last ?? query, last: pathQuery?.last };
+}
+
 const RANK_EXACT = 0;
 const RANK_PREFIX = 1;
 const RANK_SUBSTRING = 2;
@@ -114,7 +131,6 @@ function rankScore(path: string, query: string): number {
        return RANK_OTHER;
 }
 
-/** Sort entries by relevance, then shorter path, then alphabetically. */
 export function rankEntries(entries: GlobEntry[], query: string): GlobEntry[] {
        return [...entries].sort(
                (a, b) =>
@@ -124,13 +140,11 @@ export function rankEntries(entries: GlobEntry[], query: string): GlobEntry[] {
        );
 }
 
-/** Join a base path and a relative segment, avoiding duplicate slashes. */
 export function joinPath(base: string, rel: string): string {
        if (!base) return rel;
        return base.replace(TRAILING_SLASHES_REGEX, '') + PATH_SEPARATOR + rel;
 }
 
-/** Split `text` into alternating segments at each case-insensitive `query` match. */
 export function highlightMatch(text: string, query: string): { text: string; match: boolean }[] {
        if (!query) return [{ text, match: false }];
        const segments: { text: string; match: boolean }[] = [];
diff --git a/tools/ui/tests/client/components/PickerListScrollHarness.svelte b/tools/ui/tests/client/components/PickerListScrollHarness.svelte
new file mode 100644 (file)
index 0000000..8774697
--- /dev/null
@@ -0,0 +1,43 @@
+<script lang="ts">
+       import { ChatFormPickerList, ChatFormPickerListItem } from '$lib/components/app/chat';
+
+       interface Item {
+               id: string;
+               label: string;
+       }
+
+       const items: Item[] = Array.from({ length: 20 }, (_, i) => ({
+               id: String(i),
+               label: `item ${i}`
+       }));
+
+       let open = $state(false);
+       let scrollTrigger = $state(0);
+       let selectedIndex = $state(0);
+
+       export function openPicker() {
+               open = true;
+       }
+</script>
+
+<div style="height: 5000px;">conversation</div>
+
+{#if open}
+       <div data-testid="picker-host">
+               <ChatFormPickerList
+                       {items}
+                       isLoading={false}
+                       {selectedIndex}
+                       searchQuery=""
+                       showSearchInput={false}
+                       {scrollTrigger}
+                       itemKey={(it) => it.id}
+               >
+                       {#snippet item(it, index, isSelected)}
+                               <ChatFormPickerListItem dataIndex={index} {isSelected} onclick={() => {}}>
+                                       {it.label}
+                               </ChatFormPickerListItem>
+                       {/snippet}
+               </ChatFormPickerList>
+       </div>
+{/if}
diff --git a/tools/ui/tests/client/picker-list-scroll.svelte.test.ts b/tools/ui/tests/client/picker-list-scroll.svelte.test.ts
new file mode 100644 (file)
index 0000000..1ce0199
--- /dev/null
@@ -0,0 +1,29 @@
+// Regression test: opening a chat-form picker must not scroll the
+// conversation to the top. Root cause: the list's scroll effect fired
+// scrollIntoView on the initial mount, before the popover was positioned,
+// so the browser scrolled every scrollable ancestor to reveal the row.
+
+import { describe, it, expect } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import { tick } from 'svelte';
+import PickerListScrollHarness from './components/PickerListScrollHarness.svelte';
+
+describe('ChatFormPickerList mount scroll', () => {
+       it('does not scroll documentElement when the picker mounts', async () => {
+               const screen = render(PickerListScrollHarness);
+               await tick();
+
+               document.documentElement.scrollTop = document.documentElement.scrollHeight;
+               await tick();
+               const before = document.documentElement.scrollTop;
+               expect(before).toBeGreaterThan(0);
+
+               screen.component.openPicker();
+               await tick();
+               await new Promise((r) => setTimeout(r, 100));
+               await tick();
+
+               const after = document.documentElement.scrollTop;
+               expect(after).toBe(before);
+       });
+});
diff --git a/tools/ui/tests/unit/glob-search-children.test.ts b/tools/ui/tests/unit/glob-search-children.test.ts
new file mode 100644 (file)
index 0000000..46b7caa
--- /dev/null
@@ -0,0 +1,119 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('$lib/services/tools.service', () => ({
+       ToolsService: { executeToolRaw: vi.fn() }
+}));
+
+import { ToolsService } from '$lib/services/tools.service';
+import { GlobSearchType } from '$lib/enums';
+import { runGlobSearchWithChildren } from '$lib/utils';
+
+const mockExecute = vi.mocked(ToolsService.executeToolRaw);
+
+// Distinct roots per test so the module-level search cache never serves a
+// prior test's result under the same (type, path, glob, depth) key.
+beforeEach(() => {
+       mockExecute.mockReset();
+});
+
+describe('runGlobSearchWithChildren', () => {
+       it('returns ranked outer entries as absolute paths without descending', async () => {
+               mockExecute.mockResolvedValueOnce({
+                       base: '/Users/rootA',
+                       entries: [
+                               { path: 'note.md', type: 'file' },
+                               { path: 'src', type: 'dir' }
+                       ]
+               });
+               const res = await runGlobSearchWithChildren(
+                       'note',
+                       '/Users/rootA',
+                       3,
+                       50,
+                       new AbortController().signal
+               );
+               expect(res.error).toBeUndefined();
+               expect(res.entries.map((e) => e.path)).toEqual(['/Users/rootA/note.md', '/Users/rootA/src']);
+               expect(res.exactDir).toBeUndefined();
+               expect(mockExecute).toHaveBeenCalledTimes(1);
+       });
+
+       it('appends a matched directorys children when the query ends with a separator', async () => {
+               mockExecute
+                       .mockResolvedValueOnce({ base: '/Users/rootB', entries: [{ path: 'src', type: 'dir' }] })
+                       .mockResolvedValueOnce({
+                               base: '/Users/rootB/src',
+                               entries: [
+                                       { path: 'a.txt', type: 'file' },
+                                       { path: 'sub', type: 'dir' }
+                               ]
+                       });
+               const res = await runGlobSearchWithChildren(
+                       '/Users/rootB/src/',
+                       '/Users/rootB',
+                       3,
+                       50,
+                       new AbortController().signal,
+                       { type: GlobSearchType.ALL, descendOnTrailingSeparator: true }
+               );
+               expect(res.error).toBeUndefined();
+               expect(res.exactDir).toBe('/Users/rootB/src');
+               expect(res.entries.map((e) => e.path)).toEqual([
+                       '/Users/rootB/src',
+                       '/Users/rootB/src/a.txt',
+                       '/Users/rootB/src/sub'
+               ]);
+               expect(mockExecute).toHaveBeenCalledTimes(2);
+       });
+
+       it('does not descend without a trailing separator in mention mode', async () => {
+               mockExecute.mockResolvedValueOnce({
+                       base: '/Users/rootC',
+                       entries: [{ path: 'src', type: 'dir' }]
+               });
+               const res = await runGlobSearchWithChildren(
+                       '/Users/rootC/src',
+                       '/Users/rootC',
+                       3,
+                       50,
+                       new AbortController().signal,
+                       { type: GlobSearchType.ALL, descendOnTrailingSeparator: true }
+               );
+               expect(res.exactDir).toBeUndefined();
+               expect(mockExecute).toHaveBeenCalledTimes(1);
+       });
+
+       it('descends on an exact directory match in WD mode', async () => {
+               mockExecute
+                       .mockResolvedValueOnce({ base: '/Users/rootD', entries: [{ path: 'src', type: 'dir' }] })
+                       .mockResolvedValueOnce({
+                               base: '/Users/rootD/src',
+                               entries: [{ path: 'a.txt', type: 'file' }]
+                       });
+               const res = await runGlobSearchWithChildren(
+                       '/Users/rootD/src',
+                       '/Users/rootD',
+                       3,
+                       50,
+                       new AbortController().signal,
+                       { type: GlobSearchType.DIR }
+               );
+               expect(res.exactDir).toBe('/Users/rootD/src');
+               expect(res.entries.map((e) => e.path)).toEqual(['/Users/rootD/src', '/Users/rootD/src/a.txt']);
+               expect(mockExecute).toHaveBeenCalledTimes(2);
+       });
+
+       it('surfaces a server error without attempting a child walk', async () => {
+               mockExecute.mockResolvedValueOnce({ error: 'boom' });
+               const res = await runGlobSearchWithChildren(
+                       'src',
+                       '/Users/rootE',
+                       3,
+                       50,
+                       new AbortController().signal
+               );
+               expect(res.error).toBe('boom');
+               expect(res.entries).toEqual([]);
+               expect(mockExecute).toHaveBeenCalledTimes(1);
+       });
+});
diff --git a/tools/ui/tests/unit/mention-badge.test.ts b/tools/ui/tests/unit/mention-badge.test.ts
new file mode 100644 (file)
index 0000000..03a0d5b
--- /dev/null
@@ -0,0 +1,202 @@
+import { describe, expect, it } from 'vitest';
+import {
+       MENTION_BADGE_FILE_ICON_PATHS,
+       MENTION_BADGE_FOLDER_ICON_PATHS,
+       buildMentionInsertion,
+       decodeFileLinkPath,
+       encodeFileLinkPath,
+       fileMentionLinkRe,
+       getMentionBadgeIconPaths,
+       getMentionBadgeLabel,
+       mentionLinkEndingAt
+} from '$lib/utils';
+import { FileMentionEntryType } from '$lib/enums';
+
+describe('encodeFileLinkPath', () => {
+       it('leaves a clean path unchanged', () => {
+               expect(encodeFileLinkPath('/Users/foo/bar.txt')).toBe('/Users/foo/bar.txt');
+       });
+
+       it('encodes spaces per path segment', () => {
+               expect(
+                       encodeFileLinkPath('/Users/allozaur/Desktop/Screenshot 2026-08-05 at 11.33.45.png')
+               ).toBe('/Users/allozaur/Desktop/Screenshot%202026-08-05%20at%2011.33.45.png');
+       });
+
+       it('preserves the leading and trailing slash (directory marker)', () => {
+               expect(encodeFileLinkPath('/Users/foo/bar/')).toBe('/Users/foo/bar/');
+       });
+
+       it('encodes parentheses in macOS screenshot names', () => {
+               expect(encodeFileLinkPath('/Users/foo/Pic (1).png')).toBe('/Users/foo/Pic%20(1).png');
+       });
+});
+
+describe('fileMentionLinkRe', () => {
+       it('matches a standard mention link', () => {
+               expect(fileMentionLinkRe().test('[docs](file:///a/b)')).toBe(true);
+       });
+
+       it('does not match non-file links', () => {
+               expect(fileMentionLinkRe().test('[foo](https://example.com)')).toBe(false);
+               expect(fileMentionLinkRe().test('plain text')).toBe(false);
+       });
+
+       it('admits a close paren in a macOS-style file name', () => {
+               const match = fileMentionLinkRe().exec(
+                       '[Screenshot (1).png](file:///Users/foo/Screenshot (1).png)'
+               );
+               expect(match).not.toBeNull();
+               expect(match?.[1]).toBe('Screenshot (1).png');
+               expect(match?.[2]).toBe('/Users/foo/Screenshot (1).png');
+       });
+
+       it('admits a parenthesized folder segment', () => {
+               expect(
+                       fileMentionLinkRe().exec('[main.rs](file:///Users/foo/Project (Stuff)/main.rs)')?.[2]
+               ).toBe('/Users/foo/Project (Stuff)/main.rs');
+       });
+
+       it('stops at the closing paren of an adjacent badge', () => {
+               expect(fileMentionLinkRe().exec('[a](file:///p)[b](file:///q)')?.[0]).toBe('[a](file:///p)');
+       });
+});
+
+describe('getMentionBadgeIconPaths', () => {
+       it('returns the folder glyphs for a trailing-separator path', () => {
+               expect(getMentionBadgeIconPaths('/Users/foo/bar/')).toBe(MENTION_BADGE_FOLDER_ICON_PATHS);
+       });
+
+       it('returns the file glyphs otherwise', () => {
+               expect(getMentionBadgeIconPaths('/Users/foo/bar.txt')).toBe(MENTION_BADGE_FILE_ICON_PATHS);
+       });
+});
+
+describe('getMentionBadgeLabel', () => {
+       it('returns the name by default', () => {
+               expect(getMentionBadgeLabel('bar', '/Users/foo/bar/', false)).toBe('bar');
+       });
+
+       it('renders the decoded full path without the trailing separator', () => {
+               expect(getMentionBadgeLabel('bar', '/Users/foo/bar/', true)).toBe('/Users/foo/bar');
+               expect(getMentionBadgeLabel('shot', '/Users/foo/Screenshot%20(1).png', true)).toBe(
+                       '/Users/foo/Screenshot (1).png'
+               );
+       });
+
+       it('abbreviates a known home prefix to a tilde', () => {
+               expect(getMentionBadgeLabel('main.rs', '/home/user/src/main.rs', true, '/home/user')).toBe(
+                       '~/src/main.rs'
+               );
+       });
+
+       it('falls back to the name when the decoded path is empty', () => {
+               expect(getMentionBadgeLabel('root', '/', true)).toBe('root');
+       });
+});
+
+describe('decodeFileLinkPath', () => {
+       it('decodes encoded segments back to the original path', () => {
+               expect(
+                       decodeFileLinkPath('/Users/allozaur/Desktop/Screenshot%202026-08-05%20at%2011.33.45.png')
+               ).toBe('/Users/allozaur/Desktop/Screenshot 2026-08-05 at 11.33.45.png');
+       });
+
+       it('is the inverse of encodeFileLinkPath', () => {
+               for (const path of [
+                       '/a/b.txt',
+                       '/Users/foo/Desktop/Screenshot 2026-08-05 at 11.33.45.png',
+                       '/Users/foo/bar (1)/dir/',
+                       '/sp ace/pa%th.txt'
+               ]) {
+                       expect(decodeFileLinkPath(encodeFileLinkPath(path))).toBe(path);
+               }
+       });
+
+       it('falls back to the input on malformed percent sequences', () => {
+               expect(decodeFileLinkPath('/a/%zz.txt')).toBe('/a/%zz.txt');
+       });
+});
+
+describe('buildMentionInsertion', () => {
+       const file = (path: string, name: string) => ({
+               path,
+               name,
+               type: FileMentionEntryType.FILE
+       });
+       const dir = (path: string, name: string) => ({
+               path,
+               name,
+               type: FileMentionEntryType.DIRECTORY
+       });
+
+       it('splices a root-anchored file link in place of the token', () => {
+               const value = 'hello @repo';
+               const result = buildMentionInsertion(file('/Users/foo/myRepo', 'myRepo'), value, {
+                       start: 6,
+                       end: 11
+               });
+               expect(result).not.toBeNull();
+               const { newValue, caretOffset } = result!;
+               expect(newValue).toBe('hello [myRepo](file:///Users/foo/myRepo) ');
+               expect(caretOffset).toBe(6 + '[myRepo](file:///Users/foo/myRepo) '.length);
+       });
+
+       it('keeps the trailing slash on the directory marker', () => {
+               const value = 'see @src';
+               const { newValue } = buildMentionInsertion(dir('/Users/foo/myRepo/src/', 'src'), value, {
+                       start: 4,
+                       end: 8
+               })!;
+               expect(newValue).toBe('see [src](file:///Users/foo/myRepo/src/) ');
+       });
+
+       it('escapes spaces and parens in the target', () => {
+               const value = '@pic';
+               const { newValue } = buildMentionInsertion(
+                       file('/Users/foo/Desktop/Pic (1).png', 'Pic (1).png'),
+                       value,
+                       { start: 0, end: 4 }
+               )!;
+               expect(newValue).toBe('[Pic (1).png](file:///Users/foo/Desktop/Pic%20(1).png) ');
+       });
+
+       it('re-adds the directory marker when the cleaned path empties', () => {
+               const { newValue } = buildMentionInsertion(dir('/', 'root'), '/', { start: 0, end: 1 })!;
+               expect(newValue).toBe('[root](file:///) ');
+       });
+
+       it('returns null for an out-of-range token', () => {
+               expect(buildMentionInsertion(file('/a/b.txt', 'b.txt'), 'x', { start: 0, end: 5 })).toBeNull();
+               expect(buildMentionInsertion(file('/a/b.txt', 'b.txt'), 'x', { start: 2, end: 1 })).toBeNull();
+       });
+});
+
+describe('mentionLinkEndingAt', () => {
+       const LINK = '[docs](file:///a/b)';
+
+       it('returns the extent when the caret is exactly at the link end', () => {
+               expect(mentionLinkEndingAt(`see ${LINK} here`, 4 + LINK.length)).toEqual({
+                       start: 4,
+                       end: 4 + LINK.length
+               });
+       });
+
+       it('returns null when the caret is inside or past the link', () => {
+               expect(mentionLinkEndingAt(LINK, LINK.length - 1)).toBeNull();
+               expect(mentionLinkEndingAt(`${LINK} `, LINK.length + 1)).toBeNull();
+       });
+
+       it('returns null for non-file links and plain text', () => {
+               expect(mentionLinkEndingAt('[foo](https://example.com)', 26)).toBeNull();
+               expect(mentionLinkEndingAt('plain', 5)).toBeNull();
+       });
+
+       it('picks the link that ends at the caret when several exist', () => {
+               const value = `${LINK} and ${LINK}`;
+               expect(mentionLinkEndingAt(value, value.length)).toEqual({
+                       start: value.length - LINK.length,
+                       end: value.length
+               });
+       });
+});
diff --git a/tools/ui/tests/unit/mention-token.test.ts b/tools/ui/tests/unit/mention-token.test.ts
new file mode 100644 (file)
index 0000000..fd21a94
--- /dev/null
@@ -0,0 +1,64 @@
+import { describe, expect, it } from 'vitest';
+import { findMentionToken, takeMentionDismissSnapshot } from '$lib/utils';
+
+describe('findMentionToken', () => {
+       it('returns null for an empty/bare cursor', () => {
+               expect(findMentionToken('', 0)).toBeNull();
+               expect(findMentionToken('text', 0)).toBeNull();
+       });
+
+       it('recognizes a mention at the start of the value', () => {
+               expect(findMentionToken('@pr', 3)).toEqual({ start: 0, end: 3, query: 'pr' });
+       });
+
+       it('recognizes a mention after a word boundary', () => {
+               expect(findMentionToken('hello @pr', 9)).toEqual({ start: 6, end: 9, query: 'pr' });
+       });
+
+       it('returns null when the @ is mid-identifier', () => {
+               expect(findMentionToken('em@', 3)).toBeNull();
+               expect(findMentionToken('text@pr', 7)).toBeNull();
+       });
+
+       it('returns null when the cursor is past the whitespace break', () => {
+               expect(findMentionToken('@pr hello', 9)).toBeNull();
+       });
+
+       it('treats boundary characters (parens, brackets, comma) as token starts', () => {
+               expect(findMentionToken('(@pr', 4)).toEqual({ start: 1, end: 4, query: 'pr' });
+               expect(findMentionToken('[@pr', 4)).toEqual({ start: 1, end: 4, query: 'pr' });
+               expect(findMentionToken('a,@pr', 5)).toEqual({ start: 2, end: 5, query: 'pr' });
+       });
+
+       it('does not treat an identifier character as a boundary', () => {
+               expect(findMentionToken('user@abc', 8)).toBeNull();
+       });
+
+       it('extracts the whole token up to the trailing boundary as the query', () => {
+               expect(findMentionToken('@', 1)).toEqual({ start: 0, end: 1, query: '' });
+               expect(findMentionToken('@hello', 6)).toEqual({ start: 0, end: 6, query: 'hello' });
+       });
+
+       it('keeps the whole token as the query when the caret is mid-token', () => {
+               expect(findMentionToken('@hello', 4)).toEqual({ start: 0, end: 6, query: 'hello' });
+               expect(findMentionToken('@hello world', 4)).toEqual({ start: 0, end: 6, query: 'hello' });
+       });
+
+       it('ignores a boundary @ and keeps the most recent token', () => {
+               expect(findMentionToken('a @foo @bar', 11)).toEqual({ start: 7, end: 11, query: 'bar' });
+       });
+});
+
+describe('takeMentionDismissSnapshot', () => {
+       it('returns null when there is no valid mention at the cursor', () => {
+               expect(takeMentionDismissSnapshot('plain text', 5)).toBeNull();
+               expect(takeMentionDismissSnapshot('user@abc', 8)).toBeNull();
+       });
+
+       it('captures start and query of the current mention', () => {
+               expect(takeMentionDismissSnapshot('hello @proj', 11)).toEqual({
+                       start: 6,
+                       query: 'proj'
+               });
+       });
+});
index f7858e29bf2545e17902fb995fdae181388f62e6..7481c154d06e62e80b79cc0c1ef487380ea7b8d4 100644 (file)
@@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest';
 import {
        splitPathQuery,
        buildCaseInsensitiveGlob,
+       buildGlobSearchArgs,
        rankEntries,
        joinPath,
        highlightMatch
 } from '$lib/utils';
+import { GLOB_WILDCARD, PATH_NAV_MAX_DEPTH } from '$lib/constants';
 
 describe('splitPathQuery', () => {
        it('treats a plain query as a home-relative glob (not navigation)', () => {
@@ -124,3 +126,41 @@ describe('highlightMatch', () => {
                expect(highlightMatch('abc', 'z')).toEqual([{ text: 'abc', match: false }]);
        });
 });
+
+describe('buildGlobSearchArgs', () => {
+       const DEPTH = 6;
+
+       it('glob-matches home-relative within the scope path', () => {
+               const args = buildGlobSearchArgs('docs', '/home', DEPTH);
+               expect(args.path).toBe('/home');
+               expect(args.include).toBe(buildCaseInsensitiveGlob('docs'));
+               expect(args.maxDepth).toBe(DEPTH);
+               expect(args.rankQuery).toBe('docs');
+               expect(args.last).toBeUndefined();
+       });
+
+       it('navigates home for a `~` path query', () => {
+               const args = buildGlobSearchArgs('~/proj', '/home', DEPTH);
+               expect(args.path).toBe('~');
+               expect(args.include).toBe(buildCaseInsensitiveGlob('proj'));
+               expect(args.maxDepth).toBe(PATH_NAV_MAX_DEPTH);
+               expect(args.rankQuery).toBe('proj');
+               expect(args.last).toBe('proj');
+       });
+
+       it('lists the scope root when a path query has no last segment', () => {
+               const args = buildGlobSearchArgs('~/', '/home', DEPTH);
+               expect(args.path).toBe('~');
+               expect(args.include).toBe(GLOB_WILDCARD);
+               expect(args.maxDepth).toBe(PATH_NAV_MAX_DEPTH);
+       });
+
+       it('navigates an absolute path under its root', () => {
+               const args = buildGlobSearchArgs('/usr/local/bin', '/home', DEPTH);
+               expect(args.path).toBe('/usr/local');
+               expect(args.include).toBe(buildCaseInsensitiveGlob('bin'));
+               expect(args.maxDepth).toBe(PATH_NAV_MAX_DEPTH);
+               expect(args.rankQuery).toBe('bin');
+               expect(args.last).toBe('bin');
+       });
+});