From 4d5f1f887109aef908f937d9622f6a180bddb816 Mon Sep 17 00:00:00 2001 From: Miguel Vargas Date: Fri, 2 Oct 2026 19:07:26 +0000 Subject: [PATCH 1/3] fix(cli): skip eager recursive file reading for @ references Resolve @ references to their relative workspace path without converting them into recursive '**' globs for client-side ReadManyFilesTool execution. This prevents inlining the entire contents of all files in a directory into the initial prompt and allows the model to evaluate the user request and select the appropriate tool (e.g., list_directory or read_file), reducing unnecessary token usage. - Add isDirectory flag to ResolvedFile in atCommandProcessor.ts. - Keep relative directory pathSpec instead of expanding directories to '**' in resolveFilePaths. - Filter out directory entries in readLocalFiles before invoking ReadManyFilesTool. - Update and add unit tests in atCommandProcessor.test.ts for directory and mixed @ + @ references. --- .../src/ui/hooks/atCommandProcessor.test.ts | 94 +++++++++++++++---- .../cli/src/ui/hooks/atCommandProcessor.ts | 16 ++-- 2 files changed, 87 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index b41abdb5688..9c52528faf4 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -242,7 +242,7 @@ describe('handleAtCommand', () => { ); }); - it('should process a valid directory path and convert to glob', async () => { + it('should resolve a valid directory path without eagerly reading all files', async () => { const fileContent = 'This is the file content.'; const filePath = await createTestFile( path.join(testRootDir, 'path', 'to', 'file.txt'), @@ -250,9 +250,7 @@ describe('handleAtCommand', () => { ); const dirPath = path.dirname(filePath); const relativeDirPath = getRelativePath(dirPath); - const relativeFilePath = getRelativePath(filePath); const query = `@${dirPath}`; - const resolvedGlob = path.join(relativeDirPath, '**'); const result = await handleAtCommand({ query, @@ -263,18 +261,86 @@ describe('handleAtCommand', () => { signal: abortController.signal, }); + expect(result).toEqual({ + processedQuery: [{ text: `@${relativeDirPath}` }], + }); + expect(mockAddItem).not.toHaveBeenCalled(); + expect(mockOnDebugMessage).toHaveBeenCalledWith( + `Path ${dirPath} resolved to directory: ${dirPath}, using relative path: ${relativeDirPath}`, + ); + }); + + it('should not eagerly read file contents when asking to list files in @', async () => { + await createTestFile( + path.join(testRootDir, 'Documents', 'internal', 'dev-link.sh'), + '#!/bin/bash\necho "dev-link"', + ); + await createTestFile( + path.join( + testRootDir, + 'Documents', + 'internal', + 'poc', + 'gemini_poc', + 'lib', + 'large_module.py', + ), + 'x = 1\n'.repeat(1000), + ); + + const query = 'List all files stored in @Documents/internal/'; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 127, + signal: abortController.signal, + }); + expect(result).toEqual({ processedQuery: [ - { text: `@${resolvedGlob}` }, + { text: 'List all files stored in @Documents/internal/' }, + ], + }); + expect(mockAddItem).not.toHaveBeenCalled(); + }); + + it('should read @ content while skipping eager file reads for @ in mixed queries', async () => { + const fileContent = 'File A content'; + const filePath = await createTestFile( + path.join(testRootDir, 'fileA.txt'), + fileContent, + ); + await createTestFile( + path.join(testRootDir, 'Documents', 'internal', 'nested.txt'), + 'Nested directory file content that should not be eagerly read', + ); + + const relativeFilePath = getRelativePath(filePath); + const query = `Compare @${relativeFilePath} with files in @Documents/internal/`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 1271, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [ + { + text: `Compare @${relativeFilePath} with files in @Documents/internal/`, + }, { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${relativeFilePath}:\n` }, { text: fileContent }, { text: '\n--- End of content ---' }, ], }); - expect(mockOnDebugMessage).toHaveBeenCalledWith( - `Path ${dirPath} resolved to directory, using glob: ${resolvedGlob}`, - ); }); it('should handle query with text before and after @command', async () => { @@ -1271,17 +1337,13 @@ describe('handleAtCommand', () => { expect(result.processedQuery).not.toBeNull(); expect(result.error).toBeUndefined(); - expect(result.processedQuery).toEqual( - expect.arrayContaining([ - { text: `Check @${path.join(subDirPath, '**')} please.` }, - expect.objectContaining({ - text: '\n--- Content from referenced files ---', - }), - ]), - ); + expect(result.processedQuery).toEqual([ + { text: `Check @${subDirPath} please.` }, + ]); + expect(mockAddItem).not.toHaveBeenCalled(); expect(mockOnDebugMessage).toHaveBeenCalledWith( - expect.stringContaining(`using glob: ${path.join(subDirPath, '**')}`), + expect.stringContaining(`using relative path: ${subDirPath}`), ); }); }); diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index 45a7e104824..6cd4096cc6c 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -225,6 +225,7 @@ interface ResolvedFile { pathSpec: string; displayLabel: string; absolutePath?: string; + isDirectory?: boolean; } interface IgnoredFile { @@ -300,15 +301,15 @@ async function resolveFilePaths( if (result.status === 'resolved') { const { absolutePath, relativePath, stats } = result.resolved; if (stats.isDirectory()) { - const pathSpec = path.join(relativePath, '**'); resolvedFiles.push({ part, - pathSpec, + pathSpec: relativePath, displayLabel: path.isAbsolute(pathName) ? relativePath : pathName, absolutePath, + isDirectory: true, }); onDebugMessage( - `Path ${pathName} resolved to directory, using glob: ${pathSpec}`, + `Path ${pathName} resolved to directory: ${absolutePath}, using relative path: ${relativePath}`, ); } else { resolvedFiles.push({ @@ -549,7 +550,8 @@ async function readLocalFiles( display?: IndividualToolCallDisplay; error?: string; }> { - if (resolvedFiles.length === 0) { + const filesToRead = resolvedFiles.filter((rf) => !rf.isDirectory); + if (filesToRead.length === 0) { return { parts: [] }; } @@ -558,7 +560,7 @@ async function readLocalFiles( config.getMessageBus(), ); - const pathSpecsToRead = resolvedFiles.map((rf) => { + const pathSpecsToRead = filesToRead.map((rf) => { if (rf.absolutePath) { return rf.pathSpec.endsWith('**') ? path.join(rf.absolutePath, '**') @@ -566,7 +568,7 @@ async function readLocalFiles( } return rf.pathSpec; }); - const fileLabelsForDisplay = resolvedFiles.map((rf) => rf.displayLabel); + const fileLabelsForDisplay = filesToRead.map((rf) => rf.displayLabel); const respectFileIgnore = config.getFileFilteringOptions(); const toolArgs = { @@ -604,7 +606,7 @@ async function readLocalFiles( const fileActualContent = match[2].trim(); // Find the display label for this path - const resolvedFile = resolvedFiles.find( + const resolvedFile = filesToRead.find( (rf) => rf.absolutePath === filePathSpecInContent || rf.pathSpec === filePathSpecInContent, From d6a782b2783ab0d04127888cb32517ff673113f7 Mon Sep 17 00:00:00 2001 From: Miguel Vargas Date: Fri, 2 Oct 2026 19:47:16 +0000 Subject: [PATCH 2/3] refactor(cli): explicitly handle undefined isDirectory with nullish coalescing --- packages/cli/src/ui/hooks/atCommandProcessor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index 6cd4096cc6c..11195674860 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -550,7 +550,7 @@ async function readLocalFiles( display?: IndividualToolCallDisplay; error?: string; }> { - const filesToRead = resolvedFiles.filter((rf) => !rf.isDirectory); + const filesToRead = resolvedFiles.filter((rf) => !(rf.isDirectory ?? false)); if (filesToRead.length === 0) { return { parts: [] }; } From f5ea8c2a8ebccf27fea5609e291651a4b1814ac6 Mon Sep 17 00:00:00 2001 From: Miguel Vargas Date: Tue, 6 Oct 2026 19:54:12 +0000 Subject: [PATCH 3/3] refactor(cli): normalize root relativePath and clean up directory @ handling - Normalize empty relativePath to '.' in resolveFilePaths when an @ command references the workspace root directory via an absolute path, and consolidate the directory/file resolution branches. - Simplify pathSpecsToRead in readLocalFiles by removing the dead endsWith('**') check. - Update handleAtCommand JSDoc and docs/reference/commands.md to reflect that @ resolves the directory path without eagerly reading nested files. - Add unit test for workspace root directory resolution and assert tool_group display in mixed @ + @ queries. --- docs/reference/commands.md | 5 +- .../src/ui/hooks/atCommandProcessor.test.ts | 30 +++++++++++ .../cli/src/ui/hooks/atCommandProcessor.ts | 50 +++++++------------ 3 files changed, 52 insertions(+), 33 deletions(-) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index e95be7826c7..1e610a1dc21 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -532,8 +532,9 @@ your prompt to Gemini. These commands include git-aware filtering. - `What is this file about? @README.md` - **Details:** - If a path to a single file is provided, the content of that file is read. - - If a path to a directory is provided, the command attempts to read the - content of files within that directory and any subdirectories. + - If a path to a directory is provided, the directory path is resolved and + passed to the model so it can inspect or list the directory using its + tools without eagerly reading every nested file into the prompt. - Spaces in paths should be escaped with a backslash (for example, `@My\ Documents/file.txt`). - The command uses the `read_many_files` tool internally. The content is diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index 9c52528faf4..8e9ef08c01b 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -270,6 +270,27 @@ describe('handleAtCommand', () => { ); }); + it('should normalize an absolute path to the workspace root directory to @.', async () => { + const query = `List files in @${testRootDir}`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 1261, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [{ text: 'List files in @.' }], + }); + expect(mockAddItem).not.toHaveBeenCalled(); + expect(mockOnDebugMessage).toHaveBeenCalledWith( + `Path ${testRootDir} resolved to directory: ${testRootDir}, using relative path: .`, + ); + }); + it('should not eagerly read file contents when asking to list files in @', async () => { await createTestFile( path.join(testRootDir, 'Documents', 'internal', 'dev-link.sh'), @@ -341,6 +362,15 @@ describe('handleAtCommand', () => { { text: '\n--- End of content ---' }, ], }); + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'tool_group', + tools: [ + expect.objectContaining({ status: CoreToolCallStatus.Success }), + ], + }), + 1271, + ); }); it('should handle query with text before and after @command', async () => { diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index 11195674860..687476eb815 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -300,28 +300,20 @@ async function resolveFilePaths( if (result.status === 'resolved') { const { absolutePath, relativePath, stats } = result.resolved; - if (stats.isDirectory()) { - resolvedFiles.push({ - part, - pathSpec: relativePath, - displayLabel: path.isAbsolute(pathName) ? relativePath : pathName, - absolutePath, - isDirectory: true, - }); - onDebugMessage( - `Path ${pathName} resolved to directory: ${absolutePath}, using relative path: ${relativePath}`, - ); - } else { - resolvedFiles.push({ - part, - pathSpec: relativePath, - displayLabel: path.isAbsolute(pathName) ? relativePath : pathName, - absolutePath, - }); - onDebugMessage( - `Path ${pathName} resolved to file: ${absolutePath}, using relative path: ${relativePath}`, - ); - } + const isDirectory = stats.isDirectory(); + const normalizedRelativePath = relativePath || '.'; + resolvedFiles.push({ + part, + pathSpec: normalizedRelativePath, + displayLabel: path.isAbsolute(pathName) + ? normalizedRelativePath + : pathName, + absolutePath, + isDirectory, + }); + onDebugMessage( + `Path ${pathName} resolved to ${isDirectory ? 'directory' : 'file'}: ${absolutePath}, using relative path: ${normalizedRelativePath}`, + ); } else if ( result.status === 'not_found' || result.status === 'unauthorized' @@ -560,14 +552,9 @@ async function readLocalFiles( config.getMessageBus(), ); - const pathSpecsToRead = filesToRead.map((rf) => { - if (rf.absolutePath) { - return rf.pathSpec.endsWith('**') - ? path.join(rf.absolutePath, '**') - : rf.absolutePath; - } - return rf.pathSpec; - }); + const pathSpecsToRead = filesToRead.map( + (rf) => rf.absolutePath ?? rf.pathSpec, + ); const fileLabelsForDisplay = filesToRead.map((rf) => rf.displayLabel); const respectFileIgnore = config.getFileFilteringOptions(); @@ -700,7 +687,8 @@ function reportIgnoredFiles( /** * Processes user input containing one or more '@' commands. - * - Workspace paths are read via the 'read_many_files' tool. + * - Workspace file paths are read via the 'read_many_files' tool. + * - Workspace directory paths are resolved to relative paths in the query without eagerly reading their contents. * - MCP resource URIs are read via each server's `resources/read`. * The user query is updated with inline content blocks so the LLM receives the * referenced context directly.