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'
--- /dev/null
+<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>
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)}
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>
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()}
>
+++ /dev/null
-<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>
<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.
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 ?? (() => {})}
/>
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}">
* 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';
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';
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
--- /dev/null
+/**
+ * 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
+ )
+ }
+ ]
+ }
+ ];
+ });
+ };
+};
--- /dev/null
+<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}
* 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';
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__';
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';
--- /dev/null
+/**
+ * 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'
+];
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',
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';
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
}
]
},
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
}
]
},
// 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;
TEXT = 'text',
PAGES = 'pages'
}
+
+export enum FileMentionEntryType {
+ FILE = 'file',
+ DIRECTORY = 'directory'
+}
MessageRole,
MessageType,
PdfViewMode,
- ReasoningFormat
+ ReasoningFormat,
+ FileMentionEntryType
} from './chat.enums';
export { SessionRecordType } from './conversation-import.enums';
ARROW_DOWN = 'ArrowDown',
ARROW_LEFT = 'ArrowLeft',
ARROW_RIGHT = 'ArrowRight',
+ BACKSPACE = 'Backspace',
TAB = 'Tab',
B_LOWER = 'b',
D_LOWER = 'd',
--- /dev/null
+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>;
--- /dev/null
+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>;
--- /dev/null
+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>;
-import type { ErrorDialogType } from '$lib/enums';
+import type { ErrorDialogType, FileMentionEntryType } from '$lib/enums';
import type { ApiChatCompletionToolCall } from './api';
import type { DatabaseMessage, DatabaseMessageExtra } from './database';
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;
+}
LiveProcessingStats,
LiveGenerationStats,
AttachmentDisplayItemsOptions,
- FileProcessingResult
+ FileProcessingResult,
+ FileMentionEntry
} from './chat.d';
// Database types
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;
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 }>;
--- /dev/null
+/**
+ * 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 };
+}
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,
--- /dev/null
+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 };
+}
--- /dev/null
+// 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 };
+}
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
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;
}
/**
- * 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);
}
/**
- * 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();
/**
- * 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';
GLOB_WILDCARD,
HOME_TILDE,
LEADING_SLASHES_REGEX,
+ PATH_NAV_MAX_DEPTH,
UNC_ROOT_REGEX,
WINDOWS_SEPARATOR
} from '$lib/constants';
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;
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) {
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;
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) =>
);
}
-/** 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 }[] = [];
--- /dev/null
+<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}
--- /dev/null
+// 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);
+ });
+});
--- /dev/null
+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);
+ });
+});
--- /dev/null
+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
+ });
+ });
+});
--- /dev/null
+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'
+ });
+ });
+});
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)', () => {
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');
+ });
+});