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
+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 }[] = [];