From: Aleksander Grygier Date: Fri, 7 Aug 2026 16:45:54 +0000 (+0200) Subject: ui: Filesystem `@mentions` for Chat Form (#26715) X-Git-Tag: upstream/0.0.10438~118 X-Git-Url: https://git.djapps.eu/?a=commitdiff_plain;h=23634783c541ed49e0adfd0c64c452d77d1051ed;p=pkg%2Fggml%2Fsources%2Fllama.cpp ui: Filesystem `@mentions` for Chat Form (#26715) * 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 --- diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte index 105e414fe..22406e072 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte @@ -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, @@ -39,8 +38,23 @@ 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, @@ -108,11 +122,18 @@ 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 `@` stays literal until edited. + let mentionDismissedSnapshot: MentionDismissSnapshot | null = null; let cwd = $derived(activeConversation()?.cwd ?? pendingCwd()); @@ -219,26 +240,44 @@ 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 = ''; } } @@ -247,15 +286,30 @@ 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; } @@ -432,33 +486,33 @@ 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:///)` link in place of the `@` + // 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() { @@ -505,17 +559,25 @@ 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} /> + +
+ 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([]); + let searchError = $state(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); + } + + + { + if (!open) onClose(); + }} +> + + + + 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 + ]} + > + entry.type + ':' + entry.path} + scrollTrigger={nav.scrollTrigger} + > + {#snippet item(entry, index, isSelected)} + handleSelect(entry)} + onmouseenter={() => nav.setHover(index)} + > + {@const Icon = entry.type === FileMentionEntryType.DIRECTORY ? Folder : File} + +
+
+ {#if showTooltip} + + + {#snippet child({ props })} + {entry.name} + {/snippet} + + +

{entry.path}

+
+
+ {:else} + {entry.name} + {/if} + + {entry.type} + +
+ + + +
+
+ {/snippet} +
+
+
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte index 6647928b2..04b85b24b 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte @@ -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 { @@ -11,11 +12,19 @@ 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 { @@ -25,49 +34,69 @@ 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(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' }); {#if showSearchInput}
- +
{/if} -
+
{#if isLoading} {#if skeleton} {@render skeleton()} + {:else} +
+ {#each { length: skeletonCount } as _, rowIndex (rowIndex)} +
+
+
+
+
+
+
+ {/each} +
+ {/if} + {:else if items && items.length === 0} + {#if emptyMessage} +
{emptyMessage}
{/if} - {:else if items.length === 0} -
{emptyMessage}
{:else} {#each items as itemData, index (itemKey(itemData, index))} {@render item(itemData, index, index === selectedIndex)} diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte index 4d82c6b58..0fb0ea0c0 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte @@ -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(); diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte index c43a002e6..24ea8619f 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte @@ -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 index ed97e1fc7..000000000 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte +++ /dev/null @@ -1,237 +0,0 @@ - - - - resource.serverName + ':' + resource.uri} - > - {#snippet item(resource, index, isSelected)} - {@const server = serverSettingsMap.get(resource.serverName)} - {@const serverLabel = server ? mcpStore.getServerLabel(server) : resource.serverName} - - handleResourceClick(resource)} - > - - {#snippet titleExtra()} - {#if isResourceAttached(resource.uri)} - - attached - - {/if} - {/snippet} - - {#snippet subtitle()} -

- {resource.uri} -

- {/snippet} -
-
- {/snippet} - - {#snippet skeleton()} - - {/snippet} - - {#snippet footer()} - {#if onBrowse && resources.length > 3} - - {/if} - {/snippet} -
-
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte index 7c5dc85b2..3fc29f177 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte @@ -1,16 +1,19 @@
diff --git a/tools/ui/src/lib/components/app/chat/index.ts b/tools/ui/src/lib/components/app/chat/index.ts index 57108f306..19d26cb09 100644 --- a/tools/ui/src/lib/components/app/chat/index.ts +++ b/tools/ui/src/lib/components/app/chat/index.ts @@ -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 `@` 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:///)` 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'; diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte index fc7e31412..be6041b63 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte @@ -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'; @@ -174,6 +175,7 @@ }) // Add syntax highlighting .use(rehypeRestoreTableHtml) // Restore limited HTML (e.g.,
,
    ) 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
     			.use(rehypeSvgPre) // Convert svg blocks to 
     			.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
    index 000000000..4459e2ba7
    --- /dev/null
    +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/file-badge.ts
    @@ -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 `` (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
    index 000000000..880d21a7e
    --- /dev/null
    +++ b/tools/ui/src/lib/components/app/forms/HighlightedMatch.svelte
    @@ -0,0 +1,25 @@
    +
    +
    +{#each segments as seg, i (i)}
    +	{#if seg.match}
    +		{seg.text}
    +	{:else}
    +		{seg.text}
    +	{/if}
    +{/each}
    diff --git a/tools/ui/src/lib/components/app/forms/index.ts b/tools/ui/src/lib/components/app/forms/index.ts
    index 4cf56cdc9..87594d7e3 100644
    --- a/tools/ui/src/lib/components/app/forms/index.ts
    +++ b/tools/ui/src/lib/components/app/forms/index.ts
    @@ -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 ``.
    + */
    +export { default as HighlightedMatch } from './HighlightedMatch.svelte';
    diff --git a/tools/ui/src/lib/constants/chat-form.ts b/tools/ui/src/lib/constants/chat-form.ts
    index 05ab8c1f8..6c413ed70 100644
    --- a/tools/ui/src/lib/constants/chat-form.ts
    +++ b/tools/ui/src/lib/constants/chat-form.ts
    @@ -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__';
    diff --git a/tools/ui/src/lib/constants/index.ts b/tools/ui/src/lib/constants/index.ts
    index 1f5ec7f0b..2fcd39ab2 100644
    --- a/tools/ui/src/lib/constants/index.ts
    +++ b/tools/ui/src/lib/constants/index.ts
    @@ -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
    index 000000000..b0026eb33
    --- /dev/null
    +++ b/tools/ui/src/lib/constants/mention-badge.ts
    @@ -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 `` `properties`.
    + */
    +export const MENTION_BADGE_SVG_ATTRIBUTES: Readonly> = {
    +	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
    + * `` child of the wrapper ``. 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'
    +];
    diff --git a/tools/ui/src/lib/constants/settings-keys.ts b/tools/ui/src/lib/constants/settings-keys.ts
    index 265507a5b..db4581201 100644
    --- a/tools/ui/src/lib/constants/settings-keys.ts
    +++ b/tools/ui/src/lib/constants/settings-keys.ts
    @@ -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',
    diff --git a/tools/ui/src/lib/constants/settings-registry.ts b/tools/ui/src/lib/constants/settings-registry.ts
    index 19eaef617..9704413ba 100644
    --- a/tools/ui/src/lib/constants/settings-registry.ts
    +++ b/tools/ui/src/lib/constants/settings-registry.ts
    @@ -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 = {
     				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 = {
     				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
     			}
     		]
     	},
    diff --git a/tools/ui/src/lib/constants/working-directory.ts b/tools/ui/src/lib/constants/working-directory.ts
    index 8f4f4fade..2811c1645 100644
    --- a/tools/ui/src/lib/constants/working-directory.ts
    +++ b/tools/ui/src/lib/constants/working-directory.ts
    @@ -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;
    diff --git a/tools/ui/src/lib/enums/chat.enums.ts b/tools/ui/src/lib/enums/chat.enums.ts
    index f4994bb8e..17a2a4535 100644
    --- a/tools/ui/src/lib/enums/chat.enums.ts
    +++ b/tools/ui/src/lib/enums/chat.enums.ts
    @@ -78,3 +78,8 @@ export enum PdfViewMode {
     	TEXT = 'text',
     	PAGES = 'pages'
     }
    +
    +export enum FileMentionEntryType {
    +	FILE = 'file',
    +	DIRECTORY = 'directory'
    +}
    diff --git a/tools/ui/src/lib/enums/index.ts b/tools/ui/src/lib/enums/index.ts
    index 4da9e6ac8..7323563f3 100644
    --- a/tools/ui/src/lib/enums/index.ts
    +++ b/tools/ui/src/lib/enums/index.ts
    @@ -24,7 +24,8 @@ export {
     	MessageRole,
     	MessageType,
     	PdfViewMode,
    -	ReasoningFormat
    +	ReasoningFormat,
    +	FileMentionEntryType
     } from './chat.enums';
     
     export { SessionRecordType } from './conversation-import.enums';
    diff --git a/tools/ui/src/lib/enums/keyboard.enums.ts b/tools/ui/src/lib/enums/keyboard.enums.ts
    index 735d3e4b4..918bc57c5 100644
    --- a/tools/ui/src/lib/enums/keyboard.enums.ts
    +++ b/tools/ui/src/lib/enums/keyboard.enums.ts
    @@ -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
    index 000000000..cb838ff3d
    --- /dev/null
    +++ b/tools/ui/src/lib/hooks/use-debounced-search.svelte.ts
    @@ -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;
    +}
    +
    +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;
    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
    index 000000000..08a677ccf
    --- /dev/null
    +++ b/tools/ui/src/lib/hooks/use-picker-navigation.svelte.ts
    @@ -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;
    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
    index 000000000..9f44b7b8f
    --- /dev/null
    +++ b/tools/ui/src/lib/hooks/use-scroll-active-row.svelte.ts
    @@ -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;
    diff --git a/tools/ui/src/lib/types/chat.d.ts b/tools/ui/src/lib/types/chat.d.ts
    index 1b2c18b2c..2c8844c51 100644
    --- a/tools/ui/src/lib/types/chat.d.ts
    +++ b/tools/ui/src/lib/types/chat.d.ts
    @@ -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;
    +}
    diff --git a/tools/ui/src/lib/types/index.ts b/tools/ui/src/lib/types/index.ts
    index 408ac0cbd..6cd000a62 100644
    --- a/tools/ui/src/lib/types/index.ts
    +++ b/tools/ui/src/lib/types/index.ts
    @@ -53,7 +53,8 @@ export type {
     	LiveProcessingStats,
     	LiveGenerationStats,
     	AttachmentDisplayItemsOptions,
    -	FileProcessingResult
    +	FileProcessingResult,
    +	FileMentionEntry
     } from './chat.d';
     
     // Database types
    diff --git a/tools/ui/src/lib/types/settings.d.ts b/tools/ui/src/lib/types/settings.d.ts
    index c38665de7..fbad500f9 100644
    --- a/tools/ui/src/lib/types/settings.d.ts
    +++ b/tools/ui/src/lib/types/settings.d.ts
    @@ -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
    index 000000000..66f7a8f6a
    --- /dev/null
    +++ b/tools/ui/src/lib/utils/glob-search.ts
    @@ -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();
    +
    +export interface GlobSearchResult {
    +	base: string;
    +	entries: GlobEntry[];
    +	error?: string;
    +}
    +
    +export async function runGlobSearch(
    +	args: GlobSearchArgs,
    +	type: GlobSearchType,
    +	limit: number,
    +	signal: AbortSignal
    +): Promise {
    +	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 {
    +	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 };
    +}
    diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts
    index 4c0257639..afcf26d35 100644
    --- a/tools/ui/src/lib/utils/index.ts
    +++ b/tools/ui/src/lib/utils/index.ts
    @@ -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
    index 000000000..c90f1f98e
    --- /dev/null
    +++ b/tools/ui/src/lib/utils/mention-badge.ts
    @@ -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
    index 000000000..be2614360
    --- /dev/null
    +++ b/tools/ui/src/lib/utils/mention-token.ts
    @@ -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 };
    +}
    diff --git a/tools/ui/src/lib/utils/path-display.ts b/tools/ui/src/lib/utils/path-display.ts
    index 4663e6df8..05820194c 100644
    --- a/tools/ui/src/lib/utils/path-display.ts
    +++ b/tools/ui/src/lib/utils/path-display.ts
    @@ -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();
    diff --git a/tools/ui/src/lib/utils/working-directory.ts b/tools/ui/src/lib/utils/working-directory.ts
    index 55fa6ef27..175a4f9f9 100644
    --- a/tools/ui/src/lib/utils/working-directory.ts
    +++ b/tools/ui/src/lib/utils/working-directory.ts
    @@ -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
    index 000000000..87746979f
    --- /dev/null
    +++ b/tools/ui/tests/client/components/PickerListScrollHarness.svelte
    @@ -0,0 +1,43 @@
    +
    +
    +
    conversation
    + +{#if open} +
    + it.id} + > + {#snippet item(it, index, isSelected)} + {}}> + {it.label} + + {/snippet} + +
    +{/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 index 000000000..1ce019985 --- /dev/null +++ b/tools/ui/tests/client/picker-list-scroll.svelte.test.ts @@ -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 index 000000000..46b7caa4c --- /dev/null +++ b/tools/ui/tests/unit/glob-search-children.test.ts @@ -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 index 000000000..03a0d5ba4 --- /dev/null +++ b/tools/ui/tests/unit/mention-badge.test.ts @@ -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 index 000000000..fd21a94db --- /dev/null +++ b/tools/ui/tests/unit/mention-token.test.ts @@ -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' + }); + }); +}); diff --git a/tools/ui/tests/unit/working-directory.test.ts b/tools/ui/tests/unit/working-directory.test.ts index f7858e29b..7481c154d 100644 --- a/tools/ui/tests/unit/working-directory.test.ts +++ b/tools/ui/tests/unit/working-directory.test.ts @@ -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'); + }); +});