From c414512f1999b48a0dc02fed14e793b650907ba8 Mon Sep 17 00:00:00 2001 From: Scott Densmore Date: Sat, 31 May 2025 16:19:14 -0700 Subject: [PATCH] Fix: Make file path case-insensitive in @-command (#659) --- .../src/ui/hooks/atCommandProcessor.test.ts | 71 +++++++++++++++++++ packages/cli/src/ui/hooks/useCompletion.ts | 8 ++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index 176a1210..b0a4cc13 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -484,6 +484,77 @@ ${content2}`, expect(result.processedQuery).toEqual([ { text: 'Check @nonexistent.txt and @ also' }, ]); + + expect(result.shouldProceed).toBe(true); + }); + + it('should process a file path case-insensitively', async () => { + // const actualFilePath = 'path/to/MyFile.txt'; // Unused, path in llmContent should match queryPath + const queryPath = 'path/to/myfile.txt'; // Different case + const query = `@${queryPath}`; + const fileContent = 'This is the case-insensitive file content.'; + + // Mock fs.stat to "find" MyFile.txt when looking for myfile.txt + // This simulates a case-insensitive file system or resolution + vi.mocked(fsPromises.stat).mockImplementation(async (p) => { + if (p.toString().toLowerCase().endsWith('myfile.txt')) { + return { + isDirectory: () => false, + // You might need to add other Stats properties if your code uses them + } as Stats; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + mockReadManyFilesExecute.mockResolvedValue({ + llmContent: ` +--- ${queryPath} --- +${fileContent}`, + returnDisplay: 'Read 1 file.', + }); + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 134, // New messageId + signal: abortController.signal, + }); + + expect(mockAddItem).toHaveBeenCalledWith( + { type: 'user', text: query }, + 134, + ); + // The atCommandProcessor resolves the path before calling read_many_files. + // We expect it to be called with the path that fs.stat "found". + // In a real case-insensitive FS, stat(myfile.txt) might return info for MyFile.txt. + // The key is that *a* valid path that points to the content is used. + expect(mockReadManyFilesExecute).toHaveBeenCalledWith( + // Depending on how path resolution and fs.stat mock interact, + // this could be queryPath or actualFilePath. + // For this test, we'll assume the processor uses the path that stat "succeeded" with. + // If the underlying fs/stat is truly case-insensitive, it might resolve to actualFilePath. + // If the mock is simpler, it might use queryPath if stat(queryPath) succeeds. + // The most important part is that *some* version of the path that leads to the content is used. + // Let's assume it uses the path from the query if stat confirms it exists (even if different case on disk) + { paths: [queryPath] }, + abortController.signal, + ); + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'tool_group', + tools: [expect.objectContaining({ status: ToolCallStatus.Success })], + }), + 134, + ); + expect(result.processedQuery).toEqual([ + { text: `@${queryPath}` }, // Query uses the input path + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${queryPath}:\n` }, // Content display also uses input path + { text: fileContent }, + { text: '\n--- End of content ---' }, + ]); expect(result.shouldProceed).toBe(true); }); }); diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts index f2c85458..f3ad7847 100644 --- a/packages/cli/src/ui/hooks/useCompletion.ts +++ b/packages/cli/src/ui/hooks/useCompletion.ts @@ -193,6 +193,7 @@ export function useCompletion( return []; } + const lowerSearchPrefix = searchPrefix.toLowerCase(); let foundSuggestions: Suggestion[] = []; try { const entries = await fs.readdir(startDir, { withFileTypes: true }); @@ -200,7 +201,7 @@ export function useCompletion( if (foundSuggestions.length >= maxResults) break; const entryPathRelative = path.join(currentRelativePath, entry.name); - if (entry.name.startsWith(searchPrefix)) { + if (entry.name.toLowerCase().startsWith(lowerSearchPrefix)) { foundSuggestions.push({ label: entryPathRelative + (entry.isDirectory() ? '/' : ''), value: escapePath( @@ -217,7 +218,7 @@ export function useCompletion( foundSuggestions = foundSuggestions.concat( await findFilesRecursively( path.join(startDir, entry.name), - searchPrefix, + searchPrefix, // Pass original searchPrefix for recursive calls entryPathRelative, depth + 1, maxDepth, @@ -242,11 +243,12 @@ export function useCompletion( fetchedSuggestions = await findFilesRecursively(cwd, prefix); } else { // Original behavior: list files in the specific directory + const lowerPrefix = prefix.toLowerCase(); const entries = await fs.readdir(baseDirAbsolute, { withFileTypes: true, }); fetchedSuggestions = entries - .filter((entry) => entry.name.startsWith(prefix)) + .filter((entry) => entry.name.toLowerCase().startsWith(lowerPrefix)) .map((entry) => { const label = entry.isDirectory() ? entry.name + '/' : entry.name; return {