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
This commit is contained in:
Aleksander Grygier
2026-08-07 18:45:54 +02:00
committed by GitHub
parent 4cb22cd537
commit 23634783c5
40 changed files with 1839 additions and 399 deletions
@@ -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 `@<query>` 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 handleMentionPickerClose() {
if (isMentionPickerOpen) {
const cursor = textareaRef?.getCaretOffset() ?? value.length;
mentionDismissedSnapshot = takeMentionDismissSnapshot(value, cursor);
}
isMentionPickerOpen = false;
mentionQuery = '';
refocusInput();
}
function handleInlineResourceSelect() {
if (value.startsWith(RESOURCE_TRIGGER_PREFIX)) {
value = '';
onValueChange?.('');
}
// 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;
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
textareaRef?.focus();
}
const built = buildMentionInsertion(entry, value, token);
if (!built) return;
function handleBrowseResources() {
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
value = built.newValue;
onValueChange?.(built.newValue);
if (value.startsWith(RESOURCE_TRIGGER_PREFIX)) {
value = '';
onValueChange?.('');
}
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}
/>
<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'
@@ -0,0 +1,258 @@
<script lang="ts">
import { File, Folder } from '@lucide/svelte';
import { abbreviateHome, runGlobSearchWithChildren, type GlobEntryResult } from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
import { BuiltInTool, FileMentionEntryType, GlobSearchType } from '$lib/enums';
import { isMobile } from '$lib/stores/viewport.svelte';
import { config } from '$lib/stores/settings.svelte';
import * as Popover from '$lib/components/ui/popover';
import * as Tooltip from '$lib/components/ui/tooltip';
import HighlightedMatch from '$lib/components/app/forms/HighlightedMatch.svelte';
import { ChatFormPickerList, ChatFormPickerListItem } from '$lib/components/app/chat';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import type { FileMentionEntry } from '$lib/types';
import {
FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH,
HOME_TILDE,
SEARCH_DEBOUNCE_MS
} from '$lib/constants';
/**
* Floating file/folder mention picker. The chat input is the search
* surface: `query` (typed after `@`) drives a `file_glob_search` tool
* call scoped to `scopePath`. The parent owns the "dismissed token,
* don't re-open until it changes" snapshot.
*/
interface Props {
class?: string;
isOpen: boolean;
query: string;
customAnchor?: HTMLElement | null;
scopePath?: string | null;
onClose: () => void;
onSelect: (entry: FileMentionEntry) => void;
/** Fired when `isOpen` becomes true, so the host can keep focus on the chat input. */
onOpened?: () => void;
}
let {
class: className = '',
isOpen,
query,
customAnchor = null,
scopePath = null,
onClose,
onSelect,
onOpened
}: Props = $props();
const nav = usePickerNavigation({
isOpen: () => isOpen,
count: () => displayedItems.length,
onClose: () => onClose(),
onSelect: (index) => handleSelect(displayedItems[index])
});
// When the server does not expose file_glob_search (started without
// --tools) or the user disabled it, the picker still opens but explains
// why instead of firing searches that would only fail.
const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.FILE_GLOB_SEARCH));
const fileSearchEnabled = $derived(
fileSearchKey !== null && toolsStore.isToolEnabled(fileSearchKey)
);
let searchResults = $state<FileMentionEntry[]>([]);
let searchError = $state<string | null>(null);
// Coerce the depth setting to a positive integer; an invalid value
// would otherwise reach the server as max_depth 0 = unlimited.
const searchDepth = $derived.by(() => {
const n = Number(config().mentionSearchMaxDepth);
return Number.isInteger(n) && n > 0 ? n : FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH;
});
const home = $derived(toolsStore.serverHome);
// A smaller window than the WD picker suffices: entries are ranked client-side.
const MENTION_SEARCH_LIMIT = 50;
const search = useDebouncedSearch({
debounceMs: SEARCH_DEBOUNCE_MS,
canRun: () => isOpen && fileSearchEnabled,
getQuery: () => trimmedQuery,
run: async (query, signal, isCurrent) => {
try {
// A trailing path separator targets a directory, so also list its
// children. Accept both `/` and `\`.
const res = await runGlobSearchWithChildren(
query,
scopePath ?? home ?? HOME_TILDE,
searchDepth,
MENTION_SEARCH_LIMIT,
signal,
{ type: GlobSearchType.ALL, descendOnTrailingSeparator: true }
);
if (!isCurrent()) return;
if (res.error) {
searchResults = [];
searchError = res.error;
return;
}
const toEntry = (e: GlobEntryResult): FileMentionEntry => ({
path: e.path,
name: e.name,
type: e.type === 'dir' ? FileMentionEntryType.DIRECTORY : FileMentionEntryType.FILE
});
searchResults = res.entries.map(toEntry);
searchError = null;
} catch (err) {
if (!isCurrent() || signal.aborted) return;
searchResults = [];
searchError = err instanceof Error ? err.message : String(err);
}
}
});
const trimmedQuery = $derived((query ?? '').trim());
const displayedItems = $derived(searchResults);
const emptyMessage = $derived.by(() => {
if (fileSearchKey === null) {
return 'File search is unavailable on this server (started without --tools)';
}
if (!fileSearchEnabled) {
return 'File search is disabled - enable "Search files" in Settings > Tools to use @-mentions';
}
return searchError ? `Search failed - ${searchError}` : 'No matching files or folders';
});
const showTooltip = $derived(!isMobile.current);
$effect(() => {
if (typeof window === 'undefined') return;
void toolsStore.resolveServerHome();
});
$effect(() => {
if (isOpen) {
nav.reset(0);
}
});
$effect(() => {
if (isOpen) onOpened?.();
});
$effect(() => {
const q = (query ?? '').trim();
if (!isOpen || !q || !fileSearchEnabled) {
search.cancel();
searchResults = [];
searchError = null;
return;
}
search.setLoading(true);
search.run(q);
});
function handleSelect(entry: FileMentionEntry) {
onSelect(entry);
onClose();
}
export function handleKeydown(event: KeyboardEvent): boolean {
return nav.handleKeydown(event);
}
</script>
<Popover.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<!-- Invisible form-wide trigger: stops bits-ui's outside-click detector
from closing the picker when the user clicks inside the textarea.
We open programmatically via `open={isOpen}`, so it is inert
(tabindex=-1 + pointer-events-none + opacity-0 + aria-hidden).
Positioning comes from `customAnchor` at the form's top edge. -->
<Popover.Trigger
class="pointer-events-none absolute inset-0 opacity-0"
tabindex={-1}
aria-hidden="true"
>
<span class="sr-only">Open file mention picker</span>
</Popover.Trigger>
<Popover.Content
align="start"
side="top"
sideOffset={12}
{customAnchor}
preventScroll={false}
onkeydown={handleKeydown}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
class={[
'w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl',
className
]}
>
<ChatFormPickerList
items={displayedItems}
isLoading={search.isSearching}
selectedIndex={nav.hoveredIndex}
showSearchInput={false}
searchQuery={query ?? ''}
{emptyMessage}
itemKey={(entry) => entry.type + ':' + entry.path}
scrollTrigger={nav.scrollTrigger}
>
{#snippet item(entry, index, isSelected)}
<ChatFormPickerListItem
dataIndex={index}
{isSelected}
onclick={() => handleSelect(entry)}
onmouseenter={() => nav.setHover(index)}
>
{@const Icon = entry.type === FileMentionEntryType.DIRECTORY ? Folder : File}
<Icon
class={[
'mt-0.5 h-4 w-4 shrink-0',
entry.type === FileMentionEntryType.DIRECTORY
? 'text-amber-500'
: 'text-muted-foreground'
]}
/>
<div class="flex min-w-0 flex-1 flex-col">
<div class="flex min-w-0 items-center gap-2">
{#if showTooltip}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<span {...props} class="truncate text-sm font-medium">{entry.name}</span>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{entry.path}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<span class="truncate text-sm font-medium">{entry.name}</span>
{/if}
<span
class="shrink-0 rounded-full bg-muted px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-muted-foreground"
>
{entry.type}
</span>
</div>
<span class="min-w-0 flex-1 truncate font-mono text-left text-xs">
<HighlightedMatch text={abbreviateHome(entry.path, home)} query={trimmedQuery} />
</span>
</div>
</ChatFormPickerListItem>
{/snippet}
</ChatFormPickerList>
</Popover.Content>
</Popover.Root>
@@ -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<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)}
@@ -3,21 +3,34 @@
interface Props {
isSelected?: boolean;
disabled?: boolean;
onclick: () => void;
onmouseenter?: () => void;
dataIndex?: number;
children: Snippet;
class?: string;
}
let { isSelected = false, onclick, dataIndex, children }: Props = $props();
let {
class: className = '',
isSelected = false,
disabled = false,
onclick,
onmouseenter,
dataIndex,
children
}: Props = $props();
</script>
<button
type="button"
data-picker-index={dataIndex}
{disabled}
{onclick}
{onmouseenter}
class="flex w-full cursor-pointer items-start gap-3 rounded-lg px-3 py-2 text-left hover:bg-accent/50 {isSelected
? 'bg-accent/50'
: ''}"
: ''} {disabled ? 'cursor-not-allowed opacity-50' : ''} {className}"
>
{@render children()}
</button>
@@ -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()}
>
@@ -1,237 +0,0 @@
<script lang="ts">
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
import { KeyboardKey } from '$lib/enums';
import type { MCPResourceInfo, MCPServerSettingsEntry } from '$lib/types';
import { SvelteMap } from 'svelte/reactivity';
import { FolderOpen } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import {
ChatFormPickerPopover,
ChatFormPickerList,
ChatFormPickerListItem,
ChatFormPickerItemHeader,
ChatFormPickerListItemSkeleton
} from '$lib/components/app/chat';
interface Props {
class?: string;
isOpen?: boolean;
searchQuery?: string;
onClose?: () => void;
onResourceSelect?: (resource: MCPResourceInfo) => void;
onBrowse?: () => void;
}
let {
class: className = '',
isOpen = false,
searchQuery = '',
onClose,
onResourceSelect,
onBrowse
}: Props = $props();
let resources = $state<MCPResourceInfo[]>([]);
let isLoading = $state(false);
let selectedIndex = $state(0);
let internalSearchQuery = $state('');
let serverSettingsMap = $derived.by(() => {
const servers = mcpStore.getServers();
const map = new SvelteMap<string, MCPServerSettingsEntry>();
for (const server of servers) {
map.set(server.id, server);
}
return map;
});
$effect(() => {
if (isOpen) {
loadResources();
selectedIndex = 0;
}
});
$effect(() => {
if (filteredResources.length > 0 && selectedIndex >= filteredResources.length) {
selectedIndex = 0;
}
});
async function loadResources() {
isLoading = true;
try {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
if (!initialized) {
resources = [];
return;
}
await mcpStore.fetchAllResources();
resources = mcpResourceStore.getAllResourceInfos();
} catch (error) {
console.error('[ChatFormPickerMcpResources] Failed to load resources:', error);
resources = [];
} finally {
isLoading = false;
}
}
function handleResourceClick(resource: MCPResourceInfo) {
mcpStore.attachResource(resource.uri);
onResourceSelect?.(resource);
onClose?.();
}
function isResourceAttached(uri: string): boolean {
return mcpResourceStore.isAttached(uri);
}
export function handleKeydown(event: KeyboardEvent): boolean {
if (!isOpen) return false;
if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
onClose?.();
return true;
}
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
if (filteredResources.length > 0) {
selectedIndex = (selectedIndex + 1) % filteredResources.length;
}
return true;
}
if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
if (filteredResources.length > 0) {
selectedIndex = selectedIndex === 0 ? filteredResources.length - 1 : selectedIndex - 1;
}
return true;
}
if (event.key === KeyboardKey.ENTER) {
event.preventDefault();
if (filteredResources[selectedIndex]) {
handleResourceClick(filteredResources[selectedIndex]);
}
return true;
}
return false;
}
let filteredResources = $derived.by(() => {
const sortedServers = mcpStore.getServers();
const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
const sortedResources = [...resources].sort((a, b) => {
const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER;
const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER;
return orderA - orderB;
});
const query = (searchQuery || internalSearchQuery).toLowerCase();
if (!query) return sortedResources;
return sortedResources.filter(
(resource) =>
resource.name.toLowerCase().includes(query) ||
resource.title?.toLowerCase().includes(query) ||
resource.description?.toLowerCase().includes(query) ||
resource.uri.toLowerCase().includes(query)
);
});
let showSearchInput = $derived(resources.length > 3);
</script>
<ChatFormPickerPopover
bind:isOpen
class={className}
srLabel="Open resource picker"
{onClose}
onKeydown={handleKeydown}
>
<ChatFormPickerList
items={filteredResources}
{isLoading}
{selectedIndex}
bind:searchQuery={internalSearchQuery}
{showSearchInput}
searchPlaceholder="Search resources..."
emptyMessage="No MCP resources available"
itemKey={(resource) => resource.serverName + ':' + resource.uri}
>
{#snippet item(resource, index, isSelected)}
{@const server = serverSettingsMap.get(resource.serverName)}
{@const serverLabel = server ? mcpStore.getServerLabel(server) : resource.serverName}
<ChatFormPickerListItem
dataIndex={index}
{isSelected}
onclick={() => handleResourceClick(resource)}
>
<ChatFormPickerItemHeader
{server}
{serverLabel}
title={resource.title || resource.name}
description={resource.description}
>
{#snippet titleExtra()}
{#if isResourceAttached(resource.uri)}
<span
class="inline-flex items-center rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary"
>
attached
</span>
{/if}
{/snippet}
{#snippet subtitle()}
<p class="mt-0.5 truncate text-xs text-muted-foreground/60">
{resource.uri}
</p>
{/snippet}
</ChatFormPickerItemHeader>
</ChatFormPickerListItem>
{/snippet}
{#snippet skeleton()}
<ChatFormPickerListItemSkeleton />
{/snippet}
{#snippet footer()}
{#if onBrowse && resources.length > 3}
<Button
class="fixed right-3 bottom-3"
type="button"
onclick={onBrowse}
variant="secondary"
size="sm"
>
<FolderOpen class="h-3 w-3" />
Browse all
</Button>
{/if}
{/snippet}
</ChatFormPickerList>
</ChatFormPickerPopover>
@@ -1,16 +1,19 @@
<script lang="ts">
import ChatFormMentionPicker from './ChatFormMentionPicker.svelte';
import ChatFormPickerMcpPrompts from './ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte';
import ChatFormPickerMcpResources from './ChatFormPickerMcpResources.svelte';
import type { GetPromptResult, MCPPromptInfo } from '$lib/types';
import type { FileMentionEntry, GetPromptResult, MCPPromptInfo } from '$lib/types';
interface Props {
isPromptPickerOpen?: boolean;
promptSearchQuery?: string;
isInlineResourcePickerOpen?: boolean;
resourceSearchQuery?: string;
isMentionPickerOpen?: boolean;
mentionQuery?: string;
mentionAnchor?: HTMLElement | null;
scopePath?: string | null;
onPromptPickerClose?: () => void;
onInlineResourcePickerClose?: () => void;
onInlineResourceSelect?: () => void;
onMentionPickerClose?: () => void;
onMentionOpened?: () => void;
onMentionSelect?: (entry: FileMentionEntry) => void;
onPromptLoadStart?: (
placeholderId: string,
promptInfo: MCPPromptInfo,
@@ -18,25 +21,26 @@
) => void;
onPromptLoadComplete?: (placeholderId: string, result: GetPromptResult) => void;
onPromptLoadError?: (placeholderId: string, error: string) => void;
onInlineResourceBrowse?: () => void;
}
let {
isPromptPickerOpen,
promptSearchQuery,
isInlineResourcePickerOpen,
resourceSearchQuery,
isMentionPickerOpen,
mentionQuery,
mentionAnchor,
scopePath,
onPromptPickerClose,
onInlineResourcePickerClose,
onInlineResourceSelect,
onMentionPickerClose,
onMentionOpened,
onMentionSelect,
onPromptLoadStart,
onPromptLoadComplete,
onPromptLoadError,
onInlineResourceBrowse
onPromptLoadError
}: Props = $props();
let promptPickerRef: ChatFormPickerMcpPrompts | undefined = $state(undefined);
let resourcePickerRef: ChatFormPickerMcpResources | undefined = $state(undefined);
let mentionPickerRef: ChatFormMentionPicker | undefined = $state(undefined);
/**
* Delegates keyboard events to the active picker child.
@@ -47,7 +51,7 @@
return true;
}
if (isInlineResourcePickerOpen && resourcePickerRef?.handleKeydown(event)) {
if (isMentionPickerOpen && mentionPickerRef?.handleKeydown(event)) {
return true;
}
@@ -65,11 +69,13 @@
{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 ?? (() => {})}
/>
@@ -48,6 +48,16 @@
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}">
+9 -20
View File
@@ -351,14 +351,14 @@ export { default as ChatFormPickerPopover } from './ChatForm/ChatFormPickers/Cha
* Generic scrollable list for picker popovers. Provides search input,
* scroll-into-view for keyboard navigation, loading skeletons, empty state,
* and optional footer. Uses Svelte 5 snippets for item/skeleton/footer rendering.
* Shared by ChatFormPickerMcpPrompts and ChatFormPickerMcpResources.
* Shared by ChatFormPickerMcpPrompts and ChatFormMentionPicker.
*/
export { default as ChatFormPickerList } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte';
/**
* Generic button wrapper for picker list items. Provides consistent styling,
* hover/selected states, and data-picker-index attribute for scroll-into-view.
* Shared by ChatFormPickerMcpPrompts and ChatFormPickerMcpResources.
* Shared by ChatFormPickerMcpPrompts and ChatFormMentionPicker.
*/
export { default as ChatFormPickerListItem } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte';
@@ -376,30 +376,19 @@ export { default as ChatFormPickerItemHeader } from './ChatForm/ChatFormPickers/
export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte';
/**
* **ChatFormPickerMcpResources** - MCP resource selection interface
*
* Floating picker for browsing and attaching MCP Server Resources.
* Triggered by typing `@` in the chat input.
* Loads resources from connected MCP servers and allows users to attach them to the chat context.
*
* **Features:**
* - Search/filter resources by name, title, description, or URI across all connected servers
* - Keyboard navigation (↑/↓ to navigate, Enter to select, Esc to close)
* - Shows attached state for already-attached resources
* - Loading states with skeleton placeholders
* - Server information header per resource for visual identification
*
* **Exported API:**
* - `handleKeydown(event): boolean` - Process keyboard events, returns true if handled
* `@`-triggered file/folder mention picker. Resolves `@<query>` in the chat
* input to a filesystem match via the server's `file_glob_search` built-in
* tool, scoped to the conversation cwd (or server home when unset).
* Selection splices a `[name](file:///<abs path>)` link into the input.
*/
export { default as ChatFormPickerMcpResources } from './ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte';
export { default as ChatFormMentionPicker } from './ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte';
/**
* **ChatFormPickers** - Chat input picker container
*
* Container component that hosts both MCP prompt and MCP resource pickers.
* Container component that hosts the MCP prompt and file mention pickers.
* Manages shared state, keyboard navigation, and coordination between the two
* picker interfaces. Used within ChatForm for `@`-triggered pickers.
* picker interfaces. Used within ChatForm.
*/
export { default as ChatFormPickers } from './ChatForm/ChatFormPickers/ChatFormPickers.svelte';
@@ -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., <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
@@ -0,0 +1,100 @@
/**
* Rehype plugin that rewrites `file://` markdown anchors into the inline
* @-mention chip, reusing the visual contract from
* `$lib/constants/mention-badge`.
*
* The chip is presentational: `file://` navigation is blocked from
* http(s) pages, so the anchor becomes a plain `<span>` (no link role,
* no tab stop); the full path stays available on `title`.
*/
import { decodeFileLinkPath, getMentionBadgeIconPaths, getMentionBadgeLabel } from '$lib/utils';
import {
FILE_URI_PREFIX,
MENTION_BADGE_CLASSNAME,
MENTION_BADGE_ICON_CLASSNAME,
MENTION_BADGE_SVG_ATTRIBUTES,
PATH_SEPARATOR,
SETTINGS_KEYS
} from '$lib/constants';
import { settingsStore } from '$lib/stores/settings.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import type { Plugin } from 'unified';
import type { Root, Element } from 'hast';
import { visit } from 'unist-util-visit';
// Trailing path separators mark a directory and are kept out of the label.
const TRAILING_SEPARATOR_REGEX = /\/+$/;
function decodeHrefPath(href: string): string {
const stripped = href.startsWith(FILE_URI_PREFIX) ? href.slice(FILE_URI_PREFIX.length) : href;
return decodeFileLinkPath(stripped);
}
function labelFromFileUrl(href: string): string {
const decoded = decodeHrefPath(href);
const trimmed = decoded.replace(TRAILING_SEPARATOR_REGEX, '');
const slash = trimmed.lastIndexOf(PATH_SEPARATOR);
return slash === -1 ? trimmed : trimmed.slice(slash + 1);
}
// A trailing `/` in the target marks a directory and selects the folder
// icon, matching the convention the mention picker inserts with.
function iconElement(href: string): Element {
return {
type: 'element',
tagName: 'svg',
properties: {
...MENTION_BADGE_SVG_ATTRIBUTES,
className: MENTION_BADGE_ICON_CLASSNAME.split(' ').filter(Boolean)
},
children: getMentionBadgeIconPaths(href).map((d) => ({
type: 'element',
tagName: 'path',
properties: { d },
children: []
}))
};
}
export const rehypeFileBadge: Plugin<[], Root> = () => {
return (tree: Root) => {
visit(tree, 'element', (node: Element) => {
if (node.tagName !== 'a') return;
const props = node.properties ?? {};
const href = typeof props.href === 'string' ? props.href : null;
if (!href || !href.startsWith(FILE_URI_PREFIX)) return;
const label = labelFromFileUrl(href);
const titleAttr = typeof props.title === 'string' ? props.title : href;
const decodedPath = decodeHrefPath(href);
node.tagName = 'span';
node.properties = {
className: MENTION_BADGE_CLASSNAME.split(' ').filter(Boolean),
title: titleAttr.startsWith(FILE_URI_PREFIX) ? decodedPath : titleAttr
};
node.children = [
iconElement(href),
{
type: 'element',
tagName: 'span',
properties: { className: ['shrink-0', 'truncate'] },
children: [
{
type: 'text',
value: getMentionBadgeLabel(
label,
decodedPath,
settingsStore.getConfig(SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS),
toolsStore.serverHome
)
}
]
}
];
});
};
};
@@ -0,0 +1,25 @@
<script lang="ts">
import { highlightMatch } from '$lib/utils';
interface Props {
text: string;
query: string;
matchClass?: string;
}
let {
text,
query,
matchClass = 'rounded bg-yellow-200/60 px-0.5 text-foreground dark:bg-yellow-500/30'
}: Props = $props();
let segments = $derived(highlightMatch(text, query));
</script>
{#each segments as seg, i (i)}
{#if seg.match}
<mark class={matchClass}>{seg.text}</mark>
{:else}
{seg.text}
{/if}
{/each}
@@ -42,3 +42,11 @@ export { default as KeyValuePairs } from './KeyValuePairs.svelte';
* Supports placeholder, autofocus, and change callbacks.
*/
export { default as SearchInput } from './SearchInput.svelte';
/**
* **HighlightedMatch** - Substring-match text highlight
*
* Renders `text` with each case-insensitive occurrence of `query` wrapped
* in `<mark>`.
*/
export { default as HighlightedMatch } from './HighlightedMatch.svelte';
-1
View File
@@ -2,5 +2,4 @@ export const INITIAL_FILE_SIZE = 0;
export const PROMPT_CONTENT_SEPARATOR = '\n\n';
export const CLIPBOARD_CONTENT_QUOTE_PREFIX = '"';
export const PROMPT_TRIGGER_PREFIX = '/';
export const RESOURCE_TRIGGER_PREFIX = '@';
export const NEW_CHAT_DRAFT_KEY = '__new_chat__';
+1
View File
@@ -39,6 +39,7 @@ export * from './max-bundle-size';
export * from './mcp';
export * from './mcp-form';
export * from './mcp-resource';
export * from './mention-badge';
export * from './message-export';
export * from './path-display';
export * from './model-id';
@@ -0,0 +1,39 @@
/**
* Visual contract for message @-mention badges. Svelte cannot be mounted
* from a hast tree, so the rehype file-badge plugin emits the shared class
* string below; keeping it here as a literal lets Tailwind's source
* scanner generate the utility classes.
*/
export const MENTION_BADGE_CLASSNAME =
'inline-flex w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md border border-border/50 bg-foreground/5 px-1.5 py-0.5 text-xs font-mono text-foreground hover:bg-foreground/10 dark:bg-foreground/10 dark:text-secondary-foreground';
export const MENTION_BADGE_ICON_CLASSNAME = 'h-3 w-3 shrink-0';
/**
* SVG attributes shared by the hast-built badge icons; the rehype plugin
* spreads them onto the `<svg>` `properties`.
*/
export const MENTION_BADGE_SVG_ATTRIBUTES: Readonly<Record<string, string>> = {
xmlns: 'http://www.w3.org/2000/svg',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'aria-hidden': 'true'
};
/**
* SVG path strings for the badge's inline icon; each entry becomes one
* `<path>` child of the wrapper `<svg>`. Paths match `lucide-svelte`'s
* current `File` and `Folder` glyphs.
*/
export const MENTION_BADGE_FILE_ICON_PATHS: readonly string[] = [
'M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z',
'M14 2v5a1 1 0 0 0 1 1h5'
];
export const MENTION_BADGE_FOLDER_ICON_PATHS: readonly string[] = [
'M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z'
];
@@ -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',
@@ -23,7 +23,12 @@ import type {
SettingsSectionEntry,
SettingsSection
} from '$lib/types';
import { CLI_FLAGS, DEFAULT_MCP_CONFIG } from '$lib/constants';
import { CLI_FLAGS } from './cli-flags';
import { DEFAULT_MCP_CONFIG } from './mcp';
import {
FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH,
FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH
} from './working-directory';
import { SETTINGS_KEYS } from './settings-keys';
import { ROUTES, SETTINGS_SECTION_SLUGS } from './routes';
import { TITLE_GENERATION } from './title-generation';
@@ -298,6 +303,14 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY
},
{
key: SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS,
label: 'Show full path in mentions',
help: 'Display the full file system path inside file and folder @-mention badges instead of just the file or folder name.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY
}
]
},
@@ -555,6 +568,18 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.AGENTIC,
isPositiveInteger: true
},
{
key: SETTINGS_KEYS.MENTION_SEARCH_MAX_DEPTH,
label: 'Mention search depth',
help: 'How many directory levels below the working directory the @-mention file search descends. Larger values surface deeply nested files but take longer on large trees.',
defaultValue: FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH,
placeholder: `${FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH}`,
min: 1,
max: FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH,
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.AGENTIC,
isPositiveInteger: true
}
]
},
@@ -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;
+5
View File
@@ -78,3 +78,8 @@ export enum PdfViewMode {
TEXT = 'text',
PAGES = 'pages'
}
export enum FileMentionEntryType {
FILE = 'file',
DIRECTORY = 'directory'
}
+2 -1
View File
@@ -24,7 +24,8 @@ export {
MessageRole,
MessageType,
PdfViewMode,
ReasoningFormat
ReasoningFormat,
FileMentionEntryType
} from './chat.enums';
export { SessionRecordType } from './conversation-import.enums';
+1
View File
@@ -8,6 +8,7 @@ export enum KeyboardKey {
ARROW_DOWN = 'ArrowDown',
ARROW_LEFT = 'ArrowLeft',
ARROW_RIGHT = 'ArrowRight',
BACKSPACE = 'Backspace',
TAB = 'Tab',
B_LOWER = 'b',
D_LOWER = 'd',
@@ -0,0 +1,67 @@
import { debounce } from '$lib/utils/debounce';
/**
* Shared debounced async-search machinery for the chat-form pickers:
* AbortController + sequence counter to discard stale responses, a
* debounce, and a live `isSearching` flag.
*/
export interface UseDebouncedSearchOptions {
debounceMs: number;
/** Fire-time guard: a scheduled call that outlives a reset is dropped. */
canRun: () => boolean;
/** Live query, used to drop a scheduled call whose query changed. */
getQuery: () => string;
/** Perform the search and commit results; bail out when `isCurrent()` is false. */
run: (query: string, signal: AbortSignal, isCurrent: () => boolean) => void | Promise<void>;
}
export function useDebouncedSearch(opts: UseDebouncedSearchOptions) {
let controller: AbortController | null = null;
let searchSeq = 0;
let isSearching = $state(false);
function isCurrent(seq: number) {
return seq === searchSeq;
}
function cancel() {
controller?.abort();
searchSeq++;
isSearching = false;
}
const schedule = debounce((query: string) => {
if (!opts.canRun() || query !== opts.getQuery().trim()) return;
void start(query);
}, opts.debounceMs);
async function start(query: string) {
cancel();
const fresh = new AbortController();
controller = fresh;
const mySeq = ++searchSeq;
isSearching = true;
try {
await opts.run(query, fresh.signal, () => isCurrent(mySeq));
} finally {
if (isCurrent(mySeq)) isSearching = false;
}
}
return {
get isSearching() {
return isSearching;
},
/** Bump the loading flag synchronously (e.g. before the debounce fires). */
setLoading(value: boolean) {
isSearching = value;
},
run(query: string) {
schedule(query);
},
cancel
};
}
export type UseDebouncedSearchReturn = ReturnType<typeof useDebouncedSearch>;
@@ -0,0 +1,108 @@
import { KeyboardKey } from '$lib/enums';
/**
* Shared keyboard navigation state for the chat-form pickers: a highlighted
* row, a scroll trigger, and Arrow/Escape/Enter handling.
*/
export interface UsePickerNavigationOptions {
/** Gates all key handling. */
isOpen: () => boolean;
count: () => number;
/**
* Resolve the row to highlight for a movement step, or -1 when no move
* is possible. Defaults to plain wraparound across `count()`.
*/
step?: (from: number, dir: 1 | -1) => number;
onClose: () => void;
/** Called on Enter when `hoveredIndex` points at a selectable row. */
onSelect: (index: number) => void;
}
function wrapStep(from: number, dir: 1 | -1, count: number): number {
return dir === 1 ? (from + 1) % count : from <= 0 ? count - 1 : from - 1;
}
export function usePickerNavigation(opts: UsePickerNavigationOptions) {
let hoveredIndex = $state(-1);
let scrollTrigger = $state(0);
function resolve(from: number, dir: 1 | -1): number {
const n = opts.count();
if (n === 0) return -1;
if (opts.step) return opts.step(from, dir);
return wrapStep(from, dir, n);
}
function move(dir: 1 | -1) {
const next = resolve(hoveredIndex, dir);
if (next >= 0) {
hoveredIndex = next;
scrollTrigger++;
}
}
/** Reset the highlight without bumping the scroll trigger. */
function reset(index: number) {
hoveredIndex = index;
}
/** Bump the scroll trigger without moving the highlight. */
function bumpScroll() {
scrollTrigger++;
}
/** Mouse hover highlights a row but must NOT bump the scroll trigger. */
function setHover(index: number) {
hoveredIndex = index;
}
function handleKeydown(event: KeyboardEvent): boolean {
if (!opts.isOpen()) return false;
if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
opts.onClose();
return true;
}
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
move(1);
return true;
}
if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
move(-1);
return true;
}
if (event.key === KeyboardKey.ENTER) {
if (hoveredIndex >= 0 && hoveredIndex < opts.count()) {
event.preventDefault();
opts.onSelect(hoveredIndex);
return true;
}
// No selectable row - let the caller's Enter-to-submit run.
return false;
}
return false;
}
return {
get hoveredIndex() {
return hoveredIndex;
},
get scrollTrigger() {
return scrollTrigger;
},
reset,
setHover,
move,
bumpScroll,
handleKeydown
};
}
export type UsePickerNavigationReturn = ReturnType<typeof usePickerNavigation>;
@@ -0,0 +1,47 @@
import { untrack } from 'svelte';
/**
* Scrolls the highlighted row of a picker list into view when the scroll
* trigger is bumped, without scrolling on mouse hover or result
* replacement.
*/
export interface UseScrollActiveRowOptions {
/** Counter bumped by keyboard nav; `undefined` disables the effect. */
getTrigger: () => number | undefined;
getContainer: () => HTMLDivElement | null;
getIndex: () => number;
getCount: () => number;
/** Attribute prefix, e.g. 'picker' for `[data-picker-index="0"]`. */
dataIndex: string;
}
export function useScrollActiveRow(opts: UseScrollActiveRowOptions) {
let lastTrigger: number | null = null;
$effect(() => {
const trigger = opts.getTrigger();
if (trigger === undefined) return;
// Skip the initial run on mount: the list opens with the first row
// already in view, and scrolling here fires before the popover is
// positioned, which would scroll the whole page to the top.
if (lastTrigger === null) {
lastTrigger = trigger;
return;
}
if (trigger === lastTrigger) return;
lastTrigger = trigger;
untrack(() => {
const container = opts.getContainer();
const index = opts.getIndex();
if (!container || index < 0 || index >= opts.getCount()) return;
const row = container.querySelector(
`[data-${opts.dataIndex}-index="${index}"]`
) as HTMLElement | null;
row?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
});
});
}
export type UseScrollActiveRowReturn = ReturnType<typeof useScrollActiveRow>;
+11 -1
View File
@@ -1,4 +1,4 @@
import type { ErrorDialogType } from '$lib/enums';
import type { ErrorDialogType, FileMentionEntryType } from '$lib/enums';
import type { ApiChatCompletionToolCall } from './api';
import type { DatabaseMessage, DatabaseMessageExtra } from './database';
@@ -166,3 +166,13 @@ export interface FileProcessingResult {
extras: DatabaseMessageExtra[];
emptyFiles: string[];
}
/**
* A file or folder picked in the @-mention picker. `path` is the absolute
* server-side path; `name` is the basename.
*/
export interface FileMentionEntry {
path: string;
name: string;
type: FileMentionEntryType;
}
+2 -1
View File
@@ -53,7 +53,8 @@ export type {
LiveProcessingStats,
LiveGenerationStats,
AttachmentDisplayItemsOptions,
FileProcessingResult
FileProcessingResult,
FileMentionEntry
} from './chat.d';
// Database types
+6
View File
@@ -31,6 +31,9 @@ export interface SettingsEntry {
radioOptions?: Array<{ value: string; label: string; key: string; isExperimental?: boolean }>;
isExperimental?: boolean;
isPositiveInteger?: boolean;
placeholder?: string;
min?: number;
max?: number;
dependsOn?: string;
sync?: {
serverKey: string;
@@ -52,6 +55,9 @@ export interface SettingsFieldConfig {
type: SettingsFieldType;
isExperimental?: boolean;
isPositiveInteger?: boolean;
placeholder?: string;
min?: number;
max?: number;
dependsOn?: string;
help?: string;
options?: Array<{ value: string; label: string; icon?: typeof Icon }>;
+151
View File
@@ -0,0 +1,151 @@
/**
* Shared `file_glob_search` runners with a short-lived result cache, so a
* repeated query for the same (type, path, glob, depth) reuses the last
* result instead of re-walking the tree.
*/
import { BuiltInTool, GlobSearchType } from '$lib/enums';
import { ToolsService } from '$lib/services/tools.service';
import {
GLOB_WILDCARD,
PATH_NAV_MAX_DEPTH,
PATH_SEPARATOR,
WINDOWS_SEPARATOR
} from '$lib/constants';
import { lastPathSegment } from './path-display';
import {
buildGlobSearchArgs,
joinPath,
rankEntries,
type GlobEntry,
type GlobSearchArgs
} from './working-directory';
const SEARCH_CACHE_TTL_MS = 2000;
interface CacheEntry {
results: GlobEntry[];
base: string;
at: number;
}
const searchCache = new Map<string, CacheEntry>();
export interface GlobSearchResult {
base: string;
entries: GlobEntry[];
error?: string;
}
export async function runGlobSearch(
args: GlobSearchArgs,
type: GlobSearchType,
limit: number,
signal: AbortSignal
): Promise<GlobSearchResult> {
const key = `${type}\u0000${args.path}\u0000${args.include}\u0000${args.maxDepth}\u0000${limit}`;
const cached = searchCache.get(key);
if (cached && Date.now() - cached.at < SEARCH_CACHE_TTL_MS) {
return { base: cached.base, entries: cached.results };
}
const res = await ToolsService.executeToolRaw(
BuiltInTool.FILE_GLOB_SEARCH,
{ path: args.path, type, include: args.include, max_depth: args.maxDepth, limit },
signal
);
if (typeof res.error === 'string') return { base: '', entries: [], error: res.error };
const base = typeof res.base === 'string' ? res.base : '';
const entries = Array.isArray(res.entries) ? (res.entries as GlobEntry[]) : [];
const now = Date.now();
// prune stale entries so the short-lived cache cannot grow unbounded
for (const [k, v] of searchCache) {
if (now - v.at >= SEARCH_CACHE_TTL_MS) searchCache.delete(k);
}
searchCache.set(key, { results: entries, base, at: now });
return { base, entries };
}
export interface GlobEntryResult {
path: string;
name: string;
type: string;
}
export interface GlobSearchChildOptions {
type?: GlobSearchType;
/** Descend only on a trailing path separator (mention picker); off for
* the WD picker, which descends on any exact match. */
descendOnTrailingSeparator?: boolean;
childMaxDepth?: number;
}
export interface GlobSearchChildResult {
base: string;
args: GlobSearchArgs;
/** Outer ranked entries plus the walked directory's children (absolute). */
entries: GlobEntryResult[];
/** Absolute path of the directory whose children were appended. */
exactDir?: string;
error?: string;
}
function toEntryResult(e: GlobEntry, base: string): GlobEntryResult {
return { path: joinPath(base, e.path), name: lastPathSegment(e.path), type: e.type };
}
/**
* One ranked glob search that may also list the matched directory's
* children, shared by the WD picker (descend on exact match) and the
* mention picker (descend on a trailing `/` or `\`).
*/
export async function runGlobSearchWithChildren(
query: string,
scopePath: string,
searchDepth: number,
limit: number,
signal: AbortSignal,
options: GlobSearchChildOptions = {}
): Promise<GlobSearchChildResult> {
const {
type = GlobSearchType.ALL,
descendOnTrailingSeparator = false,
childMaxDepth = PATH_NAV_MAX_DEPTH
} = options;
const args = buildGlobSearchArgs(query, scopePath, searchDepth);
const res = await runGlobSearch(args, type, limit, signal);
if (res.error) return { base: res.base, args, entries: [], error: res.error };
const ranked = rankEntries(res.entries, args.rankQuery);
const entries = ranked.map((e) => toEntryResult(e, res.base));
const last = args.last;
if (last) {
const wantsDescend = descendOnTrailingSeparator
? query.endsWith(PATH_SEPARATOR) || query.endsWith(WINDOWS_SEPARATOR)
: true;
const exact = ranked.find(
(e) => e.type === 'dir' && lastPathSegment(e.path).toLowerCase() === last.toLowerCase()
);
if (wantsDescend && exact) {
const exactDir = joinPath(res.base, exact.path);
const childRes = await runGlobSearch(
{ path: exactDir, include: GLOB_WILDCARD, maxDepth: childMaxDepth, rankQuery: '' },
type,
limit,
signal
);
if (!childRes.error) {
const children = childRes.entries
.map((e) => toEntryResult(e, childRes.base))
.sort((a, b) => a.path.localeCompare(b.path));
return { base: res.base, args, entries: [...entries, ...children], exactDir };
}
}
}
return { base: res.base, args, entries };
}
+34
View File
@@ -174,13 +174,47 @@ export {
export {
splitPathQuery,
buildCaseInsensitiveGlob,
buildGlobSearchArgs,
rankEntries,
joinPath,
highlightMatch,
type GlobEntry,
type GlobSearchArgs,
type PathQuery
} from './working-directory';
// Shared `file_glob_search` runner with a short-lived result cache
export {
runGlobSearch,
runGlobSearchWithChildren,
type GlobEntryResult,
type GlobSearchResult
} from './glob-search';
// Mention-token detection (for the `@`-triggered file/folder mention picker)
export {
findMentionToken,
takeMentionDismissSnapshot,
type MentionDismissSnapshot
} from './mention-token';
// Mention-chip visual contract shared by the rehype file-badge plugin,
// plus the `[name](file://...)` link helpers the mention picker splices in
export {
fileMentionLinkRe,
encodeFileLinkPath,
decodeFileLinkPath,
MENTION_BADGE_CLASSNAME,
MENTION_BADGE_ICON_CLASSNAME,
MENTION_BADGE_SVG_ATTRIBUTES,
MENTION_BADGE_FILE_ICON_PATHS,
MENTION_BADGE_FOLDER_ICON_PATHS,
getMentionBadgeIconPaths,
getMentionBadgeLabel,
buildMentionInsertion,
mentionLinkEndingAt
} from './mention-badge';
// Agentic content utilities (structured section derivation)
export {
deriveAgenticSections,
+101
View File
@@ -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 };
}
+73
View File
@@ -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 };
}
+10 -24
View File
@@ -9,22 +9,14 @@ import {
HOME_TILDE_PREFIX
} from '$lib/constants';
/**
* Last non-empty slash-delimited segment of `path`, with trailing
* slashes stripped. Returns the input unchanged when no `/` is present.
*/
export function lastPathSegment(p: string): string {
const trimmed = p.replace(TRAILING_SLASHES_REGEX, '');
const idx = trimmed.lastIndexOf(PATH_SEPARATOR);
return idx === -1 ? trimmed : trimmed.slice(idx + 1);
}
/**
* Abbreviate `path` to `~/...` when it sits under `home`, or to `~` when
* it equals `home`. Falls back to `lastPathSegment(path)` when home is
* unknown or the path is outside it. `~` semantics are reserved for the
* home directory, mirroring how shells render it.
*/
// `~/...` under `home`; falls back to the basename when home is unknown
// or the path is outside it.
export function abbreviateWorkingDir(
path: string | null | undefined,
home: string | null | undefined
@@ -37,12 +29,8 @@ export function abbreviateWorkingDir(
return lastPathSegment(path);
}
/**
* Replace a leading `home` prefix in `path` with `~`. Unlike
* abbreviateWorkingDir, paths outside `home` (or an unknown home) are
* returned unchanged - used for tool-call path displays where the full
* path matters.
*/
// Unlike abbreviateWorkingDir, paths outside `home` are returned
// unchanged - used where the full path matters.
export function abbreviateHome(path: string, home: string | null | undefined): string {
if (!home) return path;
if (path === home) return HOME_TILDE;
@@ -61,10 +49,9 @@ export interface CwdMessageInfo {
}
/**
* Format a synthetic cwd-change message. The text mirrors what the UI
* renders for it; the path travels as `[file:///abs/path](display)` so
* both the absolute and the short form are visible to the model and
* parseable back by the UI.
* Format a synthetic cwd-change message. The path travels as
* `[file:///abs/path](display)` so both the absolute and short form are
* visible to the model and parseable back by the UI.
*/
export function formatCwdMessage(cwd: string, home: string | null): string {
const display = abbreviateWorkingDir(cwd, home);
@@ -72,10 +59,9 @@ export function formatCwdMessage(cwd: string, home: string | null): string {
}
/**
* Parse a synthetic cwd message back into its parts. The caller must already
* know the message is synthetic (via the persisted `isSynthetic` flag); this
* only extracts the path from the message text. Returns null when `content`
* is not a cwd message.
* Parse a synthetic cwd message back into its parts. The caller must
* already know the message is synthetic (via the persisted `isSynthetic`
* flag); this only extracts the path.
*/
export function parseCwdMessage(content: string): CwdMessageInfo | null {
const trimmed = content.trim();
+32 -18
View File
@@ -1,13 +1,8 @@
/**
* Pure helpers for the working-directory picker search.
*
* The picker is backed by the server's `file_glob_search` built-in tool.
* Queries that start from a root (`/`, `C:\`, `\\host\share`) or from `~`
* navigate the directory tree (search the parent for the last segment);
* anything else glob-matches home-relative entries. Paths are carried with
* `/` separators, which is what the server returns and what Windows accepts.
* These helpers build the glob, normalize results and rank them
* client-side; the component owns the network/state plumbing.
* Pure helpers for the working-directory picker search, backed by the
* server's `file_glob_search` tool. Queries starting from a root (`/`,
* `C:\`, `\\host\share`) or `~` navigate the tree (search the parent for
* the last segment); anything else glob-matches home-relative entries.
*/
import { PATH_SEPARATOR } from '$lib/constants/mcp-resource';
@@ -21,6 +16,7 @@ import {
GLOB_WILDCARD,
HOME_TILDE,
LEADING_SLASHES_REGEX,
PATH_NAV_MAX_DEPTH,
UNC_ROOT_REGEX,
WINDOWS_SEPARATOR
} from '$lib/constants';
@@ -45,10 +41,6 @@ function toPosixSeparators(query: string): string {
return query.split(WINDOWS_SEPARATOR).join(PATH_SEPARATOR);
}
/**
* Length of the root prefix of `path`, or 0 when it has none. Covers the
* POSIX root, a Windows drive (`C:/`) and a UNC share (`//host/share/`).
*/
export function rootPrefixLength(path: string): number {
const unc = path.match(UNC_ROOT_REGEX);
if (unc) return unc[0].length;
@@ -84,7 +76,6 @@ export function splitPathQuery(query: string): PathQuery | null {
return { parent: parentOf(rest.slice(0, idx)), last: rest.slice(idx + 1) };
}
/** Build a case-insensitive glob that matches `query` anywhere within a name. */
export function buildCaseInsensitiveGlob(query: string): string {
let out = GLOB_WILDCARD;
for (const c of query) {
@@ -99,7 +90,33 @@ export function buildCaseInsensitiveGlob(query: string): string {
return out + GLOB_WILDCARD;
}
/** Exact basename first, then prefix, then substring; lower is better. */
export interface GlobSearchArgs {
path: string;
include: string;
maxDepth: number;
rankQuery: string;
/** Last segment of a path-navigation query (`~/dir/sub`), undefined for
* a plain home-relative glob. Lets callers act on the exact targeted
* segment (e.g. the WD picker "entering" a directory). */
last?: string;
}
export function buildGlobSearchArgs(
query: string,
scopePath: string,
searchDepth: number
): GlobSearchArgs {
const pathQuery = splitPathQuery(query);
const path = pathQuery ? pathQuery.parent : scopePath;
const include = pathQuery
? pathQuery.last
? buildCaseInsensitiveGlob(pathQuery.last)
: GLOB_WILDCARD
: buildCaseInsensitiveGlob(query);
const maxDepth = pathQuery ? PATH_NAV_MAX_DEPTH : searchDepth;
return { path, include, maxDepth, rankQuery: pathQuery?.last ?? query, last: pathQuery?.last };
}
const RANK_EXACT = 0;
const RANK_PREFIX = 1;
const RANK_SUBSTRING = 2;
@@ -114,7 +131,6 @@ function rankScore(path: string, query: string): number {
return RANK_OTHER;
}
/** Sort entries by relevance, then shorter path, then alphabetically. */
export function rankEntries(entries: GlobEntry[], query: string): GlobEntry[] {
return [...entries].sort(
(a, b) =>
@@ -124,13 +140,11 @@ export function rankEntries(entries: GlobEntry[], query: string): GlobEntry[] {
);
}
/** Join a base path and a relative segment, avoiding duplicate slashes. */
export function joinPath(base: string, rel: string): string {
if (!base) return rel;
return base.replace(TRAILING_SLASHES_REGEX, '') + PATH_SEPARATOR + rel;
}
/** Split `text` into alternating segments at each case-insensitive `query` match. */
export function highlightMatch(text: string, query: string): { text: string; match: boolean }[] {
if (!query) return [{ text, match: false }];
const segments: { text: string; match: boolean }[] = [];
@@ -0,0 +1,43 @@
<script lang="ts">
import { ChatFormPickerList, ChatFormPickerListItem } from '$lib/components/app/chat';
interface Item {
id: string;
label: string;
}
const items: Item[] = Array.from({ length: 20 }, (_, i) => ({
id: String(i),
label: `item ${i}`
}));
let open = $state(false);
let scrollTrigger = $state(0);
let selectedIndex = $state(0);
export function openPicker() {
open = true;
}
</script>
<div style="height: 5000px;">conversation</div>
{#if open}
<div data-testid="picker-host">
<ChatFormPickerList
{items}
isLoading={false}
{selectedIndex}
searchQuery=""
showSearchInput={false}
{scrollTrigger}
itemKey={(it) => it.id}
>
{#snippet item(it, index, isSelected)}
<ChatFormPickerListItem dataIndex={index} {isSelected} onclick={() => {}}>
{it.label}
</ChatFormPickerListItem>
{/snippet}
</ChatFormPickerList>
</div>
{/if}
@@ -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);
});
});
@@ -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);
});
});
+202
View File
@@ -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
});
});
});
+64
View File
@@ -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'
});
});
});
@@ -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');
});
});