qwen-code/packages/web-shell/client/hooks/useAtMentionMenu.ts
ytahdn 6672573433
fix(web-shell): reduce composer input latency (#8015)
Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
2026-07-29 08:06:53 +00:00

1938 lines
58 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { SetStateAction } from 'react';
import type { RefObject, ReactNode } from 'react';
import type { StateEffect } from '@codemirror/state';
import type { EditorView } from '@codemirror/view';
import type {
WebShellAtItem,
WebShellAtProvider,
WebShellAtProviderTab,
WebShellBuiltinAtProviderId,
WebShellBuiltinAtProvidersConfig,
WebShellComposerTag,
} from '../customization';
import { useI18n } from '../i18n';
export interface AtMentionProviderView {
id: string;
provider: WebShellAtProvider;
label: ReactNode;
textValue: string;
description?: string;
tabs?: readonly WebShellAtProviderTab[];
selectedTabId?: string;
renderItem?: WebShellAtProvider['renderItem'];
}
export interface AtMentionItem extends WebShellAtItem {
kind?: 'insert' | 'directory' | 'mcp-server';
targetPath?: string;
serverName?: string;
}
export interface AtMentionMenuState {
from: number;
to: number;
query: string;
level: 'categories' | 'items';
selectedProviderId?: string;
selectedIndex: number;
providers: AtMentionProviderView[];
items: AtMentionItem[];
loading: boolean;
itemMode?: 'default' | 'mcpServers' | 'mcpResources';
mcpServerName?: string;
fileDirectory?: string;
// search mode owns the panel input; context mode mirrors text typed in the editor.
inputMode?: 'search' | 'context';
validateMcpServer?: boolean;
tabs?: readonly WebShellAtProviderTab[];
selectedTabId?: string;
}
type GlobWorkspaceFn = (
pattern: string,
opts?: { maxResults?: number; signal?: AbortSignal },
) => Promise<{ matches: string[] }>;
interface ExtensionEntry {
name: string;
displayName?: string;
description?: string;
isActive: boolean;
}
type LoadExtensionsStatusFn = () => Promise<{
extensions: ExtensionEntry[];
}>;
interface DirectoryEntry {
name: string;
kind: 'file' | 'directory' | 'symlink' | 'other';
ignored: boolean;
}
type ListDirectoryFn = (
dirPath: string,
options?: { signal?: AbortSignal },
) => Promise<{
kind: 'list';
path: string;
entries: DirectoryEntry[];
truncated: boolean;
}>;
interface McpServerEntry {
kind: 'mcp_server';
name: string;
disabled: boolean;
mcpStatus?: string;
resourceCount?: number;
description?: string;
}
type LoadMcpStatusFn = () => Promise<{
servers: McpServerEntry[];
}>;
type LoadMcpResourcesFn = (
serverName: string,
options?: { signal?: AbortSignal },
) => Promise<{
resources: Array<{
uri: string;
name?: string;
title?: string;
description?: string;
mimeType?: string;
size?: number;
}>;
}>;
type DirectoryListing = Awaited<ReturnType<ListDirectoryFn>>;
type GlobWorkspaceResult = Awaited<ReturnType<GlobWorkspaceFn>>;
type ExtensionsStatus = Awaited<ReturnType<LoadExtensionsStatusFn>>;
type McpStatus = Awaited<ReturnType<LoadMcpStatusFn>>;
type McpResources = Awaited<ReturnType<LoadMcpResourcesFn>>;
interface BuiltinProviderCache {
directories: Map<string, Promise<DirectoryListing>>;
globResults: Map<string, Promise<GlobWorkspaceResult>>;
extensionsStatus?: Promise<ExtensionsStatus>;
mcpStatus?: Promise<McpStatus>;
mcpResources: Map<string, Promise<McpResources>>;
}
function createBuiltinProviderCache(): BuiltinProviderCache {
return {
directories: new Map(),
globResults: new Map(),
mcpResources: new Map(),
};
}
function getCached<K, V>(
cache: Map<K, Promise<V>>,
key: K,
load: () => Promise<V>,
) {
let promise = cache.get(key);
if (!promise) {
promise = load().catch((error) => {
cache.delete(key);
throw error;
});
cache.set(key, promise);
}
return promise;
}
interface LoadMcpResourceOptions {
validateServer?: boolean;
}
export interface AtMentionWorkspaceActions {
globWorkspace?: GlobWorkspaceFn;
loadExtensionsStatus?: LoadExtensionsStatusFn;
listDirectory?: ListDirectoryFn;
loadMcpStatus?: LoadMcpStatusFn;
loadMcpResources?: LoadMcpResourcesFn;
}
export interface UseAtMentionMenuOptions {
viewRef: RefObject<EditorView | null>;
disabledRef: RefObject<boolean>;
shellModeRef: RefObject<boolean>;
workspaceActionsRef: RefObject<AtMentionWorkspaceActions | undefined>;
workspaceKey?: string;
builtinProviders?: WebShellBuiltinAtProvidersConfig;
providers?: readonly WebShellAtProvider[];
createInlineTagEffect?: (range: {
from: number;
to: number;
tag: WebShellComposerTag;
}) => StateEffect<unknown>;
}
const AT_PATTERN = /@((?:[\p{L}\p{N}_./:-]|\\.)*)$/u;
const EMPTY_PROVIDERS: readonly WebShellAtProvider[] = [];
const SEARCH_DEBOUNCE_MS = 150;
const ITEM_LIMIT = 50;
const FILE_ROOT_ITEM_LIMIT = ITEM_LIMIT + 1;
export const FILE_PROVIDER_ID = 'files';
const EXTENSIONS_PROVIDER_ID = 'extensions';
export const MCP_RESOURCES_PROVIDER_ID = 'mcp-resources';
const BUILTIN_PROVIDER_IDS: readonly WebShellBuiltinAtProviderId[] = [
FILE_PROVIDER_ID,
EXTENSIONS_PROVIDER_ID,
MCP_RESOURCES_PROVIDER_ID,
];
const ESC = String.fromCharCode(27);
const ANSI_RE = new RegExp(`${ESC}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~])`, 'g');
// Strip zero-width and BiDi controls so provider text cannot spoof paths/URIs.
const BIDI_CONTROL_RE = /[\u200B\u200E\u200F\u061C\u2066-\u2069\u202A-\u202E]/g;
const SAFE_DISPLAY_FALLBACK = '[invalid]';
const AT_REFERENCE_UNSAFE_CHARS = /[^\p{L}\p{N}_./-]/gu;
function shallowEqualMenuState(
current: AtMentionMenuState | null,
next: AtMentionMenuState | null,
): boolean {
if (current === next) return true;
if (!current || !next) return false;
const keys = Object.keys(current) as Array<keyof AtMentionMenuState>;
return (
keys.length === Object.keys(next).length &&
keys.every((key) => {
const currentValue = current[key];
const nextValue = next[key];
if (Object.is(currentValue, nextValue)) return true;
return (
Array.isArray(currentValue) &&
Array.isArray(nextValue) &&
currentValue.length === nextValue.length &&
currentValue.every((value, index) => Object.is(value, nextValue[index]))
);
})
);
}
function equalProviderViews(
current: readonly AtMentionProviderView[],
next: readonly AtMentionProviderView[],
): boolean {
return (
current.length === next.length &&
current.every((provider, index) => {
const nextProvider = next[index];
return (
nextProvider !== undefined &&
provider.id === nextProvider.id &&
provider.textValue === nextProvider.textValue &&
Object.is(provider.label, nextProvider.label) &&
provider.description === nextProvider.description &&
provider.tabs === nextProvider.tabs &&
provider.selectedTabId === nextProvider.selectedTabId &&
provider.renderItem === nextProvider.renderItem
);
})
);
}
function isBuiltinProviderId(providerId: string): boolean {
return BUILTIN_PROVIDER_IDS.includes(
providerId as WebShellBuiltinAtProviderId,
);
}
function isBuiltinProviderEnabled(
providerId: WebShellBuiltinAtProviderId,
config: WebShellBuiltinAtProvidersConfig | undefined,
): boolean {
if (config === undefined || config === true) return true;
if (config === false) return false;
if (Array.isArray(config)) {
return (config as readonly WebShellBuiltinAtProviderId[]).includes(
providerId,
);
}
const options = config as Exclude<
WebShellBuiltinAtProvidersConfig,
boolean | readonly WebShellBuiltinAtProviderId[]
>;
if (options.enabled === false) return false;
if (options.include && !options.include.includes(providerId)) return false;
if (options.exclude?.includes(providerId)) return false;
return true;
}
function getRegisteredCustomProviders(
customProviders: readonly WebShellAtProvider[],
): WebShellAtProvider[] {
const registeredIds = new Set<string>(BUILTIN_PROVIDER_IDS);
const accepted: WebShellAtProvider[] = [];
for (const provider of customProviders) {
if (registeredIds.has(provider.id)) {
console.error(
`[@mention] duplicate provider id="${provider.id}" ignored`,
);
continue;
}
registeredIds.add(provider.id);
accepted.push(provider);
}
return accepted;
}
function getProviderTextValue(provider: WebShellAtProvider): string {
return (
(provider.textValue === undefined
? undefined
: sanitizeDisplayText(provider.textValue)) ??
(typeof provider.label === 'string'
? (sanitizeDisplayText(provider.label) ?? undefined)
: undefined) ??
safeDisplayText(provider.id) ??
SAFE_DISPLAY_FALLBACK
);
}
function joinWorkspacePath(dirPath: string, name: string): string {
if (dirPath === '.' || dirPath === '') return name;
return `${dirPath.replace(/\/+$/, '')}/${name}`;
}
function normalizeDirectoryPath(path: string): string {
const segments: string[] = [];
for (const segment of path.split('/')) {
if (!segment || segment === '.') continue;
if (segment === '..') {
segments.pop();
continue;
}
segments.push(segment);
}
return segments.length > 0 ? segments.join('/') : '.';
}
function escapeGlobQuery(query: string): string {
return Array.from(query, (char) => {
if (/[a-z]/i.test(char)) {
return `[${char.toLowerCase()}${char.toUpperCase()}]`;
}
return /[\\*?[{\]}()!@+|]/.test(char) ? `\\${char}` : char;
}).join('');
}
function fileSearchGlobPattern(query: string): string {
const normalizedQuery = unescapeAtReferenceText(query).replace(/^\.\//, '');
return normalizedQuery ? `**/*${escapeGlobQuery(normalizedQuery)}*` : '**/*';
}
function matchesQuery(query: string, ...values: Array<string | undefined>) {
const lowerQuery = query.toLowerCase();
return values.some((value) => value?.toLowerCase().includes(lowerQuery));
}
function directoryInsertText(path: string): string {
const normalized = normalizeDirectoryPath(path);
const safePath = sanitizeInsertText(normalized);
return normalized === '.' ? '@./ ' : `@${escapeAtReferenceText(safePath)}/ `;
}
function buildMcpResourceRef(serverName: string, uri: string): string {
return `${serverName}:${uri}`;
}
function escapeAtReferenceText(ref: string): string {
return ref.replace(AT_REFERENCE_UNSAFE_CHARS, '\\$&');
}
function unescapeAtReferenceText(ref: string): string {
return ref.replace(/\\(.)/g, '$1');
}
function isSafeTextChar(char: string): boolean {
const code = char.charCodeAt(0);
return code > 0x1f && code !== 0x7f && (code < 0x80 || code > 0x9f);
}
function mcpResourceInsertText(serverName: string, uri: string): string {
return `@${escapeAtReferenceText(
buildMcpResourceRef(
sanitizeInsertText(serverName),
sanitizeInsertText(uri),
),
)} `;
}
function splitInsertedReferenceQuery(
query: string,
lastSelectedProviderId: string | null,
lastSelectedMcpServerName: string | null,
): {
providerId: string;
serverName?: string;
itemQuery: string;
validateServer?: boolean;
} | null {
const unescapedQuery = unescapeAtReferenceText(query);
if (
lastSelectedProviderId === MCP_RESOURCES_PROVIDER_ID &&
lastSelectedMcpServerName &&
unescapedQuery.startsWith(`${lastSelectedMcpServerName}:`)
) {
return {
providerId: MCP_RESOURCES_PROVIDER_ID,
serverName: lastSelectedMcpServerName,
itemQuery: unescapedQuery.slice(lastSelectedMcpServerName.length + 1),
validateServer: true,
};
}
if (query.startsWith('ext:')) {
return {
providerId: EXTENSIONS_PROVIDER_ID,
itemQuery: query.slice('ext:'.length),
};
}
if (query.startsWith('mcp:')) {
return {
providerId: MCP_RESOURCES_PROVIDER_ID,
itemQuery: query.slice('mcp:'.length),
};
}
return null;
}
function getProviderQueryFromMention(
providerId: string,
parsedQuery: string,
mcpServerName?: string,
): string {
if (providerId === EXTENSIONS_PROVIDER_ID && parsedQuery.startsWith('ext:')) {
return parsedQuery.slice('ext:'.length);
}
if (
providerId === MCP_RESOURCES_PROVIDER_ID &&
parsedQuery.startsWith('mcp:')
) {
return parsedQuery.slice('mcp:'.length);
}
const unescapedQuery = unescapeAtReferenceText(parsedQuery);
if (
providerId === MCP_RESOURCES_PROVIDER_ID &&
mcpServerName &&
unescapedQuery.startsWith(`${mcpServerName}:`)
) {
return unescapedQuery.slice(mcpServerName.length + 1);
}
if (
!isBuiltinProviderId(providerId) &&
parsedQuery.startsWith(`${providerId}:`)
) {
return parsedQuery.slice(providerId.length + 1);
}
return parsedQuery;
}
async function isEnabledMcpServer(
actions: AtMentionWorkspaceActions | undefined,
serverName: string,
signal?: AbortSignal,
loadStatus?: () => Promise<McpStatus>,
) {
if (signal?.aborted) return false;
const loadMcpStatus = loadStatus ?? actions?.loadMcpStatus;
if (!loadMcpStatus) return false;
try {
const status = await loadMcpStatus();
if (signal?.aborted) return false;
return status.servers.some(
(server) => server.name === serverName && !server.disabled,
);
} catch (error) {
console.warn(`Failed to verify @ MCP server "${serverName}" status`, error);
return false;
}
}
function parentDirectoryPath(path: string): string {
const normalized = normalizeDirectoryPath(path);
if (normalized === '.') return '.';
const slashIndex = normalized.lastIndexOf('/');
return slashIndex < 0 ? '.' : normalized.slice(0, slashIndex);
}
function splitFileQuery(query: string, fallbackDir: string) {
const normalizedQuery = unescapeAtReferenceText(query)
.replace(/^\.\/+/, '')
.replace(/\/+/g, '/');
const slashIndex = normalizedQuery.lastIndexOf('/');
if (slashIndex < 0) {
return {
dirPath: normalizeDirectoryPath(fallbackDir),
entryQuery: normalizedQuery,
};
}
const dirQuery = normalizedQuery.slice(0, slashIndex);
const fallback = normalizeDirectoryPath(fallbackDir);
const dirPath =
fallback === '.' ? dirQuery : joinWorkspacePath(fallback, dirQuery);
return {
dirPath: normalizeDirectoryPath(dirPath),
entryQuery: normalizedQuery.slice(slashIndex + 1),
};
}
function parseAtMention(view: EditorView | null) {
if (!view) return null;
const selection = view.state.selection.main;
if (!selection.empty) return null;
const line = view.state.doc.lineAt(selection.head);
const textBefore = line.text.slice(0, selection.head - line.from);
const match = textBefore.match(AT_PATTERN);
if (!match) return null;
const from = selection.head - match[0].length;
if (from > line.from) {
const previous = view.state.doc.sliceString(from - 1, from);
if (!/[\s([{'"]/.test(previous)) {
return null;
}
}
return {
from,
to: selection.head,
query: match[1] ?? '',
};
}
export function sanitizeDisplayText(raw: string): string | undefined {
const stripped = raw
.replace(ANSI_RE, '')
.replace(BIDI_CONTROL_RE, '')
.split('')
.filter(isSafeTextChar)
.join('')
.replace(/\s+/g, ' ')
.trim();
return stripped.length > 0 ? stripped : undefined;
}
function sanitizeInsertText(raw: string): string {
const stripped = raw
.replace(ANSI_RE, '')
.replace(BIDI_CONTROL_RE, '')
.split('')
.filter(isSafeTextChar)
.join('');
return stripped.length > 0 ? stripped : SAFE_DISPLAY_FALLBACK;
}
function safeDisplayText(raw: string | undefined): string {
if (raw === undefined) return SAFE_DISPLAY_FALLBACK;
return sanitizeDisplayText(raw) ?? SAFE_DISPLAY_FALLBACK;
}
function sanitizeOptionalInsertText(
raw: string | undefined,
): string | undefined {
if (raw === undefined) return undefined;
return sanitizeInsertText(raw).trim();
}
function sanitizeComposerTag(
tag: WebShellComposerTag | undefined,
): WebShellComposerTag | undefined {
if (!tag) return undefined;
const kind =
tag.kind === undefined ? undefined : sanitizeDisplayText(tag.kind);
return {
id: sanitizeDisplayText(tag.id) ?? SAFE_DISPLAY_FALLBACK,
label: tag.label === undefined ? undefined : sanitizeDisplayText(tag.label),
value: tag.value === undefined ? undefined : sanitizeDisplayText(tag.value),
removable: tag.removable,
kind,
icon: tag.icon,
metadata: tag.metadata,
serialized: sanitizeOptionalInsertText(tag.serialized),
};
}
function sanitizeAtMentionItem(
item: AtMentionItem,
options?: { customProvider?: boolean },
): AtMentionItem {
const sanitized = {
...item,
label: sanitizeDisplayText(item.label) ?? safeDisplayText(item.id),
description:
item.description === undefined
? undefined
: sanitizeDisplayText(item.description),
subtitle:
item.subtitle === undefined
? undefined
: sanitizeDisplayText(item.subtitle),
detail:
item.detail === undefined ? undefined : sanitizeDisplayText(item.detail),
icon: item.icon,
iconTooltip:
item.iconTooltip === undefined
? undefined
: sanitizeDisplayText(item.iconTooltip),
insertText:
item.insertText === undefined
? undefined
: sanitizeInsertText(item.insertText),
composerTag: sanitizeComposerTag(item.composerTag),
};
if (!options?.customProvider) {
return sanitized;
}
const safe = { ...sanitized };
delete safe.targetPath;
delete safe.serverName;
return {
...safe,
kind: 'insert',
};
}
function createComposerTagForItem(
providerId: string | undefined,
item: AtMentionItem,
insert: string,
): WebShellComposerTag | null {
const serialized = insert.trim();
if (!serialized) return null;
if (item.composerTag) {
return {
...item.composerTag,
serialized: item.composerTag.serialized?.trim() || serialized,
};
}
if (providerId === EXTENSIONS_PROVIDER_ID) {
return {
id: `extension:${serialized}`,
kind: 'extension',
value: item.label,
serialized,
};
}
if (providerId === MCP_RESOURCES_PROVIDER_ID) {
return {
id: `mcp:${serialized}`,
kind: 'mcp',
value: item.label,
serialized,
};
}
if (providerId === FILE_PROVIDER_ID) {
return {
id: `file:${serialized}`,
kind: 'file',
value: item.description ?? item.label,
serialized,
};
}
return null;
}
function createFileProvider(
getActions: () => AtMentionWorkspaceActions | undefined,
getCurrentDir: () => string,
getCache: () => BuiltinProviderCache,
label: string,
description: string,
): WebShellAtProvider {
return {
id: FILE_PROVIDER_ID,
label,
description,
order: 1,
async search({ query, signal }) {
const actions = getActions();
const currentDir = normalizeDirectoryPath(getCurrentDir());
const listDirectory = actions?.listDirectory;
if (listDirectory && (!query || !actions?.globWorkspace)) {
try {
const { dirPath, entryQuery } = splitFileQuery(query, currentDir);
const lowerQuery = entryQuery.toLowerCase();
const listing = await getCached(getCache().directories, dirPath, () =>
listDirectory(dirPath, { signal }),
);
if (signal.aborted) return [];
const entries = listing.entries
.filter((entry) => !entry.ignored)
.filter((entry) => entry.name.toLowerCase().includes(lowerQuery))
.sort((a, b) => {
if (a.kind === 'directory' && b.kind !== 'directory') return -1;
if (a.kind !== 'directory' && b.kind === 'directory') return 1;
return a.name.localeCompare(b.name);
});
const currentDirectoryItem: AtMentionItem = {
id: `current:${dirPath}`,
label: directoryInsertText(dirPath).trim(),
description: sanitizeDisplayText(dirPath),
insertText: directoryInsertText(dirPath),
kind: 'insert',
};
return [
...(entryQuery ? [] : [currentDirectoryItem]),
...entries.map((entry): AtMentionItem => {
const path = joinWorkspacePath(dirPath, entry.name);
const safeName = safeDisplayText(entry.name);
const safePath = sanitizeDisplayText(path);
const safeInsertPath = sanitizeInsertText(path);
if (entry.kind === 'directory') {
return {
id: `dir:${path}`,
label: `${safeName}/`,
description: safePath,
kind: 'directory',
targetPath: path,
};
}
return {
id: `file:${path}`,
label: safeName,
description: safePath,
insertText: `@${escapeAtReferenceText(safeInsertPath)} `,
kind: 'insert',
};
}),
].slice(0, entryQuery ? ITEM_LIMIT : FILE_ROOT_ITEM_LIMIT);
} catch (error) {
if (!signal.aborted) {
console.warn('Failed to load @ file suggestions', error);
}
return [];
}
}
const globWorkspace = actions?.globWorkspace;
if (!globWorkspace) {
console.warn(
'[@mention] file provider unavailable: workspace actions are not configured',
);
return [];
}
try {
const pattern = fileSearchGlobPattern(query);
const result = await getCached(getCache().globResults, pattern, () =>
globWorkspace(pattern, { maxResults: ITEM_LIMIT, signal }),
);
if (signal.aborted) return [];
return result.matches
.filter((file) => file !== '.')
.map((file) => ({
id: file,
label: safeDisplayText(file),
insertText: `@${escapeAtReferenceText(sanitizeInsertText(file))} `,
kind: 'insert',
}));
} catch (error) {
if (!signal.aborted) {
console.warn('Failed to load @ file suggestions', error);
}
return [];
}
},
};
}
function createExtensionProvider(
getActions: () => AtMentionWorkspaceActions | undefined,
getCache: () => BuiltinProviderCache,
label: string,
description: string,
): WebShellAtProvider {
return {
id: EXTENSIONS_PROVIDER_ID,
label,
description,
order: 2,
async search({ query, signal }) {
const loadExtensionsStatus = getActions()?.loadExtensionsStatus;
if (!loadExtensionsStatus) return [];
try {
const cache = getCache();
cache.extensionsStatus ??= loadExtensionsStatus().catch((error) => {
cache.extensionsStatus = undefined;
throw error;
});
const status = await cache.extensionsStatus;
if (signal.aborted) return [];
const lowerQuery = query.toLowerCase();
return status.extensions
.filter((ext) => ext.isActive)
.map((ext) => {
const label = safeDisplayText(ext.name);
const insertName = escapeAtReferenceText(
sanitizeInsertText(ext.name),
);
const serialized = `@ext:${insertName}`;
const displayName = sanitizeDisplayText(ext.displayName ?? '');
const description = sanitizeDisplayText(ext.description ?? '');
return {
id: ext.name,
label,
description:
displayName && displayName !== label
? displayName
: description,
detail:
displayName && description
? `${displayName} - ${description}`
: (displayName ?? description),
composerTag: {
id: `extension:${serialized}`,
kind: 'extension',
value: displayName || label,
serialized,
},
insertText: `${serialized} `,
};
})
.filter((ext) => {
return matchesQuery(lowerQuery, ext.label, ext.description);
})
.sort((a, b) => {
const aLabel = a.label.toLowerCase();
const bLabel = b.label.toLowerCase();
const aPrefix = aLabel.startsWith(lowerQuery) ? 0 : 1;
const bPrefix = bLabel.startsWith(lowerQuery) ? 0 : 1;
if (aPrefix !== bPrefix) return aPrefix - bPrefix;
return aLabel.localeCompare(bLabel);
})
.slice(0, ITEM_LIMIT);
} catch (error) {
if (!signal.aborted) {
console.warn('Failed to load @ extension suggestions', error);
}
return [];
}
},
};
}
function createMcpResourcesProvider(
getActions: () => AtMentionWorkspaceActions | undefined,
getCache: () => BuiltinProviderCache,
label: string,
description: string,
formatResourceCount: (count: number) => string,
): WebShellAtProvider {
return {
id: MCP_RESOURCES_PROVIDER_ID,
label,
description,
order: 3,
async search({ query, signal }) {
const loadMcpStatus = getActions()?.loadMcpStatus;
if (!loadMcpStatus) return [];
try {
const cache = getCache();
cache.mcpStatus ??= loadMcpStatus().catch((error) => {
cache.mcpStatus = undefined;
throw error;
});
const status = await cache.mcpStatus;
if (signal.aborted) return [];
const lowerQuery = query.toLowerCase();
return status.servers
.filter((server) => !server.disabled)
.map((server): AtMentionItem => {
const safeName = sanitizeInsertText(server.name);
const count =
server.resourceCount === undefined
? undefined
: formatResourceCount(server.resourceCount);
if (server.resourceCount === 0) {
return {
id: `mcp-server-ref:${server.name}`,
label: safeDisplayText(server.name),
description: sanitizeDisplayText(server.description ?? ''),
detail: sanitizeDisplayText(server.description ?? ''),
insertText: `@mcp:${escapeAtReferenceText(safeName)} `,
kind: 'insert',
};
}
return {
id: `mcp-server:${server.name}`,
label: safeDisplayText(server.name),
description:
count === undefined
? sanitizeDisplayText(server.description ?? '')
: count,
detail: sanitizeDisplayText(server.description ?? ''),
kind: 'mcp-server',
serverName: server.name,
};
})
.filter((server) => {
return matchesQuery(lowerQuery, server.label, server.description);
})
.sort((a, b) => a.label.localeCompare(b.label))
.slice(0, ITEM_LIMIT);
} catch (error) {
if (!signal.aborted) {
console.warn('Failed to load @ MCP resource suggestions', error);
}
return [];
}
},
};
}
function nextSelectionIndex(
current: number,
total: number,
direction: 'up' | 'down',
) {
if (total <= 0) return null;
if (direction === 'up') return current <= 0 ? null : current - 1;
return current >= total - 1 ? null : current + 1;
}
export function useAtMentionMenu({
viewRef,
disabledRef,
shellModeRef,
workspaceActionsRef,
workspaceKey,
builtinProviders,
providers = EMPTY_PROVIDERS,
createInlineTagEffect,
}: UseAtMentionMenuOptions) {
const { t } = useI18n();
const [state, setState] = useState<AtMentionMenuState | null>(null);
const stateRef = useRef<AtMentionMenuState | null>(null);
const requestIdRef = useRef(0);
const abortRef = useRef<AbortController | null>(null);
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fileDirectoryRef = useRef('.');
const builtinCacheRef = useRef<BuiltinProviderCache>(
createBuiltinProviderCache(),
);
const lastSelectedProviderIdRef = useRef<string | null>(null);
const lastSelectedMcpServerNameRef = useRef<string | null>(null);
const preserveProviderSelectionRef = useRef(false);
const allProviders = useMemo(() => {
const builtinAtProviders = [
createFileProvider(
() => workspaceActionsRef.current,
() => fileDirectoryRef.current,
() => builtinCacheRef.current,
t('at.category.files'),
t('at.category.files.description'),
),
createExtensionProvider(
() => workspaceActionsRef.current,
() => builtinCacheRef.current,
t('at.category.extensions'),
t('at.category.extensions.description'),
),
createMcpResourcesProvider(
() => workspaceActionsRef.current,
() => builtinCacheRef.current,
t('at.category.mcpResources'),
t('at.category.mcpResources.description'),
(count) => t('mcp.resourceCount', { count }),
),
].filter((provider) =>
isBuiltinProviderEnabled(
provider.id as WebShellBuiltinAtProviderId,
builtinProviders,
),
);
return [
...builtinAtProviders,
...getRegisteredCustomProviders(providers),
].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}, [builtinProviders, providers, t, workspaceActionsRef]);
const allProvidersRef = useRef(allProviders);
allProvidersRef.current = allProviders;
const providerViews = useMemo(
() =>
allProviders.map((provider) => ({
id: provider.id,
provider,
textValue: getProviderTextValue(provider),
label:
typeof provider.label === 'string'
? getProviderTextValue(provider)
: provider.label,
description:
provider.description === undefined
? undefined
: sanitizeDisplayText(provider.description),
tabs: provider.tabs,
renderItem: provider.renderItem,
})),
[allProviders],
);
const providerViewsRef = useRef(providerViews);
providerViewsRef.current = providerViews;
const setMenu = useCallback(
(next: SetStateAction<AtMentionMenuState | null>) => {
const resolved =
typeof next === 'function' ? next(stateRef.current) : next;
if (shallowEqualMenuState(stateRef.current, resolved)) return;
stateRef.current = resolved;
setState(resolved);
},
[],
);
useEffect(() => {
abortRef.current?.abort();
abortRef.current = null;
if (searchTimerRef.current) {
clearTimeout(searchTimerRef.current);
searchTimerRef.current = null;
}
fileDirectoryRef.current = '.';
builtinCacheRef.current = createBuiltinProviderCache();
stateRef.current = null;
setState(null);
}, [workspaceKey]);
const clearPendingLoad = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
if (searchTimerRef.current) {
clearTimeout(searchTimerRef.current);
searchTimerRef.current = null;
}
}, []);
const close = useCallback(
(options: { preserveProviderSelection?: boolean } = {}) => {
if (
stateRef.current === null &&
abortRef.current === null &&
searchTimerRef.current === null &&
lastSelectedProviderIdRef.current === null &&
lastSelectedMcpServerNameRef.current === null &&
!preserveProviderSelectionRef.current
) {
return;
}
clearPendingLoad();
builtinCacheRef.current = createBuiltinProviderCache();
const preserveSelection =
options.preserveProviderSelection ||
preserveProviderSelectionRef.current;
preserveProviderSelectionRef.current = false;
if (!preserveSelection) {
lastSelectedProviderIdRef.current = null;
lastSelectedMcpServerNameRef.current = null;
}
setMenu(null);
},
[clearPendingLoad, setMenu],
);
const closeIfOpen = useCallback(() => {
const current = stateRef.current;
if (!current) return false;
if (current.level === 'items') {
clearPendingLoad();
setMenu({
...current,
level: 'categories',
selectedProviderId: undefined,
selectedIndex: 0,
query: '',
items: [],
loading: false,
itemMode: undefined,
mcpServerName: undefined,
fileDirectory: undefined,
inputMode: undefined,
tabs: undefined,
selectedTabId: undefined,
});
return 'categories';
}
close();
return 'closed';
}, [clearPendingLoad, close, setMenu]);
useEffect(
() => () => {
if (searchTimerRef.current) {
clearTimeout(searchTimerRef.current);
}
abortRef.current?.abort();
abortRef.current = null;
fileDirectoryRef.current = '.';
builtinCacheRef.current = createBuiltinProviderCache();
},
[],
);
const getPreviousProviderItems = useCallback(
(
providerId: string,
query: string,
baseState: Omit<AtMentionMenuState, 'items' | 'loading'>,
) => {
const current = stateRef.current;
if (
current?.level !== 'items' ||
current.selectedProviderId !== providerId ||
current.selectedTabId !== baseState.selectedTabId ||
current.itemMode !== baseState.itemMode ||
current.mcpServerName !== baseState.mcpServerName ||
current.fileDirectory !== baseState.fileDirectory
) {
return [];
}
if (current.query !== query && !isBuiltinProviderId(providerId)) {
return [];
}
if (
providerId === FILE_PROVIDER_ID &&
query === '' &&
fileDirectoryRef.current === '.'
) {
return [];
}
return current.items;
},
[],
);
const hasCachedProviderData = useCallback(
(providerId: string, query: string) => {
const actions = workspaceActionsRef.current;
const cache = builtinCacheRef.current;
if (providerId === FILE_PROVIDER_ID) {
if (query && actions?.globWorkspace) {
return cache.globResults.has(fileSearchGlobPattern(query));
}
if (actions?.listDirectory) {
const { dirPath } = splitFileQuery(
query,
normalizeDirectoryPath(fileDirectoryRef.current),
);
return cache.directories.has(dirPath);
}
const pattern = fileSearchGlobPattern(query);
return (
Boolean(actions?.globWorkspace) && cache.globResults.has(pattern)
);
}
if (providerId === EXTENSIONS_PROVIDER_ID) {
return cache.extensionsStatus !== undefined;
}
if (providerId === MCP_RESOURCES_PROVIDER_ID) {
return cache.mcpStatus !== undefined;
}
return false;
},
[workspaceActionsRef],
);
const loadItems = useCallback(
(
providerId: string,
query: string,
baseState: Omit<AtMentionMenuState, 'items' | 'loading'>,
options: { loadingAlreadySet?: boolean } = {},
) => {
const provider = allProvidersRef.current.find(
(item) => item.id === providerId,
);
if (!provider) {
console.warn(
`[@mention] provider="${providerId}" not found (may have been removed)`,
);
setMenu(null);
return;
}
abortRef.current?.abort();
const abort = new AbortController();
abortRef.current = abort;
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
const previousItems = getPreviousProviderItems(
providerId,
query,
baseState,
);
if (!options.loadingAlreadySet) {
setMenu({ ...baseState, items: previousItems, loading: true });
}
Promise.resolve()
.then(() =>
provider.search({
query,
signal: abort.signal,
tabId: baseState.selectedTabId,
}),
)
.then((items) => {
if (abort.signal.aborted || requestIdRef.current !== requestId) {
return;
}
setMenu((prev) => {
if (!prev || prev.level !== 'items') return prev;
const maxItems =
providerId === FILE_PROVIDER_ID && query.length === 0
? FILE_ROOT_ITEM_LIMIT
: ITEM_LIMIT;
return {
...prev,
items: items.slice(0, maxItems).map((item) =>
sanitizeAtMentionItem(item, {
customProvider: !isBuiltinProviderId(providerId),
}),
),
selectedIndex: 0,
loading: false,
};
});
})
.catch((error) => {
if (abort.signal.aborted || requestIdRef.current !== requestId) {
return;
}
console.warn(
`[@mention] provider="${providerId}" query=<redacted> failed`,
error,
);
setMenu((prev) => {
if (!prev || prev.level !== 'items') return prev;
return {
...prev,
items: [],
selectedIndex: 0,
loading: false,
};
});
});
},
[getPreviousProviderItems, setMenu],
);
const scheduleLoadItems = useCallback(
(
providerId: string,
query: string,
baseState: Omit<AtMentionMenuState, 'items' | 'loading'>,
) => {
if (searchTimerRef.current) {
clearTimeout(searchTimerRef.current);
}
abortRef.current?.abort();
abortRef.current = null;
const previousItems = getPreviousProviderItems(
providerId,
query,
baseState,
);
setMenu({ ...baseState, items: previousItems, loading: true });
if (hasCachedProviderData(providerId, query)) {
loadItems(providerId, query, baseState, { loadingAlreadySet: true });
return;
}
searchTimerRef.current = setTimeout(() => {
searchTimerRef.current = null;
loadItems(providerId, query, baseState, { loadingAlreadySet: true });
}, SEARCH_DEBOUNCE_MS);
},
[getPreviousProviderItems, hasCachedProviderData, loadItems, setMenu],
);
const loadMcpResourceItems = useCallback(
(
serverName: string,
query: string,
baseState: Omit<AtMentionMenuState, 'items' | 'loading'>,
options: LoadMcpResourceOptions & { loadingAlreadySet?: boolean } = {},
) => {
const actions = workspaceActionsRef.current;
const loadMcpResources = actions?.loadMcpResources;
if (!loadMcpResources) {
console.warn(
`[@mention] loadMcpResources not available for server="${serverName}"`,
);
setMenu({ ...baseState, items: [], loading: false });
return;
}
abortRef.current?.abort();
const abort = new AbortController();
abortRef.current = abort;
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
const previousItems =
stateRef.current?.level === 'items' &&
stateRef.current.itemMode === 'mcpResources' &&
stateRef.current.mcpServerName === serverName
? stateRef.current.items
: [];
if (!options.loadingAlreadySet) {
setMenu({ ...baseState, items: previousItems, loading: true });
}
const loadMcpStatus = actions?.loadMcpStatus;
const enabledPromise = options.validateServer
? isEnabledMcpServer(
actions,
serverName,
abort.signal,
loadMcpStatus
? () => {
const cache = builtinCacheRef.current;
cache.mcpStatus ??= loadMcpStatus().catch((error) => {
cache.mcpStatus = undefined;
throw error;
});
return cache.mcpStatus;
}
: undefined,
)
: Promise.resolve(true);
enabledPromise
.then((enabled) => {
if (abort.signal.aborted) {
return { resources: [] };
}
if (!enabled) {
console.warn('[@mention] MCP server disabled or not found', {
serverName,
});
return { resources: [] };
}
return getCached(
builtinCacheRef.current.mcpResources,
serverName,
() => loadMcpResources(serverName, { signal: abort.signal }),
);
})
.then((status) => {
if (abort.signal.aborted || requestIdRef.current !== requestId) {
return;
}
const items = status.resources
.map((resource): AtMentionItem => {
const label =
sanitizeDisplayText(resource.title ?? '') ??
sanitizeDisplayText(resource.name ?? '') ??
safeDisplayText(resource.uri);
return {
id: `mcp-resource:${serverName}:${resource.uri}`,
label,
description:
sanitizeDisplayText(resource.description ?? '') ??
sanitizeDisplayText(resource.mimeType ?? ''),
detail: safeDisplayText(resource.uri),
insertText: mcpResourceInsertText(serverName, resource.uri),
kind: 'insert',
};
})
.filter((resource) => {
return matchesQuery(
query,
resource.label,
resource.description,
resource.detail,
);
})
.sort((a, b) => a.label.localeCompare(b.label))
.slice(0, ITEM_LIMIT);
setMenu((prev) => {
if (!prev || prev.level !== 'items') return prev;
return {
...prev,
items,
selectedIndex: 0,
loading: false,
};
});
})
.catch((error) => {
if (abort.signal.aborted || requestIdRef.current !== requestId) {
return;
}
console.warn('Failed to load @ MCP resources', error);
setMenu((prev) => {
if (!prev || prev.level !== 'items') return prev;
return {
...prev,
items: [],
selectedIndex: 0,
loading: false,
};
});
});
},
[setMenu, workspaceActionsRef],
);
const scheduleLoadMcpResourceItems = useCallback(
(
serverName: string,
query: string,
baseState: Omit<AtMentionMenuState, 'items' | 'loading'>,
options: LoadMcpResourceOptions = {},
) => {
if (searchTimerRef.current) {
clearTimeout(searchTimerRef.current);
}
abortRef.current?.abort();
abortRef.current = null;
const previousItems =
stateRef.current?.level === 'items' &&
stateRef.current.itemMode === 'mcpResources' &&
stateRef.current.mcpServerName === serverName &&
baseState.itemMode === 'mcpResources'
? stateRef.current.items
: [];
setMenu({ ...baseState, items: previousItems, loading: true });
if (builtinCacheRef.current.mcpResources.has(serverName)) {
loadMcpResourceItems(serverName, query, baseState, {
...options,
loadingAlreadySet: true,
});
return;
}
searchTimerRef.current = setTimeout(() => {
searchTimerRef.current = null;
loadMcpResourceItems(serverName, query, baseState, {
...options,
loadingAlreadySet: true,
});
}, SEARCH_DEBOUNCE_MS);
},
[loadMcpResourceItems, setMenu],
);
const refreshForView = useCallback(
(view: EditorView | null) => {
if (disabledRef.current || shellModeRef.current) {
close();
return false;
}
const parsed = parseAtMention(view);
if (!parsed) {
close();
return false;
}
const current = stateRef.current;
const keepItemsLevel =
current?.level === 'items' &&
current.from === parsed.from &&
(current.to === parsed.to ||
(current.inputMode === 'search' && parsed.to >= current.to) ||
(current.inputMode === 'context' && parsed.to >= current.to)) &&
current.selectedProviderId !== undefined &&
providerViewsRef.current.some(
(provider) => provider.id === current.selectedProviderId,
);
if (keepItemsLevel) {
if (!current.selectedProviderId) return true;
const providerId: string = current.selectedProviderId;
const query =
current.inputMode === 'context'
? getProviderQueryFromMention(
providerId,
parsed.query,
current.mcpServerName,
)
: current.query;
if (query !== current.query) {
const nextState: Omit<AtMentionMenuState, 'items' | 'loading'> = {
...current,
from: parsed.from,
to: parsed.to,
query,
selectedIndex: 0,
providers: providerViewsRef.current,
};
if (
providerId === MCP_RESOURCES_PROVIDER_ID &&
current.itemMode === 'mcpResources' &&
current.mcpServerName
) {
scheduleLoadMcpResourceItems(
current.mcpServerName,
query,
nextState,
{ validateServer: current.validateMcpServer },
);
return true;
}
scheduleLoadItems(providerId, query, nextState);
return true;
}
setMenu({
...current,
from: parsed.from,
to: parsed.to,
query,
providers: providerViewsRef.current,
});
return true;
}
const filteredProviders = providerViewsRef.current
.filter((provider) => {
return matchesQuery(
parsed.query,
provider.textValue,
provider.description,
);
})
.slice(0, ITEM_LIMIT);
if (
current?.level === 'categories' &&
current.from === parsed.from &&
current.to === parsed.to &&
current.query === parsed.query &&
equalProviderViews(current.providers, filteredProviders)
) {
return true;
}
if (filteredProviders.length === 0 && parsed.query) {
const insertedReference = splitInsertedReferenceQuery(
parsed.query,
lastSelectedProviderIdRef.current,
lastSelectedMcpServerNameRef.current,
);
if (
insertedReference &&
providerViewsRef.current.some(
(provider) => provider.id === insertedReference.providerId,
)
) {
if (
insertedReference.providerId === MCP_RESOURCES_PROVIDER_ID &&
insertedReference.serverName
) {
lastSelectedMcpServerNameRef.current = insertedReference.serverName;
scheduleLoadMcpResourceItems(
insertedReference.serverName,
insertedReference.itemQuery,
{
from: parsed.from,
to: parsed.to,
query: insertedReference.itemQuery,
level: 'items',
selectedProviderId: MCP_RESOURCES_PROVIDER_ID,
selectedIndex: 0,
providers: providerViewsRef.current,
itemMode: 'mcpResources',
mcpServerName: insertedReference.serverName,
fileDirectory: undefined,
inputMode: 'context',
validateMcpServer: insertedReference.validateServer,
},
{ validateServer: insertedReference.validateServer },
);
return true;
}
scheduleLoadItems(
insertedReference.providerId,
insertedReference.itemQuery,
{
from: parsed.from,
to: parsed.to,
query: insertedReference.itemQuery,
level: 'items',
selectedProviderId: insertedReference.providerId,
selectedIndex: 0,
providers: providerViewsRef.current,
itemMode: 'default',
mcpServerName: undefined,
fileDirectory:
insertedReference.providerId === FILE_PROVIDER_ID
? fileDirectoryRef.current
: undefined,
inputMode: 'context',
},
);
return true;
}
const prefixedProvider = providerViewsRef.current.find(
(provider) =>
!isBuiltinProviderId(provider.id) &&
parsed.query.startsWith(`${provider.id}:`),
);
if (prefixedProvider) {
const itemQuery = parsed.query.slice(prefixedProvider.id.length + 1);
scheduleLoadItems(prefixedProvider.id, itemQuery, {
from: parsed.from,
to: parsed.to,
query: itemQuery,
level: 'items',
selectedProviderId: prefixedProvider.id,
selectedIndex: 0,
providers: providerViewsRef.current,
itemMode: 'default',
mcpServerName: undefined,
fileDirectory: undefined,
inputMode: 'context',
});
return true;
}
if (
providerViewsRef.current.some(
(provider) => provider.id === FILE_PROVIDER_ID,
)
) {
fileDirectoryRef.current = '.';
scheduleLoadItems(FILE_PROVIDER_ID, parsed.query, {
from: parsed.from,
to: parsed.to,
query: parsed.query,
level: 'items',
selectedProviderId: FILE_PROVIDER_ID,
selectedIndex: 0,
providers: providerViewsRef.current,
itemMode: 'default',
mcpServerName: undefined,
fileDirectory: fileDirectoryRef.current,
inputMode: 'context',
});
return true;
}
}
setMenu({
from: parsed.from,
to: parsed.to,
query: parsed.query,
level: 'categories',
selectedIndex: 0,
providers: filteredProviders,
items: [],
loading: false,
inputMode: undefined,
});
return true;
},
[
close,
disabledRef,
scheduleLoadItems,
scheduleLoadMcpResourceItems,
setMenu,
shellModeRef,
],
);
const moveSelection = useCallback(
(direction: 'up' | 'down') => {
const current = stateRef.current;
if (!current) return false;
const total =
current.level === 'categories'
? current.providers.length
: current.items.length;
if (total <= 0) return true;
const nextIndex = nextSelectionIndex(
current.selectedIndex,
total,
direction,
);
// Keep arrow keys owned by the @ panel while it is open. Returning false
// at the boundary would fall through to the editor history navigation.
if (nextIndex === null) return true;
setMenu({ ...current, selectedIndex: nextIndex });
return true;
},
[setMenu],
);
const select = useCallback(
(index: number) => {
const current = stateRef.current;
if (!current) return false;
const total =
current.level === 'categories'
? current.providers.length
: current.items.length;
if (index < 0 || index >= total) return false;
setMenu({ ...current, selectedIndex: index });
return true;
},
[setMenu],
);
const enterCategory = useCallback(
(index?: number) => {
const current = stateRef.current;
if (!current || current.level !== 'categories') return false;
const provider = current.providers[index ?? current.selectedIndex];
if (!provider) return false;
lastSelectedProviderIdRef.current = provider.id;
if (provider.id === FILE_PROVIDER_ID) {
fileDirectoryRef.current = '.';
}
const selectedTabId = provider.tabs?.find((tab) => !tab.disabled)?.id;
scheduleLoadItems(provider.id, current.query, {
...current,
level: 'items',
selectedProviderId: provider.id,
selectedIndex: 0,
tabs: provider.tabs,
selectedTabId,
itemMode:
provider.id === MCP_RESOURCES_PROVIDER_ID ? 'mcpServers' : 'default',
mcpServerName: undefined,
fileDirectory:
provider.id === FILE_PROVIDER_ID
? fileDirectoryRef.current
: undefined,
inputMode: 'search',
});
return true;
},
[scheduleLoadItems],
);
const updateSearch = useCallback(
(query: string) => {
const current = stateRef.current;
if (
!current ||
current.level !== 'items' ||
!current.selectedProviderId
) {
return false;
}
const baseState: Omit<AtMentionMenuState, 'items' | 'loading'> = {
...current,
query,
selectedIndex: 0,
inputMode: 'search',
};
if (
current.selectedProviderId === MCP_RESOURCES_PROVIDER_ID &&
current.itemMode === 'mcpResources' &&
current.mcpServerName
) {
scheduleLoadMcpResourceItems(current.mcpServerName, query, baseState, {
validateServer: current.validateMcpServer,
});
return true;
}
scheduleLoadItems(current.selectedProviderId, query, baseState);
return true;
},
[scheduleLoadItems, scheduleLoadMcpResourceItems],
);
const selectTab = useCallback(
(tabId: string) => {
const current = stateRef.current;
if (
!current ||
current.level !== 'items' ||
!current.selectedProviderId ||
!current.tabs?.some((tab) => tab.id === tabId && !tab.disabled)
) {
return false;
}
if (current.selectedTabId === tabId) return true;
const baseState: Omit<AtMentionMenuState, 'items' | 'loading'> = {
...current,
selectedTabId: tabId,
selectedIndex: 0,
inputMode: 'search',
};
scheduleLoadItems(current.selectedProviderId, current.query, baseState);
return true;
},
[scheduleLoadItems],
);
const backToCategories = useCallback((): false | 'items' | 'categories' => {
const current = stateRef.current;
if (!current || current.level !== 'items') return false;
if (
current.selectedProviderId === MCP_RESOURCES_PROVIDER_ID &&
current.itemMode === 'mcpResources'
) {
scheduleLoadItems(MCP_RESOURCES_PROVIDER_ID, '', {
...current,
query: '',
selectedIndex: 0,
itemMode: 'mcpServers',
mcpServerName: undefined,
fileDirectory: undefined,
inputMode: 'search',
validateMcpServer: undefined,
});
return 'items';
}
if (current.selectedProviderId === FILE_PROVIDER_ID) {
const currentDir = normalizeDirectoryPath(
current.fileDirectory ?? fileDirectoryRef.current,
);
if (currentDir !== '.') {
fileDirectoryRef.current = parentDirectoryPath(currentDir);
scheduleLoadItems(FILE_PROVIDER_ID, '', {
...current,
query: '',
selectedIndex: 0,
fileDirectory: fileDirectoryRef.current,
inputMode: 'search',
});
return 'items';
}
}
setMenu({
...current,
level: 'categories',
selectedProviderId: undefined,
selectedIndex: 0,
query: '',
items: [],
loading: false,
itemMode: undefined,
mcpServerName: undefined,
fileDirectory: undefined,
inputMode: undefined,
tabs: undefined,
selectedTabId: undefined,
});
clearPendingLoad();
return 'categories';
}, [clearPendingLoad, scheduleLoadItems, setMenu]);
const accept = useCallback(
(index?: number) => {
const current = stateRef.current;
if (!current) return false;
if (current.level === 'categories') {
return enterCategory(index);
}
const view = viewRef.current;
if (!view) return false;
if (current.loading) return true;
const item = current.items[index ?? current.selectedIndex];
if (!item) return false;
if (current.selectedProviderId) {
lastSelectedProviderIdRef.current = current.selectedProviderId;
}
if (
current.selectedProviderId === FILE_PROVIDER_ID &&
item.kind === 'directory' &&
item.targetPath
) {
fileDirectoryRef.current = normalizeDirectoryPath(item.targetPath);
scheduleLoadItems(FILE_PROVIDER_ID, '', {
...current,
query: '',
selectedIndex: 0,
fileDirectory: fileDirectoryRef.current,
inputMode: 'search',
});
return true;
}
if (
current.selectedProviderId === MCP_RESOURCES_PROVIDER_ID &&
item.kind === 'mcp-server' &&
item.serverName
) {
lastSelectedMcpServerNameRef.current = item.serverName;
scheduleLoadMcpResourceItems(item.serverName, '', {
...current,
query: '',
selectedIndex: 0,
itemMode: 'mcpResources',
mcpServerName: item.serverName,
fileDirectory: undefined,
inputMode: 'search',
validateMcpServer: undefined,
});
return true;
}
const rawInsert =
item.insertText ??
`@${escapeAtReferenceText(sanitizeInsertText(item.label))} `;
const insert =
item.composerTag && !/\s$/.test(rawInsert)
? `${rawInsert} `
: rawInsert;
const docLength = view.state.doc.length;
if (
current.from < 0 ||
current.to < current.from ||
current.to > docLength
) {
console.warn('[@mention] stale insertion range', {
from: current.from,
to: current.to,
docLength,
});
close();
return true;
}
const currentMention = view.state.doc.sliceString(
current.from,
current.to,
);
if (!currentMention.startsWith('@')) {
console.warn('[@mention] stale insertion text', {
from: current.from,
to: current.to,
});
close();
return true;
}
preserveProviderSelectionRef.current = true;
const tag = createComposerTagForItem(
current.selectedProviderId,
item,
insert,
);
const tagText = insert.trimEnd();
const effects =
tag && createInlineTagEffect
? [
createInlineTagEffect({
from: current.from,
to: current.from + tagText.length,
tag,
}),
]
: undefined;
view.dispatch({
changes: { from: current.from, to: current.to, insert },
...(effects ? { effects } : {}),
selection: { anchor: current.from + insert.length },
scrollIntoView: true,
});
view.focus();
close({ preserveProviderSelection: true });
return true;
},
[
close,
enterCategory,
createInlineTagEffect,
scheduleLoadItems,
scheduleLoadMcpResourceItems,
viewRef,
],
);
return {
state,
close,
closeIfOpen,
refreshForView,
moveSelection,
select,
accept,
enterCategory,
selectTab,
backToCategories,
updateSearch,
};
}