Repository navigation
feat(cli): support @extension mention in input autocomplete #5849
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
f8703be
0393b5f
ff01fa3
b45f061
ee637fe
fcdbcaf
54a77c6
1a2e3cd
acc30c9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -7,7 +7,7 @@ | |||||||||||||||||||||||||
| import * as fs from 'node:fs/promises'; | ||||||||||||||||||||||||||
| import * as path from 'node:path'; | ||||||||||||||||||||||||||
| import type { Part, PartListUnion } from '@google/genai'; | ||||||||||||||||||||||||||
| import type { Config } from '@qwen-code/qwen-code-core'; | ||||||||||||||||||||||||||
| import type { Config, Extension } from '@qwen-code/qwen-code-core'; | ||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||
| getErrorMessage, | ||||||||||||||||||||||||||
| isNodeError, | ||||||||||||||||||||||||||
|
|
@@ -19,6 +19,7 @@ import { | |||||||||||||||||||||||||
| emptyMcpResourceText, | ||||||||||||||||||||||||||
| formatMcpResourceContents, | ||||||||||||||||||||||||||
| summarizeMcpResource, | ||||||||||||||||||||||||||
| stripTerminalControlSequences, | ||||||||||||||||||||||||||
| } from '@qwen-code/qwen-code-core'; | ||||||||||||||||||||||||||
| import type { | ||||||||||||||||||||||||||
| HistoryItemToolGroup, | ||||||||||||||||||||||||||
|
|
@@ -27,6 +28,11 @@ import type { | |||||||||||||||||||||||||
| } from '../types.js'; | ||||||||||||||||||||||||||
| import { ToolCallStatus } from '../types.js'; | ||||||||||||||||||||||||||
| import { matchMcpServerPrefix } from './mcpResourceRef.js'; | ||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||
| parseExtensionRef, | ||||||||||||||||||||||||||
| matchExtensionByRef, | ||||||||||||||||||||||||||
| buildExtensionContextText, | ||||||||||||||||||||||||||
| } from './extension-mention-ref.js'; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export interface ResolveAtCommandParams { | ||||||||||||||||||||||||||
| query: string; | ||||||||||||||||||||||||||
|
|
@@ -205,6 +211,13 @@ export async function resolveAtCommandQuery({ | |||||||||||||||||||||||||
| uri: string; | ||||||||||||||||||||||||||
| }> = []; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Extension references (`@ext:<name>`) collected during the loop. | ||||||||||||||||||||||||||
| const activeExtensions = config.getActiveExtensions?.() ?? []; | ||||||||||||||||||||||||||
| const extensionMentions: Array<{ | ||||||||||||||||||||||||||
| originalAtPath: string; | ||||||||||||||||||||||||||
| extension: Extension; | ||||||||||||||||||||||||||
| }> = []; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| for (const atPathPart of atPathCommandParts) { | ||||||||||||||||||||||||||
| const originalAtPath = atPathPart.content; // e.g., "@file.txt" or "@" | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
@@ -217,6 +230,28 @@ export async function resolveAtCommandQuery({ | |||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const pathName = originalAtPath.substring(1); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Extension reference (`@ext:<name>`): detected BEFORE MCP/filesystem | ||||||||||||||||||||||||||
| // resolution. Only matches when the path starts with `ext:` and the name | ||||||||||||||||||||||||||
| // corresponds to an active extension. | ||||||||||||||||||||||||||
| const extRef = parseExtensionRef(pathName); | ||||||||||||||||||||||||||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. By design — |
||||||||||||||||||||||||||
| if (extRef) { | ||||||||||||||||||||||||||
| const extension = matchExtensionByRef(extRef.name, activeExtensions); | ||||||||||||||||||||||||||
| if (extension) { | ||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] No deduplication of extension mentions. If the user types
Suggested change
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — added dedup check: |
||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||
| !extensionMentions.some((m) => m.extension.name === extension.name) | ||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||
| extensionMentions.push({ originalAtPath, extension }); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| atPathToResolvedSpecMap.set(originalAtPath, pathName); | ||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — unmatched |
||||||||||||||||||||||||||
| onDebugMessage( | ||||||||||||||||||||||||||
| `Extension "${extRef.name}" not found among active extensions. ` + | ||||||||||||||||||||||||||
| `Available: ${activeExtensions.map((e) => e.name).join(', ') || '(none)'}`, | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Already fixed — unmatched |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // MCP resource reference (`@server:uri`): detected BEFORE filesystem | ||||||||||||||||||||||||||
| // resolution so a resource URI containing ':' / '//' isn't mistaken for | ||||||||||||||||||||||||||
| // a path. Only matches when `server` is a configured MCP server; all | ||||||||||||||||||||||||||
|
|
@@ -444,8 +479,12 @@ export async function resolveAtCommandQuery({ | |||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Fallback for lone "@" or completely invalid @-commands resulting in empty | ||||||||||||||||||||||||||
| // initialQueryText — only when there is nothing to read at all (no valid | ||||||||||||||||||||||||||
| // file paths AND no resource references). | ||||||||||||||||||||||||||
| if (pathSpecsToRead.length === 0 && mcpResourceRefs.length === 0) { | ||||||||||||||||||||||||||
| // file paths, resource references, or extension mentions). | ||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||
| pathSpecsToRead.length === 0 && | ||||||||||||||||||||||||||
| mcpResourceRefs.length === 0 && | ||||||||||||||||||||||||||
| extensionMentions.length === 0 | ||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||
| onDebugMessage('No valid file paths found in @ commands to read.'); | ||||||||||||||||||||||||||
| if (initialQueryText === '@' && query.trim() === '@') { | ||||||||||||||||||||||||||
| // If the only thing was a lone @, pass original query (which might have spaces) | ||||||||||||||||||||||||||
|
|
@@ -461,8 +500,83 @@ export async function resolveAtCommandQuery({ | |||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Build extension context parts and display cards for @-mentioned extensions. | ||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Missing integration tests for security boundary code 113 lines of new integration logic — path traversal guard ( The existing
— qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||
| // Processed BEFORE file reads so that extension labels/displays are available | ||||||||||||||||||||||||||
| // 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 | ||||||||||||||||||||||||||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The budget is an approximate guard against context blowup, not a precise byte counter. |
||||||||||||||||||||||||||
| const PER_FILE_CAP = 50_000; // 50KB per context file | ||||||||||||||||||||||||||
| let extensionContextBudgetRemaining = EXTENSION_CONTEXT_BUDGET; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const extensionParts: Part[] = []; | ||||||||||||||||||||||||||
| const extensionDisplays: IndividualToolCallDisplay[] = []; | ||||||||||||||||||||||||||
| const extensionLabels: string[] = []; | ||||||||||||||||||||||||||
| for (let i = 0; i < extensionMentions.length; i++) { | ||||||||||||||||||||||||||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The outer loop needs to be sequential because the aggregate budget ( |
||||||||||||||||||||||||||
| const { extension } = extensionMentions[i]; | ||||||||||||||||||||||||||
| const displayName = stripTerminalControlSequences( | ||||||||||||||||||||||||||
| extension.displayName || extension.name, | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| const callId = `client-extension-${userMessageTimestamp}-${i}`; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| let contextText = buildExtensionContextText(extension); | ||||||||||||||||||||||||||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fair point, but the metadata block is ~200-500 bytes which is negligible against the 200KB budget. Not worth the complexity of deducting it. |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Read extension context files in parallel, with path traversal and | ||||||||||||||||||||||||||
| // budget checks. | ||||||||||||||||||||||||||
| if (extension.contextFiles && extension.contextFiles.length > 0) { | ||||||||||||||||||||||||||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The metadata block ( |
||||||||||||||||||||||||||
| const fileReads = await Promise.allSettled( | ||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] All context files are read into memory simultaneously via Add an early budget guard before the parallel read phase:
Suggested change
— qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||
| extension.contextFiles.map(async (contextFilePath) => { | ||||||||||||||||||||||||||
| const resolved = path.resolve(contextFilePath); | ||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] Symlink-based path traversal bypasses
The codebase already has
Suggested change
— qwen3.7-max via Qwen Code /review
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — now using |
||||||||||||||||||||||||||
| if (!isSubpath(extension.path, resolved)) { | ||||||||||||||||||||||||||
| onDebugMessage( | ||||||||||||||||||||||||||
| `Skipping context file outside extension directory: ${contextFilePath}`, | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| return fs.readFile(resolved, 'utf-8'); | ||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Missing
When the user cancels a pending query (e.g., edits the input while extension context files are being read), the in-flight
Suggested change
— qwen3.7-max via Qwen Code /review
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — now passing |
||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| 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 }); | ||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Hard-coded This file already imports
Suggested change
— qwen3.7-max via Qwen Code /review
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — now using imported |
||||||||||||||||||||||||||
| extensionLabels.push(`ext:${extension.name}`); | ||||||||||||||||||||||||||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. extension.name is the machine-readable slug (alphanumeric + hyphens), not user-facing metadata. It doesn't go through terminal rendering. Sanitizing it would be inconsistent — we don't sanitize ext.name anywhere else in the codebase. |
||||||||||||||||||||||||||
| extensionDisplays.push({ | ||||||||||||||||||||||||||
| callId, | ||||||||||||||||||||||||||
| name: 'Activate Extension', | ||||||||||||||||||||||||||
| description: `Activated extension ${displayName}`, | ||||||||||||||||||||||||||
| status: ToolCallStatus.Success, | ||||||||||||||||||||||||||
| resultDisplay: undefined, | ||||||||||||||||||||||||||
| confirmationDetails: undefined, | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Read files (if any). A hard read error aborts the turn, as before — but | ||||||||||||||||||||||||||
| // any resource tool-cards already gathered are still surfaced. | ||||||||||||||||||||||||||
| // any extension/resource tool-cards already gathered are still surfaced. | ||||||||||||||||||||||||||
| const fileParts: Part[] = []; | ||||||||||||||||||||||||||
| let fileDisplays: IndividualToolCallDisplay[] = []; | ||||||||||||||||||||||||||
| if (pathSpecsToRead.length > 0) { | ||||||||||||||||||||||||||
|
|
@@ -477,7 +591,6 @@ export async function resolveAtCommandQuery({ | |||||||||||||||||||||||||
| ? result.contentParts | ||||||||||||||||||||||||||
| : [result.contentParts]; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Create individual tool call displays for each file read | ||||||||||||||||||||||||||
| fileDisplays = result.files.map((file, index) => ({ | ||||||||||||||||||||||||||
| callId: `client-read-${userMessageTimestamp}-${index}`, | ||||||||||||||||||||||||||
| name: file.isDirectory ? 'Read Directory' : 'Read File', | ||||||||||||||||||||||||||
|
|
@@ -492,7 +605,6 @@ export async function resolveAtCommandQuery({ | |||||||||||||||||||||||||
| })); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (parts.length > 0 && !result.error) { | ||||||||||||||||||||||||||
| // readManyFiles now returns properly formatted parts with headers and prefixes | ||||||||||||||||||||||||||
| for (const part of parts) { | ||||||||||||||||||||||||||
| fileParts.push(typeof part === 'string' ? { text: part } : part); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
@@ -512,14 +624,19 @@ export async function resolveAtCommandQuery({ | |||||||||||||||||||||||||
| typeof errorToolCallDisplay.resultDisplay === 'string' | ||||||||||||||||||||||||||
| ? errorToolCallDisplay.resultDisplay | ||||||||||||||||||||||||||
| : undefined; | ||||||||||||||||||||||||||
| // Resource labels are merged in too: a resource may have been read | ||||||||||||||||||||||||||
| // successfully before the file read failed, and its card is already in | ||||||||||||||||||||||||||
| // `resourceDisplays` above — the audit trail must not drop it. | ||||||||||||||||||||||||||
| const labelsOnError = [...contentLabelsForDisplay, ...resourceLabels]; | ||||||||||||||||||||||||||
| const labelsOnError = [ | ||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Missing test for error path with extension labels/displays The error path now includes Consider adding a test that mentions both an extension and a file, forces — qwen3.7-max via Qwen Code /review
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good point — adding integration tests to |
||||||||||||||||||||||||||
| ...extensionLabels, | ||||||||||||||||||||||||||
| ...contentLabelsForDisplay, | ||||||||||||||||||||||||||
| ...resourceLabels, | ||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||
| processedQuery: null, | ||||||||||||||||||||||||||
| shouldProceed: false, | ||||||||||||||||||||||||||
| toolDisplays: [...resourceDisplays, errorToolCallDisplay], | ||||||||||||||||||||||||||
| toolDisplays: [ | ||||||||||||||||||||||||||
| ...extensionDisplays, | ||||||||||||||||||||||||||
| ...resourceDisplays, | ||||||||||||||||||||||||||
| errorToolCallDisplay, | ||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||
| filesRead: labelsOnError, | ||||||||||||||||||||||||||
| recording: { | ||||||||||||||||||||||||||
| filesRead: labelsOnError, | ||||||||||||||||||||||||||
|
|
@@ -537,15 +654,20 @@ export async function resolveAtCommandQuery({ | |||||||||||||||||||||||||
| // positional alignment, so grouping is safe. | ||||||||||||||||||||||||||
| const processedQueryParts: PartListUnion = [ | ||||||||||||||||||||||||||
| { text: initialQueryText }, | ||||||||||||||||||||||||||
| ...extensionParts, | ||||||||||||||||||||||||||
| ...fileParts, | ||||||||||||||||||||||||||
| ...resourceParts, | ||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||
| const allLabels = [...contentLabelsForDisplay, ...resourceLabels]; | ||||||||||||||||||||||||||
| const allLabels = [ | ||||||||||||||||||||||||||
| ...extensionLabels, | ||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Session resume mislabels extension activations as "Read File"
Consider excluding — qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||
| ...contentLabelsForDisplay, | ||||||||||||||||||||||||||
| ...resourceLabels, | ||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||
| processedQuery: processedQueryParts, | ||||||||||||||||||||||||||
| shouldProceed: true, | ||||||||||||||||||||||||||
| toolDisplays: [...fileDisplays, ...resourceDisplays], | ||||||||||||||||||||||||||
| toolDisplays: [...extensionDisplays, ...fileDisplays, ...resourceDisplays], | ||||||||||||||||||||||||||
| filesRead: allLabels, | ||||||||||||||||||||||||||
| recording: { | ||||||||||||||||||||||||||
| filesRead: allLabels, | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point — the autocomplete side has the guard, but the resolution path doesn't. However, adding it to the resolution path would mean
@ext:namesilently disappears in untrusted folders after autocomplete showed it. The current behavior is consistent: if autocomplete doesn't show extensions in untrusted folders, the user can't construct the reference. If they manually type it, the extension context is still from the user's own installed extensions, not the workspace.