feat(daemon): support @extension mentions (#6008)

This commit is contained in:
callmeYe 2026-06-30 00:30:47 +08:00 committed by GitHub
parent 565e4bc437
commit d442a150e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 656 additions and 201 deletions

View file

@ -0,0 +1,22 @@
# Daemon @extension Mention Support
## Goal
Daemon WebShell should match the CLI extension mention behavior for active extensions. Users can discover active extensions from `@` completion, select a canonical `@ext:<name>` mention, and have the daemon inject that extension's context into the model turn without changing the visible prompt text.
## Design
- WebShell `@` completion combines active extension entries from workspace extension status with existing workspace file matches. Bare `@` shows extensions first, `@bro` filters extensions and files, and `@ext:` switches to extension-only completion.
- Extension completion inserts `@ext:<extension.name> ` so the daemon receives a stable reference independent of display text.
- Daemon extension status includes an optional `description` field populated from installed extension config. The field is additive for older clients.
- ACP session prompt resolution scans text prompt blocks for `@ext:<name>` tokens, matches only active extensions from session config, dedupes repeated mentions, and silently skips unknown or inactive names.
- The user-visible text is preserved exactly. Resolved extension context is appended as extra model text parts after the user's text.
- CLI and daemon share extension mention helpers for parsing, sanitizing display text, formatting capabilities, and reading context files with subpath and size guards.
## Bounds
Context file reads are limited per file and by aggregate extension context budget. Files outside the installed extension directory are skipped, unreadable files are skipped with debug output, and repeated mentions consume budget once.
## Verification
Targeted tests cover WebShell completion modes, daemon ACP context injection, repeated and unknown mentions, bounded context files, and the existing CLI extension mention processors. Final verification runs the repository build and typecheck.

View file

@ -1003,6 +1003,7 @@ export interface ServeExtensionEntry {
id: string;
name: string;
displayName?: string;
description?: string;
version: string;
isActive: boolean;
path: string;

View file

@ -4771,6 +4771,9 @@ class QwenAgent implements Agent {
id: ext.id,
name: ext.name,
displayName: ext.displayName,
...(ext.config.description
? { description: ext.config.description }
: {}),
version: ext.version,
isActive: ext.isActive,
path: ext.path,

View file

@ -15,7 +15,12 @@ import {
Session,
} from './Session.js';
import type { Content, FunctionCall, Part } from '@google/genai';
import type { ChatRecord, Config, GeminiChat } from '@qwen-code/qwen-code-core';
import type {
ChatRecord,
Config,
Extension,
GeminiChat,
} from '@qwen-code/qwen-code-core';
import {
ApprovalMode,
AuthType,
@ -295,6 +300,48 @@ describe('Session', () => {
};
}
function makeExtension(overrides: Partial<Extension> = {}): Extension {
return {
id: 'browser',
name: 'browser',
displayName: 'Browser',
version: '1.0.0',
isActive: true,
path: process.cwd(),
config: {
name: 'browser',
version: '1.0.0',
description: 'Browser automation',
},
mcpServers: {
'browser-mcp': {
command: 'node',
},
},
contextFiles: [],
skills: [
{
name: 'browser-skill',
description: 'Use browser tools',
path: 'skills/browser/SKILL.md',
},
],
...overrides,
} as Extension;
}
function firstSentMessage(): Part[] {
const call = vi.mocked(mockChat.sendMessageStream).mock.calls[0];
const request = call?.[1] as { message?: Part[] } | undefined;
return request?.message ?? [];
}
function textParts(parts: Part[]): string[] {
return parts.flatMap((part) =>
typeof part.text === 'string' ? [part.text] : [],
);
}
beforeEach(() => {
currentModel = 'qwen3-code-plus';
currentAuthType = AuthType.USE_OPENAI;
@ -6487,6 +6534,101 @@ describe('Session', () => {
}
});
it('injects active extension context for @ext mentions', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-acp-ext-'));
const contextFile = path.join(tempDir, 'context.md');
try {
await fs.writeFile(contextFile, 'extension context file', 'utf8');
const extension = makeExtension({
path: tempDir,
contextFiles: [contextFile],
});
mockConfig.getActiveExtensions = vi.fn().mockReturnValue([extension]);
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue(createEmptyStream());
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'Use @ext:browser now' }],
});
const message = firstSentMessage();
expect(message[0]).toEqual({ text: 'Use @ext:browser now' });
const sentText = textParts(message).join('\n');
expect(sentText).toContain(
'--- Extension: Browser (untrusted third-party content) ---',
);
expect(sentText).toContain('Browser automation');
expect(sentText).toContain(
'- Skills: browser-skill (invoke via /<skill-name>)',
);
expect(sentText).toContain('- MCP Servers: browser-mcp');
expect(sentText).toContain('extension context file');
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it('dedupes repeated extension mentions and skips unknown mentions', async () => {
const extension = makeExtension();
mockConfig.getActiveExtensions = vi.fn().mockReturnValue([extension]);
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue(createEmptyStream());
await session.prompt({
sessionId: 'test-session-id',
prompt: [
{
type: 'text',
text: 'Use @ext:browser and @ext:browser and @ext:missing',
},
],
});
const sentText = textParts(firstSentMessage()).join('\n');
expect(sentText.match(/--- Extension: Browser/g)).toHaveLength(1);
expect(sentText).not.toContain('Extension: missing');
});
it('caps extension context files and skips files outside the extension', async () => {
const tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'qwen-acp-ext-cap-'),
);
const outsideDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'qwen-acp-ext-outside-'),
);
const bigFile = path.join(tempDir, 'big.md');
const outsideFile = path.join(outsideDir, 'secret.md');
try {
await fs.writeFile(bigFile, 'x'.repeat(60_000), 'utf8');
await fs.writeFile(outsideFile, 'do not inject this secret', 'utf8');
const extension = makeExtension({
path: tempDir,
contextFiles: [bigFile, outsideFile],
});
mockConfig.getActiveExtensions = vi.fn().mockReturnValue([extension]);
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue(createEmptyStream());
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: '@ext:browser' }],
});
const sentText = textParts(firstSentMessage()).join('\n');
expect(sentText).toContain('... (truncated)');
expect(sentText).not.toContain('do not inject this secret');
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
await fs.rm(outsideDir, { recursive: true, force: true });
}
});
it('runs prompt inside runtime output dir context', async () => {
const runtimeDir = path.resolve('runtime', 'from-settings');
core.Storage.setRuntimeBaseDir(runtimeDir);

View file

@ -160,6 +160,12 @@ import {
import { parseAcpModelOption } from '../../utils/acpModelUtils.js';
import { classifyApiError } from '../../ui/hooks/useGeminiStream.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import {
buildExtensionMentionContext,
EXTENSION_CONTEXT_BUDGET,
matchExtensionByRef,
parseExtensionRef,
} from '../../utils/extension-mention.js';
// Import modular session components
import type {
@ -555,6 +561,22 @@ function isUserPromptRecord(record: ChatRecord): boolean {
);
}
const AT_TOKEN_RE = /@([^\s,;!?()[\]{}]+)/g;
function collectExtensionMentionRefs(
text: string,
mentions: Map<string, string>,
): void {
for (const match of text.matchAll(AT_TOKEN_RE)) {
const pathName = match[1];
if (!pathName) continue;
const ref = parseExtensionRef(pathName);
if (ref) {
mentions.set(ref.name.toLowerCase(), ref.name);
}
}
}
export interface AvailableCommandsSnapshot {
availableCommands: AvailableCommand[];
availableSkills?: string[];
@ -5002,10 +5024,12 @@ export class Session implements SessionContext {
const FILE_URI_SCHEME = 'file://';
const embeddedContext: EmbeddedResourceResource[] = [];
const extensionMentions = new Map<string, string>();
const parts = message.map((part) => {
switch (part.type) {
case 'text':
collectExtensionMentionRefs(part.text, extensionMentions);
return { text: part.text };
case 'image':
case 'audio':
@ -5040,9 +5064,21 @@ export class Session implements SessionContext {
});
const atPathCommandParts = parts.filter((part) => 'fileData' in part);
const extensionParts = await this.#resolveExtensionMentionParts(
extensionMentions,
abortSignal,
);
if (
atPathCommandParts.length === 0 &&
embeddedContext.length === 0 &&
extensionParts.length === 0
) {
return parts;
}
if (atPathCommandParts.length === 0 && embeddedContext.length === 0) {
return parts;
return [...parts, ...extensionParts];
}
// Extract paths from @ commands - pass directly to readManyFiles without filtering
@ -5085,6 +5121,7 @@ export class Session implements SessionContext {
// Add initial query text first
processedQueryParts.push({ text: initialQueryText });
processedQueryParts.push(...extensionParts);
// Then add content parts (preserving binary files as inlineData)
for (const part of contentParts) {
@ -5096,6 +5133,7 @@ export class Session implements SessionContext {
}
} else {
processedQueryParts.push({ text: initialQueryText.trim() });
processedQueryParts.push(...extensionParts);
}
// Process embedded context from resource blocks
@ -5122,6 +5160,39 @@ export class Session implements SessionContext {
return processedQueryParts;
}
async #resolveExtensionMentionParts(
extensionMentions: Map<string, string>,
abortSignal: AbortSignal,
): Promise<Part[]> {
if (extensionMentions.size === 0) return [];
const activeExtensions = this.config.getActiveExtensions?.() ?? [];
if (activeExtensions.length === 0) return [];
const extensionParts: Part[] = [];
const resolvedExtensionNames = new Set<string>();
let remainingBudget = EXTENSION_CONTEXT_BUDGET;
for (const name of extensionMentions.values()) {
const extension = matchExtensionByRef(name, activeExtensions);
if (!extension) {
this.debug(
`Extension "${name}" not found among active extensions. ` +
`Available: ${activeExtensions.map((e) => e.name).join(', ') || '(none)'}`,
);
continue;
}
if (resolvedExtensionNames.has(extension.name)) continue;
resolvedExtensionNames.add(extension.name);
const context = await buildExtensionMentionContext(extension, {
remainingBudget,
signal: abortSignal,
onDebugMessage: (message) => this.debug(message),
});
remainingBudget = context.remainingBudget;
extensionParts.push({ text: context.text });
}
return extensionParts;
}
debug(msg: string): void {
if (this.config.getDebugMode()) {
debugLogger.warn(msg);

View file

@ -485,6 +485,9 @@ export function registerWorkspaceExtensionRoutes(
id: ext.id,
name: ext.name,
...(ext.displayName ? { displayName: ext.displayName } : {}),
...(ext.config.description
? { description: ext.config.description }
: {}),
version: ext.version,
isActive: ext.isActive,
path: ext.path,

View file

@ -30,10 +30,13 @@ import { matchMcpServerPrefix } from './mcpResourceRef.js';
import {
parseExtensionRef,
matchExtensionByRef,
buildExtensionContextText,
buildExtensionRef,
sanitizeDisplayText,
} from './extension-mention-ref.js';
import {
buildExtensionMentionContext,
EXTENSION_CONTEXT_BUDGET,
getExtensionDisplayName,
} from '../../utils/extension-mention.js';
export interface ResolveAtCommandParams {
query: string;
@ -506,8 +509,6 @@ export async function resolveAtCommandQuery({
// in the file-read error path (mirroring how resourceDisplays/resourceLabels
// are already built before the file read).
// Aggregate cap across all extensions to prevent unbounded context injection.
const EXTENSION_CONTEXT_BUDGET = 200_000; // 200KB total
const PER_FILE_CAP = 50_000; // 50KB per context file
let extensionContextBudgetRemaining = EXTENSION_CONTEXT_BUDGET;
const extensionParts: Part[] = [];
@ -515,68 +516,17 @@ export async function resolveAtCommandQuery({
const extensionLabels: string[] = [];
for (let i = 0; i < extensionMentions.length; i++) {
const { extension } = extensionMentions[i];
const displayName =
sanitizeDisplayText(extension.displayName || extension.name) ||
extension.name;
const displayName = getExtensionDisplayName(extension);
const callId = `client-extension-${userMessageTimestamp}-${i}`;
let contextText = buildExtensionContextText(extension);
const context = await buildExtensionMentionContext(extension, {
remainingBudget: extensionContextBudgetRemaining,
signal,
onDebugMessage,
});
extensionContextBudgetRemaining = context.remainingBudget;
// Read extension context files in parallel, with symlink-aware path
// traversal and budget checks.
if (extension.contextFiles && extension.contextFiles.length > 0) {
const fileReads = await Promise.allSettled(
extension.contextFiles.map(async (contextFilePath) => {
// Resolve symlinks to prevent path traversal via symlinks within
// the extension directory (e.g., context.md -> ~/.ssh/id_rsa).
let realPath: string;
let realExtPath: string;
try {
realPath = await fs.realpath(contextFilePath);
realExtPath = await fs.realpath(extension.path);
} catch {
onDebugMessage(
`Skipping unreadable context file: ${contextFilePath}`,
);
return null;
}
if (!isSubpath(realExtPath, realPath)) {
onDebugMessage(
`Skipping context file outside extension directory: ${contextFilePath}`,
);
return null;
}
return fs.readFile(realPath, { encoding: 'utf-8', signal });
}),
);
for (let j = 0; j < fileReads.length; j++) {
const outcome = fileReads[j];
if (outcome.status === 'rejected') {
onDebugMessage(
`Failed to read extension context file ${extension.contextFiles[j]}: ${getErrorMessage(outcome.reason)}`,
);
continue;
}
const content = outcome.value;
if (!content || !content.trim()) continue;
if (extensionContextBudgetRemaining <= 0) {
onDebugMessage(
`Extension context budget exhausted, skipping remaining files.`,
);
break;
}
const cap = Math.min(PER_FILE_CAP, extensionContextBudgetRemaining);
const cappedContent =
content.length > cap
? content.slice(0, cap) + '\n... (truncated)'
: content;
contextText += `\n\n${cappedContent}`;
extensionContextBudgetRemaining -= cappedContent.length;
}
}
extensionParts.push({ text: contextText });
extensionParts.push({ text: context.text });
extensionLabels.push(buildExtensionRef(extension.name));
extensionDisplays.push({
callId,

View file

@ -4,77 +4,22 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
type Config,
type Extension,
stripTerminalControlSequences,
} from '@qwen-code/qwen-code-core';
import type { Config } from '@qwen-code/qwen-code-core';
import type { Suggestion } from '../components/SuggestionsDisplay.js';
import { MAX_SUGGESTIONS_TO_SHOW } from '../components/SuggestionsDisplay.js';
import { t } from '../../i18n/index.js';
/**
* Prefix used for extension mention references. When the user selects an
* extension from the `@` autocomplete, the inserted value is `ext:<name>`.
* This prefix disambiguates extension mentions from file paths and MCP
* resource references at parse time.
*/
export const EXTENSION_REF_PREFIX = 'ext:';
/**
* Parses an `ext:<name>` reference string. Returns the extension name
* portion if the input starts with the extension prefix, or `null` otherwise.
*/
export function parseExtensionRef(pathName: string): { name: string } | null {
if (!pathName.startsWith(EXTENSION_REF_PREFIX)) return null;
const name = pathName.slice(EXTENSION_REF_PREFIX.length);
if (!name) return null;
return { name };
}
/**
* Builds the canonical `ext:<name>` reference string for an extension.
*/
export function buildExtensionRef(extensionName: string): string {
return `${EXTENSION_REF_PREFIX}${extensionName}`;
}
/**
* Case-insensitive match of an extension name against a list of extensions.
* Matches against `extension.name` and `extension.config.name` (the canonical
* slugs). `displayName` is intentionally excluded: it often contains spaces
* which the `@`-path parser truncates at, so matching would be unreliable.
*/
export function matchExtensionByRef(
name: string,
extensions: Extension[],
): Extension | undefined {
const lower = name.toLowerCase();
return extensions.find(
(ext) =>
ext.name.toLowerCase() === lower ||
ext.config.name.toLowerCase() === lower,
);
}
/**
* Regex matching Unicode bidi override/isolate/mark characters that
* `stripTerminalControlSequences` leaves intact.
*/
const BIDI_CONTROL_RE = /[؜]/g;
/**
* Normalizes an untrusted display string for safe UI rendering. Strips
* terminal control sequences and bidi overrides, collapses/trims whitespace,
* and returns `null` when no safe text remains.
*/
export function sanitizeDisplayText(raw: string): string | null {
const stripped = stripTerminalControlSequences(raw)
.replace(BIDI_CONTROL_RE, '')
.replace(/\s+/g, ' ')
.trim();
return stripped.length > 0 ? stripped : null;
}
export {
EXTENSION_REF_PREFIX,
parseExtensionRef,
buildExtensionRef,
matchExtensionByRef,
sanitizeDisplayText,
buildExtensionContextText,
} from '../../utils/extension-mention.js';
import {
buildExtensionRef,
sanitizeDisplayText,
} from '../../utils/extension-mention.js';
/**
* Returns autocomplete suggestions for extensions matching the given pattern.
@ -120,59 +65,3 @@ export function getExtensionSuggestions(
isDirectory: false,
}));
}
/**
* Builds a structured context text block for an extension that has been
* @-mentioned. This text is injected into the user message so the model
* knows about the extension's capabilities.
*/
export function buildExtensionContextText(extension: Extension): string {
const displayName =
sanitizeDisplayText(extension.displayName || extension.name) ||
extension.name;
const lines: string[] = [];
lines.push(
`--- Extension: ${displayName} (untrusted third-party content) ---`,
);
if (extension.config.description) {
const desc = sanitizeDisplayText(extension.config.description);
if (desc) {
lines.push(desc);
lines.push('');
}
}
const capabilities: string[] = [];
if (extension.skills && extension.skills.length > 0) {
const skillNames = extension.skills
.map((s) => sanitizeDisplayText(s.name) || s.name)
.join(', ');
capabilities.push(`- Skills: ${skillNames} (invoke via /<skill-name>)`);
}
if (extension.mcpServers && Object.keys(extension.mcpServers).length > 0) {
const serverNames = Object.keys(extension.mcpServers)
.map((n) => sanitizeDisplayText(n) || n)
.join(', ');
capabilities.push(`- MCP Servers: ${serverNames}`);
}
if (extension.agents && extension.agents.length > 0) {
const agentNames = extension.agents
.map((a) => sanitizeDisplayText(a.name) || a.name)
.join(', ');
capabilities.push(`- Agents: ${agentNames}`);
}
if (capabilities.length > 0) {
lines.push('Available capabilities from this extension:');
lines.push(...capabilities);
lines.push('');
}
lines.push(`--- End Extension: ${displayName} ---`);
return lines.join('\n');
}

View file

@ -0,0 +1,179 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import type { Extension } from '@qwen-code/qwen-code-core';
import {
getErrorMessage,
isSubpath,
stripTerminalControlSequences,
} from '@qwen-code/qwen-code-core';
export const EXTENSION_REF_PREFIX = 'ext:';
export const EXTENSION_CONTEXT_BUDGET = 200_000;
export const EXTENSION_CONTEXT_FILE_CAP = 50_000;
/**
* Parses an `ext:<name>` reference string. Returns the extension name
* portion if the input starts with the extension prefix, or `null` otherwise.
*/
export function parseExtensionRef(pathName: string): { name: string } | null {
if (!pathName.startsWith(EXTENSION_REF_PREFIX)) return null;
const name = pathName.slice(EXTENSION_REF_PREFIX.length);
if (!name) return null;
return { name };
}
export function buildExtensionRef(extensionName: string): string {
return `${EXTENSION_REF_PREFIX}${extensionName}`;
}
export function matchExtensionByRef(
name: string,
extensions: Extension[],
): Extension | undefined {
const lower = name.toLowerCase();
return extensions.find(
(ext) =>
ext.name.toLowerCase() === lower ||
ext.config.name.toLowerCase() === lower,
);
}
const BIDI_CONTROL_RE = /[؜]/g;
export function sanitizeDisplayText(raw: string): string | null {
const stripped = stripTerminalControlSequences(raw)
.replace(BIDI_CONTROL_RE, '')
.replace(/\s+/g, ' ')
.trim();
return stripped.length > 0 ? stripped : null;
}
export function getExtensionDisplayName(extension: Extension): string {
return (
sanitizeDisplayText(extension.displayName || extension.name) ||
extension.name
);
}
export function buildExtensionContextText(extension: Extension): string {
const displayName = getExtensionDisplayName(extension);
const lines: string[] = [];
lines.push(
`--- Extension: ${displayName} (untrusted third-party content) ---`,
);
if (extension.config.description) {
const desc = sanitizeDisplayText(extension.config.description);
if (desc) {
lines.push(desc);
lines.push('');
}
}
const capabilities: string[] = [];
if (extension.skills && extension.skills.length > 0) {
const skillNames = extension.skills
.map((s) => sanitizeDisplayText(s.name) || s.name)
.join(', ');
capabilities.push(`- Skills: ${skillNames} (invoke via /<skill-name>)`);
}
if (extension.mcpServers && Object.keys(extension.mcpServers).length > 0) {
const serverNames = Object.keys(extension.mcpServers)
.map((n) => sanitizeDisplayText(n) || n)
.join(', ');
capabilities.push(`- MCP Servers: ${serverNames}`);
}
if (extension.agents && extension.agents.length > 0) {
const agentNames = extension.agents
.map((a) => sanitizeDisplayText(a.name) || a.name)
.join(', ');
capabilities.push(`- Agents: ${agentNames}`);
}
if (capabilities.length > 0) {
lines.push('Available capabilities from this extension:');
lines.push(...capabilities);
lines.push('');
}
lines.push(`--- End Extension: ${displayName} ---`);
return lines.join('\n');
}
export async function buildExtensionMentionContext(
extension: Extension,
options: {
remainingBudget: number;
signal?: AbortSignal;
onDebugMessage?: (message: string) => void;
},
): Promise<{ text: string; remainingBudget: number }> {
let contextText = buildExtensionContextText(extension);
let remainingBudget = options.remainingBudget;
if (extension.contextFiles.length === 0) {
return { text: contextText, remainingBudget };
}
const fileReads = await Promise.allSettled(
extension.contextFiles.map(async (contextFilePath) => {
let realPath: string;
let realExtPath: string;
try {
realPath = await fs.realpath(contextFilePath);
realExtPath = await fs.realpath(extension.path);
} catch {
options.onDebugMessage?.(
`Skipping unreadable context file: ${contextFilePath}`,
);
return null;
}
if (!isSubpath(realExtPath, realPath)) {
options.onDebugMessage?.(
`Skipping context file outside extension directory: ${contextFilePath}`,
);
return null;
}
return fs.readFile(realPath, {
encoding: 'utf-8',
signal: options.signal,
});
}),
);
for (let i = 0; i < fileReads.length; i++) {
const outcome = fileReads[i];
if (outcome.status === 'rejected') {
options.onDebugMessage?.(
`Failed to read extension context file ${extension.contextFiles[i]}: ${getErrorMessage(outcome.reason)}`,
);
continue;
}
const content = outcome.value;
if (!content || !content.trim()) continue;
if (remainingBudget <= 0) {
options.onDebugMessage?.(
'Extension context budget exhausted, skipping remaining files.',
);
break;
}
const cap = Math.min(EXTENSION_CONTEXT_FILE_CAP, remainingBudget);
const cappedContent =
content.length > cap
? content.slice(0, cap) + '\n... (truncated)'
: content;
contextText += `\n\n${cappedContent}`;
remainingBudget -= cappedContent.length;
}
return { text: contextText, remainingBudget };
}

View file

@ -1935,6 +1935,7 @@ export interface DaemonExtensionEntry {
id: string;
name: string;
displayName?: string;
description?: string;
version: string;
isActive: boolean;
path: string;

View file

@ -0,0 +1,109 @@
import { describe, expect, it, vi } from 'vitest';
import { CompletionContext } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state';
import {
atCompletionSource,
type ExtensionCompletionEntry,
type GlobFn,
} from './atCompletion';
const extensions: ExtensionCompletionEntry[] = [
{
name: 'browser',
displayName: 'Browser',
description: 'Browser automation',
isActive: true,
},
{
name: 'review-tools',
displayName: 'Review Tools',
description: 'Review code',
isActive: true,
},
{
name: 'inactive',
displayName: 'Inactive',
isActive: false,
},
];
function context(doc: string): CompletionContext {
return new CompletionContext(EditorState.create({ doc }), doc.length, true);
}
describe('atCompletionSource', () => {
it('shows active extensions and files for bare @', async () => {
const glob = vi.fn<GlobFn>().mockResolvedValue({
matches: ['README.md', 'src/index.ts'],
});
const result = await atCompletionSource(
context('@'),
() => glob,
() => async () => ({ extensions }),
);
expect(result?.from).toBe(0);
expect(result?.options.map((option) => option.label)).toEqual([
'@ext:browser',
'@ext:review-tools',
'@README.md',
'@src/index.ts',
]);
expect(glob).toHaveBeenCalledWith('**/*', { maxResults: 50 });
});
it('filters extensions and files by partial input', async () => {
const glob = vi.fn<GlobFn>().mockResolvedValue({
matches: ['browser-test.ts'],
});
const result = await atCompletionSource(
context('@bro'),
() => glob,
() => async () => ({ extensions }),
);
expect(result?.options.map((option) => option.label)).toEqual([
'@ext:browser',
'@browser-test.ts',
]);
expect(glob).toHaveBeenCalledWith('bro*', { maxResults: 50 });
});
it('shows only extensions for @ext: completion', async () => {
const glob = vi.fn<GlobFn>().mockResolvedValue({
matches: ['ext:file.ts'],
});
const result = await atCompletionSource(
context('@ext:rev'),
() => glob,
() => async () => ({ extensions }),
);
expect(result?.options.map((option) => option.label)).toEqual([
'@ext:review-tools',
]);
expect(result?.options[0]?.apply).toBe('@ext:review-tools ');
expect(glob).not.toHaveBeenCalled();
});
it('returns file completions when extension status fails', async () => {
const glob = vi.fn<GlobFn>().mockResolvedValue({
matches: ['README.md'],
});
const result = await atCompletionSource(
context('@'),
() => glob,
() => async () => {
throw new Error('boom');
},
);
expect(result?.options.map((option) => option.label)).toEqual([
'@README.md',
]);
});
});

View file

@ -8,39 +8,67 @@ export type GlobFn = (
opts?: { maxResults?: number },
) => Promise<{ matches: string[] }>;
export interface ExtensionCompletionEntry {
name: string;
displayName?: string;
description?: string;
isActive: boolean;
}
export type LoadExtensionsFn = () => Promise<{
extensions: ExtensionCompletionEntry[];
}>;
export function createAtCompletionSource(
getGlob: () => GlobFn | undefined,
getLoadExtensions: () => LoadExtensionsFn | undefined = () => undefined,
): (
context: CompletionContext,
) => CompletionResult | null | Promise<CompletionResult | null> {
return (context) => atCompletionSource(context, getGlob);
return (context) => atCompletionSource(context, getGlob, getLoadExtensions);
}
export function atCompletionSource(
context: CompletionContext,
getGlob: () => GlobFn | undefined,
getLoadExtensions: () => LoadExtensionsFn | undefined = () => undefined,
): CompletionResult | null | Promise<CompletionResult | null> {
const line = context.state.doc.lineAt(context.pos);
const textBefore = line.text.slice(0, context.pos - line.from);
const match = textBefore.match(/@([\w./-]*)$/);
const match = textBefore.match(/@([\w./:-]*)$/);
if (!match) return null;
const glob = getGlob();
if (!glob) return null;
const prefix = match[1];
const atPos = context.pos - match[0].length;
const extensionOnly = prefix.startsWith('ext:');
const extensionQuery = extensionOnly ? prefix.slice('ext:'.length) : prefix;
const glob = extensionOnly ? undefined : getGlob();
const loadExtensions = getLoadExtensions();
return fetchFiles(prefix, glob).then((files) => {
if (files.length === 0) return null;
return Promise.all([
fetchExtensions(extensionQuery, loadExtensions),
glob ? fetchFiles(prefix, glob) : Promise.resolve([]),
]).then(([extensions, files]) => {
if (extensions.length === 0 && files.length === 0) return null;
return {
from: atPos,
options: files.map((f) => ({
label: `@${f}`,
apply: `@${f} `,
type: 'file',
})),
options: [
...extensions.map((ext) => ({
label: `@ext:${ext.name}`,
apply: `@ext:${ext.name} `,
detail: extensionDetail(ext),
type: 'keyword',
section: 'Extensions',
boost: 20,
})),
...files.map((f) => ({
label: `@${f}`,
apply: `@${f} `,
type: 'file',
section: 'Files',
})),
],
filter: false,
};
});
@ -55,3 +83,59 @@ async function fetchFiles(prefix: string, glob: GlobFn): Promise<string[]> {
return [];
}
}
async function fetchExtensions(
query: string,
loadExtensions: LoadExtensionsFn | undefined,
): Promise<ExtensionCompletionEntry[]> {
if (!loadExtensions) return [];
try {
const status = await loadExtensions();
const lowerQuery = query.toLowerCase();
return status.extensions
.filter((ext) => ext.isActive)
.map((ext) => ({
...ext,
displayName: sanitizeDisplayText(ext.displayName ?? '') ?? undefined,
description: sanitizeDisplayText(ext.description ?? '') ?? undefined,
}))
.filter((ext) => {
const name = ext.name.toLowerCase();
const display = ext.displayName?.toLowerCase() ?? '';
return name.includes(lowerQuery) || display.includes(lowerQuery);
})
.sort((a, b) => {
const aLabel = (a.displayName || a.name).toLowerCase();
const bLabel = (b.displayName || b.name).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, 50);
} catch {
return [];
}
}
function extensionDetail(ext: ExtensionCompletionEntry): string | undefined {
const display =
ext.displayName && ext.displayName !== ext.name
? ext.displayName
: undefined;
if (display && ext.description) return `${display} - ${ext.description}`;
return display ?? ext.description;
}
const ESC = String.fromCharCode(27);
const ANSI_RE = new RegExp(`${ESC}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~])`, 'g');
const BIDI_CONTROL_RE = /[؜]/g;
function sanitizeDisplayText(raw: string): string | null {
const stripped = raw
.replace(ANSI_RE, '')
.replace(BIDI_CONTROL_RE, '')
.replace(/\s+/g, ' ')
.trim();
return stripped.length > 0 ? stripped : null;
}

View file

@ -1339,6 +1339,7 @@ export function useComposerCore(
const completionSources = [
createAtCompletionSource(
() => workspaceActionsRef.current?.globWorkspace,
() => workspaceActionsRef.current?.loadExtensionsStatus,
),
];