diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index 583c0b2e604..97c66ce3950 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -581,6 +581,33 @@ describe('handleAtCommand', () => { expect(result.processedQuery).toEqual([{ text: query }]); expect(result.shouldProceed).toBe(true); }); + + it('should emit debug messages in @path order while resolving in parallel', async () => { + const missingPathName = 'missing.txt'; + const query = `@${missingPathName} @`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 301, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [{ text: query }], + shouldProceed: true, + }); + expect(mockOnDebugMessage).toHaveBeenNthCalledWith( + 1, + `Glob tool not found. Path ${missingPathName} will be skipped.`, + ); + expect(mockOnDebugMessage).toHaveBeenNthCalledWith( + 2, + 'Lone @ detected, will be treated as text in the modified query.', + ); + }); }); describe('gemini-ignore filtering', () => { diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index e612c8cabe3..734e304204e 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -40,6 +40,32 @@ interface AtCommandPart { content: string; } +type IgnoreReason = 'git' | 'gemini' | 'both'; + +type AtPathResolutionResult = + | { + type: 'invalid'; + errorMessage: string; + debugMessages: string[]; + } + | { + type: 'ignored'; + reason: IgnoreReason; + pathName: string; + debugMessages: string[]; + } + | { + type: 'success'; + currentPathSpec: string; + originalAtPath: string; + pathName: string; + debugMessages: string[]; + } + | { + type: 'skipped'; + debugMessages: string[]; + }; + /** * Parses a query string to find all '@' commands and text segments. * Handles \ escaped spaces within paths. @@ -151,7 +177,7 @@ export async function handleAtCommand({ const pathSpecsToRead: string[] = []; const atPathToResolvedSpecMap = new Map(); const contentLabelsForDisplay: string[] = []; - const ignoredByReason: Record = { + const ignoredByReason: Record = { git: [], gemini: [], both: [], @@ -169,179 +195,193 @@ export async function handleAtCommand({ return { processedQuery: null, shouldProceed: false }; } - const resolutionPromises = atPathCommandParts.map(async (atPathPart) => { - const originalAtPath = atPathPart.content; // e.g., "@file.txt" or "@" - - if (originalAtPath === '@') { - onDebugMessage( - 'Lone @ detected, will be treated as text in the modified query.', - ); - return null; - } - - const pathName = originalAtPath.substring(1); - if (!pathName) { - // This case should ideally not be hit if parseAllAtCommands ensures content after @ - // but as a safeguard: - return { - error: true, - errorMessage: `Error: Invalid @ command '${originalAtPath}'. No path specified.`, + const resolutionPromises = atPathCommandParts.map( + async (atPathPart): Promise => { + const debugMessages: string[] = []; + const addDebugMessage = (message: string) => { + debugMessages.push(message); }; - } + const originalAtPath = atPathPart.content; // e.g., "@file.txt" or "@" - // Check if path should be ignored based on filtering options + if (originalAtPath === '@') { + addDebugMessage( + 'Lone @ detected, will be treated as text in the modified query.', + ); + return { type: 'skipped', debugMessages }; + } - const workspaceContext = config.getWorkspaceContext(); - if (!workspaceContext.isPathWithinWorkspace(pathName)) { - onDebugMessage( - `Path ${pathName} is not in the workspace and will be skipped.`, - ); - return null; - } + const pathName = originalAtPath.substring(1); + if (!pathName) { + // This case should ideally not be hit if parseAllAtCommands ensures content after @ + // but as a safeguard: + return { + type: 'invalid', + errorMessage: `Error: Invalid @ command '${originalAtPath}'. No path specified.`, + debugMessages, + }; + } - const gitIgnored = - respectFileIgnore.respectGitIgnore && - fileDiscovery.shouldIgnoreFile(pathName, { - respectGitIgnore: true, - respectGeminiIgnore: false, - }); - const geminiIgnored = - respectFileIgnore.respectGeminiIgnore && - fileDiscovery.shouldIgnoreFile(pathName, { - respectGitIgnore: false, - respectGeminiIgnore: true, - }); + // Check if path should be ignored based on filtering options - if (gitIgnored || geminiIgnored) { - const reason = - gitIgnored && geminiIgnored ? 'both' : gitIgnored ? 'git' : 'gemini'; - - const reasonText = - reason === 'both' - ? 'ignored by both git and gemini' - : reason === 'git' - ? 'git-ignored' - : 'gemini-ignored'; - onDebugMessage(`Path ${pathName} is ${reasonText} and will be skipped.`); - return { ignored: true, reason, pathName }; - } + const workspaceContext = config.getWorkspaceContext(); + if (!workspaceContext.isPathWithinWorkspace(pathName)) { + addDebugMessage( + `Path ${pathName} is not in the workspace and will be skipped.`, + ); + return { type: 'skipped', debugMessages }; + } - for (const dir of config.getWorkspaceContext().getDirectories()) { - let currentPathSpec = pathName; - let resolvedSuccessfully = false; - try { - const absolutePath = path.resolve(dir, pathName); - const stats = await fs.stat(absolutePath); - if (stats.isDirectory()) { - currentPathSpec = - pathName + (pathName.endsWith(path.sep) ? `**` : `/**`); - onDebugMessage( - `Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`, - ); - } else { - onDebugMessage(`Path ${pathName} resolved to file: ${absolutePath}`); - } - resolvedSuccessfully = true; - } catch (error) { - if (isNodeError(error) && error.code === 'ENOENT') { - if (config.getEnableRecursiveFileSearch() && globTool) { - onDebugMessage( - `Path ${pathName} not found directly, attempting glob search.`, + const gitIgnored = + respectFileIgnore.respectGitIgnore && + fileDiscovery.shouldIgnoreFile(pathName, { + respectGitIgnore: true, + respectGeminiIgnore: false, + }); + const geminiIgnored = + respectFileIgnore.respectGeminiIgnore && + fileDiscovery.shouldIgnoreFile(pathName, { + respectGitIgnore: false, + respectGeminiIgnore: true, + }); + + if (gitIgnored || geminiIgnored) { + const reason = + gitIgnored && geminiIgnored ? 'both' : gitIgnored ? 'git' : 'gemini'; + + const reasonText = + reason === 'both' + ? 'ignored by both git and gemini' + : reason === 'git' + ? 'git-ignored' + : 'gemini-ignored'; + addDebugMessage( + `Path ${pathName} is ${reasonText} and will be skipped.`, + ); + return { type: 'ignored', reason, pathName, debugMessages }; + } + + for (const dir of config.getWorkspaceContext().getDirectories()) { + let currentPathSpec = pathName; + let resolvedSuccessfully = false; + try { + const absolutePath = path.resolve(dir, pathName); + const stats = await fs.stat(absolutePath); + if (stats.isDirectory()) { + currentPathSpec = + pathName + (pathName.endsWith(path.sep) ? `**` : `/**`); + addDebugMessage( + `Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`, ); - try { - const globResult = await globTool.buildAndExecute( - { - pattern: `**/*${pathName}*`, - path: dir, - }, - signal, + } else { + addDebugMessage( + `Path ${pathName} resolved to file: ${absolutePath}`, + ); + } + resolvedSuccessfully = true; + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + if (config.getEnableRecursiveFileSearch() && globTool) { + addDebugMessage( + `Path ${pathName} not found directly, attempting glob search.`, ); - if ( - globResult.llmContent && - typeof globResult.llmContent === 'string' && - !globResult.llmContent.startsWith('No files found') && - !globResult.llmContent.startsWith('Error:') - ) { - const lines = globResult.llmContent.split('\n'); - if (lines.length > 1 && lines[1]) { - const firstMatchAbsolute = lines[1].trim(); - currentPathSpec = path.relative(dir, firstMatchAbsolute); - onDebugMessage( - `Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`, - ); - resolvedSuccessfully = true; + try { + const globResult = await globTool.buildAndExecute( + { + pattern: `**/*${pathName}*`, + path: dir, + }, + signal, + ); + if ( + globResult.llmContent && + typeof globResult.llmContent === 'string' && + !globResult.llmContent.startsWith('No files found') && + !globResult.llmContent.startsWith('Error:') + ) { + const lines = globResult.llmContent.split('\n'); + if (lines.length > 1 && lines[1]) { + const firstMatchAbsolute = lines[1].trim(); + currentPathSpec = path.relative(dir, firstMatchAbsolute); + addDebugMessage( + `Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`, + ); + resolvedSuccessfully = true; + } else { + addDebugMessage( + `Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`, + ); + } } else { - onDebugMessage( - `Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`, + addDebugMessage( + `Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`, ); } - } else { - onDebugMessage( - `Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`, + } catch (globError) { + console.error( + `Error during glob search for ${pathName}: ${getErrorMessage(globError)}`, + ); + addDebugMessage( + `Error during glob search for ${pathName}. Path ${pathName} will be skipped.`, ); } - } catch (globError) { - console.error( - `Error during glob search for ${pathName}: ${getErrorMessage(globError)}`, - ); - onDebugMessage( - `Error during glob search for ${pathName}. Path ${pathName} will be skipped.`, + } else { + addDebugMessage( + `Glob tool not found. Path ${pathName} will be skipped.`, ); } } else { - onDebugMessage( - `Glob tool not found. Path ${pathName} will be skipped.`, + console.error( + `Error stating path ${pathName}: ${getErrorMessage(error)}`, + ); + addDebugMessage( + `Error stating path ${pathName}. Path ${pathName} will be skipped.`, ); } - } else { - console.error( - `Error stating path ${pathName}: ${getErrorMessage(error)}`, - ); - onDebugMessage( - `Error stating path ${pathName}. Path ${pathName} will be skipped.`, - ); + } + if (resolvedSuccessfully) { + return { + type: 'success', + currentPathSpec, + originalAtPath, + pathName, + debugMessages, + }; } } - if (resolvedSuccessfully) { - return { - success: true, - currentPathSpec, - originalAtPath, - pathName, - }; - } - } - return null; - }); + return { type: 'skipped', debugMessages }; + }, + ); const resolutionResults = await Promise.all(resolutionPromises); for (const result of resolutionResults) { - if (!result) continue; + for (const debugMessage of result.debugMessages) { + onDebugMessage(debugMessage); + } - if ('error' in result && result.error) { + if (result.type === 'invalid') { addItem( { type: 'error', - text: result.errorMessage!, + text: result.errorMessage, }, userMessageTimestamp, ); return { processedQuery: null, shouldProceed: false }; } - if ('ignored' in result && result.ignored) { - ignoredByReason[result.reason!].push(result.pathName!); + if (result.type === 'ignored') { + ignoredByReason[result.reason].push(result.pathName); continue; } - if ('success' in result && result.success) { - pathSpecsToRead.push(result.currentPathSpec!); + if (result.type === 'success') { + pathSpecsToRead.push(result.currentPathSpec); atPathToResolvedSpecMap.set( - result.originalAtPath!, - result.currentPathSpec!, + result.originalAtPath, + result.currentPathSpec, ); - contentLabelsForDisplay.push(result.pathName!); + contentLabelsForDisplay.push(result.pathName); } }