From 3c16429fc4b8102b7ea44c5b2842507e3a99ec72 Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Fri, 25 Jul 2025 10:32:59 -0700 Subject: [PATCH] Make useCompletion.test.ts windows compatible (#4766) --- .../hooks/useCompletion.integration.test.ts | 854 ------- .../cli/src/ui/hooks/useCompletion.test.ts | 2124 ++++++++--------- packages/cli/src/ui/hooks/useCompletion.ts | 20 +- 3 files changed, 1045 insertions(+), 1953 deletions(-) delete mode 100644 packages/cli/src/ui/hooks/useCompletion.integration.test.ts diff --git a/packages/cli/src/ui/hooks/useCompletion.integration.test.ts b/packages/cli/src/ui/hooks/useCompletion.integration.test.ts deleted file mode 100644 index d4c66a15..00000000 --- a/packages/cli/src/ui/hooks/useCompletion.integration.test.ts +++ /dev/null @@ -1,854 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import type { Mocked } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; -import { useCompletion } from './useCompletion.js'; -import * as fs from 'fs/promises'; -import { glob } from 'glob'; -import { - CommandContext, - CommandKind, - SlashCommand, -} from '../commands/types.js'; -import { Config, FileDiscoveryService } from '@google/gemini-cli-core'; -import { useTextBuffer } from '../components/shared/text-buffer.js'; - -interface MockConfig { - getFileFilteringOptions: () => { - respectGitIgnore: boolean; - respectGeminiIgnore: boolean; - }; - getEnableRecursiveFileSearch: () => boolean; - getFileService: () => FileDiscoveryService | null; -} - -// Helper to create real TextBuffer objects within renderHook -const useTextBufferForTest = (text: string) => { - const cursorOffset = text.length; - - return useTextBuffer({ - initialText: text, - initialCursorOffset: cursorOffset, - viewport: { width: 80, height: 20 }, - isValidPath: () => false, - onChange: () => {}, - }); -}; - -// Mock dependencies -vi.mock('fs/promises'); -vi.mock('@google/gemini-cli-core', async () => { - const actual = await vi.importActual('@google/gemini-cli-core'); - return { - ...actual, - FileDiscoveryService: vi.fn(), - isNodeError: vi.fn((error) => error.code === 'ENOENT'), - escapePath: vi.fn((path) => path), - unescapePath: vi.fn((path) => path), - getErrorMessage: vi.fn((error) => error.message), - }; -}); -vi.mock('glob'); - -describe('useCompletion git-aware filtering integration', () => { - let mockFileDiscoveryService: Mocked; - let mockConfig: MockConfig; - - const testCwd = '/test/project'; - const slashCommands = [ - { - name: 'help', - description: 'Show help', - kind: CommandKind.BUILT_IN, - action: vi.fn(), - }, - { - name: 'clear', - description: 'Clear screen', - kind: CommandKind.BUILT_IN, - action: vi.fn(), - }, - ]; - - // A minimal mock is sufficient for these tests. - const mockCommandContext = {} as CommandContext; - - const mockSlashCommands: SlashCommand[] = [ - { - name: 'help', - altNames: ['?'], - description: 'Show help', - action: vi.fn(), - kind: CommandKind.BUILT_IN, - }, - { - name: 'stats', - altNames: ['usage'], - description: 'check session stats. Usage: /stats [model|tools]', - action: vi.fn(), - kind: CommandKind.BUILT_IN, - }, - { - name: 'clear', - description: 'Clear the screen', - action: vi.fn(), - kind: CommandKind.BUILT_IN, - }, - { - name: 'memory', - description: 'Manage memory', - kind: CommandKind.BUILT_IN, - // This command is a parent, no action. - subCommands: [ - { - name: 'show', - description: 'Show memory', - kind: CommandKind.BUILT_IN, - action: vi.fn(), - }, - { - name: 'add', - description: 'Add to memory', - kind: CommandKind.BUILT_IN, - action: vi.fn(), - }, - ], - }, - { - name: 'chat', - description: 'Manage chat history', - kind: CommandKind.BUILT_IN, - subCommands: [ - { - name: 'save', - description: 'Save chat', - kind: CommandKind.BUILT_IN, - action: vi.fn(), - }, - { - name: 'resume', - description: 'Resume a saved chat', - kind: CommandKind.BUILT_IN, - action: vi.fn(), - // This command provides its own argument completions - completion: vi - .fn() - .mockResolvedValue([ - 'my-chat-tag-1', - 'my-chat-tag-2', - 'my-channel', - ]), - }, - ], - }, - ]; - - beforeEach(() => { - mockFileDiscoveryService = { - shouldGitIgnoreFile: vi.fn(), - shouldGeminiIgnoreFile: vi.fn(), - shouldIgnoreFile: vi.fn(), - filterFiles: vi.fn(), - getGeminiIgnorePatterns: vi.fn(), - projectRoot: '', - gitIgnoreFilter: null, - geminiIgnoreFilter: null, - isFileIgnored: vi.fn(), - } as unknown as Mocked; - - mockConfig = { - getFileFilteringOptions: vi.fn(() => ({ - respectGitIgnore: true, - respectGeminiIgnore: true, - })), - getEnableRecursiveFileSearch: vi.fn(() => true), - getFileService: vi.fn(() => mockFileDiscoveryService), - }; - - vi.mocked(FileDiscoveryService).mockImplementation( - () => mockFileDiscoveryService, - ); - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should filter git-ignored entries from @ completions', async () => { - const globResults = [`${testCwd}/data`, `${testCwd}/dist`]; - vi.mocked(glob).mockResolvedValue(globResults); - - // Mock git ignore service to ignore certain files - mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation( - (path: string) => path.includes('dist'), - ); - mockFileDiscoveryService.shouldIgnoreFile.mockImplementation( - (path: string, options) => { - if (options?.respectGitIgnore !== false) { - return mockFileDiscoveryService.shouldGitIgnoreFile(path); - } - return false; - }, - ); - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('@d'); - return useCompletion( - textBuffer, - testCwd, - slashCommands, - mockCommandContext, - mockConfig as Config, - ); - }); - - // Wait for async operations to complete - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); // Account for debounce - }); - - expect(result.current.suggestions).toHaveLength(1); - expect(result.current.suggestions).toEqual( - expect.arrayContaining([{ label: 'data', value: 'data' }]), - ); - expect(result.current.showSuggestions).toBe(true); - }); - - it('should filter git-ignored directories from @ completions', async () => { - // Mock fs.readdir to return both regular and git-ignored directories - vi.mocked(fs.readdir).mockResolvedValue([ - { name: 'src', isDirectory: () => true }, - { name: 'node_modules', isDirectory: () => true }, - { name: 'dist', isDirectory: () => true }, - { name: 'README.md', isDirectory: () => false }, - { name: '.env', isDirectory: () => false }, - ] as unknown as Awaited>); - - // Mock ignore service to ignore certain files - mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation( - (path: string) => - path.includes('node_modules') || - path.includes('dist') || - path.includes('.env'), - ); - mockFileDiscoveryService.shouldIgnoreFile.mockImplementation( - (path: string, options) => { - if ( - options?.respectGitIgnore && - mockFileDiscoveryService.shouldGitIgnoreFile(path) - ) { - return true; - } - if ( - options?.respectGeminiIgnore && - mockFileDiscoveryService.shouldGeminiIgnoreFile - ) { - return mockFileDiscoveryService.shouldGeminiIgnoreFile(path); - } - return false; - }, - ); - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('@'); - return useCompletion( - textBuffer, - testCwd, - slashCommands, - mockCommandContext, - mockConfig as Config, - ); - }); - - // Wait for async operations to complete - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); // Account for debounce - }); - - expect(result.current.suggestions).toHaveLength(2); - expect(result.current.suggestions).toEqual( - expect.arrayContaining([ - { label: 'src/', value: 'src/' }, - { label: 'README.md', value: 'README.md' }, - ]), - ); - expect(result.current.showSuggestions).toBe(true); - }); - - it('should handle recursive search with git-aware filtering', async () => { - // Mock the recursive file search scenario - vi.mocked(fs.readdir).mockImplementation( - async ( - dirPath: string | Buffer | URL, - options?: { withFileTypes?: boolean }, - ) => { - const path = dirPath.toString(); - if (options?.withFileTypes) { - if (path === testCwd) { - return [ - { name: 'data', isDirectory: () => true }, - { name: 'dist', isDirectory: () => true }, - { name: 'node_modules', isDirectory: () => true }, - { name: 'README.md', isDirectory: () => false }, - { name: '.env', isDirectory: () => false }, - ] as unknown as Awaited>; - } - if (path.endsWith('/src')) { - return [ - { name: 'index.ts', isDirectory: () => false }, - { name: 'components', isDirectory: () => true }, - ] as unknown as Awaited>; - } - if (path.endsWith('/temp')) { - return [ - { name: 'temp.log', isDirectory: () => false }, - ] as unknown as Awaited>; - } - } - return []; - }, - ); - - // Mock ignore service - mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation( - (path: string) => path.includes('node_modules') || path.includes('temp'), - ); - mockFileDiscoveryService.shouldIgnoreFile.mockImplementation( - (path: string, options) => { - if ( - options?.respectGitIgnore && - mockFileDiscoveryService.shouldGitIgnoreFile(path) - ) { - return true; - } - if ( - options?.respectGeminiIgnore && - mockFileDiscoveryService.shouldGeminiIgnoreFile - ) { - return mockFileDiscoveryService.shouldGeminiIgnoreFile(path); - } - return false; - }, - ); - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('@t'); - return useCompletion( - textBuffer, - testCwd, - slashCommands, - mockCommandContext, - mockConfig as Config, - ); - }); - - // Wait for async operations to complete - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - // Should not include anything from node_modules or dist - const suggestionLabels = result.current.suggestions.map((s) => s.label); - expect(suggestionLabels).not.toContain('temp/'); - expect(suggestionLabels.some((l) => l.includes('node_modules'))).toBe( - false, - ); - }); - - it('should not perform recursive search when disabled in config', async () => { - const globResults = [`${testCwd}/data`, `${testCwd}/dist`]; - vi.mocked(glob).mockResolvedValue(globResults); - - // Disable recursive search in the mock config - const mockConfigNoRecursive = { - ...mockConfig, - getEnableRecursiveFileSearch: vi.fn(() => false), - } as unknown as Config; - - vi.mocked(fs.readdir).mockResolvedValue([ - { name: 'data', isDirectory: () => true }, - { name: 'dist', isDirectory: () => true }, - ] as unknown as Awaited>); - - renderHook(() => { - const textBuffer = useTextBufferForTest('@d'); - return useCompletion( - textBuffer, - testCwd, - slashCommands, - mockCommandContext, - mockConfigNoRecursive, - ); - }); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - // `glob` should not be called because recursive search is disabled - expect(glob).not.toHaveBeenCalled(); - // `fs.readdir` should be called for the top-level directory instead - expect(fs.readdir).toHaveBeenCalledWith(testCwd, { withFileTypes: true }); - }); - - it('should work without config (fallback behavior)', async () => { - vi.mocked(fs.readdir).mockResolvedValue([ - { name: 'src', isDirectory: () => true }, - { name: 'node_modules', isDirectory: () => true }, - { name: 'README.md', isDirectory: () => false }, - ] as unknown as Awaited>); - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('@'); - return useCompletion( - textBuffer, - testCwd, - slashCommands, - mockCommandContext, - undefined, - ); - }); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(result.current.suggestions).toHaveLength(3); - expect(result.current.suggestions).toEqual( - expect.arrayContaining([ - { label: 'src/', value: 'src/' }, - { label: 'node_modules/', value: 'node_modules/' }, - { label: 'README.md', value: 'README.md' }, - ]), - ); - }); - - it('should handle git discovery service initialization failure gracefully', async () => { - vi.mocked(fs.readdir).mockResolvedValue([ - { name: 'src', isDirectory: () => true }, - { name: 'README.md', isDirectory: () => false }, - ] as unknown as Awaited>); - - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('@'); - return useCompletion( - textBuffer, - testCwd, - slashCommands, - mockCommandContext, - mockConfig as Config, - ); - }); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - // Since we use centralized service, initialization errors are handled at config level - // This test should verify graceful fallback behavior - expect(result.current.suggestions.length).toBeGreaterThanOrEqual(0); - // Should still show completions even if git discovery fails - expect(result.current.suggestions.length).toBeGreaterThan(0); - - consoleSpy.mockRestore(); - }); - - it('should handle directory-specific completions with git filtering', async () => { - vi.mocked(fs.readdir).mockResolvedValue([ - { name: 'component.tsx', isDirectory: () => false }, - { name: 'temp.log', isDirectory: () => false }, - { name: 'index.ts', isDirectory: () => false }, - ] as unknown as Awaited>); - - mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation( - (path: string) => path.includes('.log'), - ); - mockFileDiscoveryService.shouldIgnoreFile.mockImplementation( - (path: string, options) => { - if (options?.respectGitIgnore) { - return mockFileDiscoveryService.shouldGitIgnoreFile(path); - } - if (options?.respectGeminiIgnore) { - return mockFileDiscoveryService.shouldGeminiIgnoreFile(path); - } - return false; - }, - ); - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('@src/comp'); - return useCompletion( - textBuffer, - testCwd, - slashCommands, - mockCommandContext, - mockConfig as Config, - ); - }); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - // Should filter out .log files but include matching .tsx files - expect(result.current.suggestions).toEqual([ - { label: 'component.tsx', value: 'component.tsx' }, - ]); - }); - - it('should use glob for top-level @ completions when available', async () => { - const globResults = [`${testCwd}/src/index.ts`, `${testCwd}/README.md`]; - vi.mocked(glob).mockResolvedValue(globResults); - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('@s'); - return useCompletion( - textBuffer, - testCwd, - slashCommands, - mockCommandContext, - mockConfig as Config, - ); - }); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(glob).toHaveBeenCalledWith('**/s*', { - cwd: testCwd, - dot: false, - nocase: true, - }); - expect(fs.readdir).not.toHaveBeenCalled(); // Ensure glob is used instead of readdir - expect(result.current.suggestions).toEqual([ - { label: 'README.md', value: 'README.md' }, - { label: 'src/index.ts', value: 'src/index.ts' }, - ]); - }); - - it('should include dotfiles in glob search when input starts with a dot', async () => { - const globResults = [ - `${testCwd}/.env`, - `${testCwd}/.gitignore`, - `${testCwd}/src/index.ts`, - ]; - vi.mocked(glob).mockResolvedValue(globResults); - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('@.'); - return useCompletion( - textBuffer, - testCwd, - slashCommands, - mockCommandContext, - mockConfig as Config, - ); - }); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(glob).toHaveBeenCalledWith('**/.*', { - cwd: testCwd, - dot: true, - nocase: true, - }); - expect(fs.readdir).not.toHaveBeenCalled(); - expect(result.current.suggestions).toEqual([ - { label: '.env', value: '.env' }, - { label: '.gitignore', value: '.gitignore' }, - { label: 'src/index.ts', value: 'src/index.ts' }, - ]); - }); - - it('should suggest top-level command names based on partial input', async () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/mem'); - return useCompletion( - textBuffer, - '/test/cwd', - mockSlashCommands, - mockCommandContext, - ); - }); - - expect(result.current.suggestions).toEqual([ - { label: 'memory', value: 'memory', description: 'Manage memory' }, - ]); - expect(result.current.showSuggestions).toBe(true); - }); - - it.each([['/?'], ['/usage']])( - 'should not suggest commands when altNames is fully typed', - async (altName) => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest(altName); - return useCompletion( - textBuffer, - '/test/cwd', - mockSlashCommands, - mockCommandContext, - ); - }); - - expect(result.current.suggestions).toHaveLength(0); - }, - ); - - it('should suggest commands based on partial altNames matches', async () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/usag'); // part of the word "usage" - return useCompletion( - textBuffer, - '/test/cwd', - mockSlashCommands, - mockCommandContext, - ); - }); - - expect(result.current.suggestions).toEqual([ - { - label: 'stats', - value: 'stats', - description: 'check session stats. Usage: /stats [model|tools]', - }, - ]); - }); - - it('should suggest sub-command names for a parent command', async () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/memory a'); - return useCompletion( - textBuffer, - '/test/cwd', - mockSlashCommands, - mockCommandContext, - ); - }); - - expect(result.current.suggestions).toEqual([ - { label: 'add', value: 'add', description: 'Add to memory' }, - ]); - }); - - it('should suggest all sub-commands when the query ends with the parent command and a space', async () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/memory '); - return useCompletion( - textBuffer, - '/test/cwd', - mockSlashCommands, - mockCommandContext, - ); - }); - - expect(result.current.suggestions).toHaveLength(2); - expect(result.current.suggestions).toEqual( - expect.arrayContaining([ - { label: 'show', value: 'show', description: 'Show memory' }, - { label: 'add', value: 'add', description: 'Add to memory' }, - ]), - ); - }); - - it('should call the command.completion function for argument suggestions', async () => { - const availableTags = ['my-chat-tag-1', 'my-chat-tag-2', 'another-channel']; - const mockCompletionFn = vi - .fn() - .mockImplementation( - async (_context: CommandContext, partialArg: string) => - availableTags.filter((tag) => tag.startsWith(partialArg)), - ); - - const mockCommandsWithFiltering = JSON.parse( - JSON.stringify(mockSlashCommands), - ) as SlashCommand[]; - - const chatCmd = mockCommandsWithFiltering.find( - (cmd) => cmd.name === 'chat', - ); - if (!chatCmd || !chatCmd.subCommands) { - throw new Error( - "Test setup error: Could not find the 'chat' command with subCommands in the mock data.", - ); - } - - const resumeCmd = chatCmd.subCommands.find((sc) => sc.name === 'resume'); - if (!resumeCmd) { - throw new Error( - "Test setup error: Could not find the 'resume' sub-command in the mock data.", - ); - } - - resumeCmd.completion = mockCompletionFn; - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/chat resume my-ch'); - return useCompletion( - textBuffer, - '/test/cwd', - mockCommandsWithFiltering, - mockCommandContext, - ); - }); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(mockCompletionFn).toHaveBeenCalledWith(mockCommandContext, 'my-ch'); - - expect(result.current.suggestions).toEqual([ - { label: 'my-chat-tag-1', value: 'my-chat-tag-1' }, - { label: 'my-chat-tag-2', value: 'my-chat-tag-2' }, - ]); - }); - - it('should not provide suggestions for a fully typed command that has no sub-commands or argument completion', async () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/clear '); - return useCompletion( - textBuffer, - '/test/cwd', - mockSlashCommands, - mockCommandContext, - ); - }); - - expect(result.current.suggestions).toHaveLength(0); - expect(result.current.showSuggestions).toBe(false); - }); - - it('should not provide suggestions for an unknown command', async () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/unknown-command'); - return useCompletion( - textBuffer, - '/test/cwd', - mockSlashCommands, - mockCommandContext, - ); - }); - - expect(result.current.suggestions).toHaveLength(0); - expect(result.current.showSuggestions).toBe(false); - }); - - it('should suggest sub-commands for a fully typed parent command without a trailing space', async () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/memory'); // Note: no trailing space - return useCompletion( - textBuffer, - '/test/cwd', - mockSlashCommands, - mockCommandContext, - ); - }); - - // Assert that suggestions for sub-commands are shown immediately - expect(result.current.suggestions).toHaveLength(2); - expect(result.current.suggestions).toEqual( - expect.arrayContaining([ - { label: 'show', value: 'show', description: 'Show memory' }, - { label: 'add', value: 'add', description: 'Add to memory' }, - ]), - ); - expect(result.current.showSuggestions).toBe(true); - }); - - it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/clear'); // No trailing space - return useCompletion( - textBuffer, - '/test/cwd', - mockSlashCommands, - mockCommandContext, - ); - }); - - expect(result.current.suggestions).toHaveLength(0); - expect(result.current.showSuggestions).toBe(false); - }); - - it('should call command.completion with an empty string when args start with a space', async () => { - const mockCompletionFn = vi - .fn() - .mockResolvedValue(['my-chat-tag-1', 'my-chat-tag-2', 'my-channel']); - - const isolatedMockCommands = JSON.parse( - JSON.stringify(mockSlashCommands), - ) as SlashCommand[]; - - const resumeCommand = isolatedMockCommands - .find((cmd) => cmd.name === 'chat') - ?.subCommands?.find((cmd) => cmd.name === 'resume'); - - if (!resumeCommand) { - throw new Error( - 'Test setup failed: could not find resume command in mock', - ); - } - resumeCommand.completion = mockCompletionFn; - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/chat resume '); // Trailing space, no partial argument - return useCompletion( - textBuffer, - '/test/cwd', - isolatedMockCommands, - mockCommandContext, - ); - }); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(mockCompletionFn).toHaveBeenCalledWith(mockCommandContext, ''); - expect(result.current.suggestions).toHaveLength(3); - expect(result.current.showSuggestions).toBe(true); - }); - - it('should suggest all top-level commands for the root slash', async () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/'); - return useCompletion( - textBuffer, - '/test/cwd', - mockSlashCommands, - mockCommandContext, - ); - }); - - expect(result.current.suggestions.length).toBe(mockSlashCommands.length); - expect(result.current.suggestions.map((s) => s.label)).toEqual( - expect.arrayContaining(['help', 'clear', 'memory', 'chat', 'stats']), - ); - }); - - it('should provide no suggestions for an invalid sub-command', async () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/memory dothisnow'); - return useCompletion( - textBuffer, - '/test/cwd', - mockSlashCommands, - mockCommandContext, - ); - }); - - expect(result.current.suggestions).toHaveLength(0); - expect(result.current.showSuggestions).toBe(false); - }); -}); diff --git a/packages/cli/src/ui/hooks/useCompletion.test.ts b/packages/cli/src/ui/hooks/useCompletion.test.ts index 96e8f156..cd525435 100644 --- a/packages/cli/src/ui/hooks/useCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCompletion.test.ts @@ -7,1068 +7,1086 @@ /** @vitest-environment jsdom */ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import type { Mocked } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useCompletion } from './useCompletion.js'; import * as fs from 'fs/promises'; -import { glob } from 'glob'; -import { - CommandContext, - CommandKind, - SlashCommand, -} from '../commands/types.js'; +import * as path from 'path'; +import * as os from 'os'; +import { CommandContext, SlashCommand } from '../commands/types.js'; import { Config, FileDiscoveryService } from '@google/gemini-cli-core'; -import { useTextBuffer } from '../components/shared/text-buffer.js'; - -// Helper to create real TextBuffer objects within renderHook -const useTextBufferForTest = (text: string) => { - const cursorOffset = text.length; - - return useTextBuffer({ - initialText: text, - initialCursorOffset: cursorOffset, - viewport: { width: 80, height: 20 }, - isValidPath: () => false, - onChange: () => {}, - }); -}; - -// Mock dependencies -vi.mock('fs/promises'); -vi.mock('glob'); -vi.mock('@google/gemini-cli-core', async () => { - const actual = await vi.importActual('@google/gemini-cli-core'); - return { - ...actual, - FileDiscoveryService: vi.fn(), - isNodeError: vi.fn((error) => error.code === 'ENOENT'), - escapePath: vi.fn((path) => path), - unescapePath: vi.fn((path) => path), - getErrorMessage: vi.fn((error) => error.message), - }; -}); -vi.mock('glob'); +import { useTextBuffer, TextBuffer } from '../components/shared/text-buffer.js'; describe('useCompletion', () => { - let mockFileDiscoveryService: Mocked; - let mockConfig: Mocked; - let mockCommandContext: CommandContext; - let mockSlashCommands: SlashCommand[]; + let testRootDir: string; + let mockConfig: Config; - const testCwd = '/test/project'; + // A minimal mock is sufficient for these tests. + const mockCommandContext = {} as CommandContext; - beforeEach(() => { - mockFileDiscoveryService = { - shouldGitIgnoreFile: vi.fn(), - shouldGeminiIgnoreFile: vi.fn(), - shouldIgnoreFile: vi.fn(), - filterFiles: vi.fn(), - getGeminiIgnorePatterns: vi.fn(), - projectRoot: '', - gitIgnoreFilter: null, - geminiIgnoreFilter: null, - } as unknown as Mocked; + async function createEmptyDir(...pathSegments: string[]) { + const fullPath = path.join(testRootDir, ...pathSegments); + await fs.mkdir(fullPath, { recursive: true }); + return fullPath; + } + async function createTestFile(content: string, ...pathSegments: string[]) { + const fullPath = path.join(testRootDir, ...pathSegments); + await fs.mkdir(path.dirname(fullPath), { recursive: true }); + await fs.writeFile(fullPath, content); + return fullPath; + } + + // Helper to create real TextBuffer objects within renderHook + function useTextBufferForTest(text: string) { + return useTextBuffer({ + initialText: text, + initialCursorOffset: text.length, + viewport: { width: 80, height: 20 }, + isValidPath: () => false, + onChange: () => {}, + }); + } + + beforeEach(async () => { + testRootDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'completion-unit-test-'), + ); mockConfig = { - getFileFilteringRespectGitIgnore: vi.fn(() => true), - getFileService: vi.fn().mockReturnValue(mockFileDiscoveryService), - getEnableRecursiveFileSearch: vi.fn(() => true), + getTargetDir: () => testRootDir, + getProjectRoot: () => testRootDir, getFileFilteringOptions: vi.fn(() => ({ respectGitIgnore: true, respectGeminiIgnore: true, })), - } as unknown as Mocked; - - mockCommandContext = {} as CommandContext; - - mockSlashCommands = [ - { - name: 'help', - altNames: ['?'], - description: 'Show help', - action: vi.fn(), - kind: CommandKind.BUILT_IN, - }, - { - name: 'stats', - altNames: ['usage'], - description: 'check session stats. Usage: /stats [model|tools]', - action: vi.fn(), - kind: CommandKind.BUILT_IN, - }, - { - name: 'clear', - description: 'Clear the screen', - action: vi.fn(), - kind: CommandKind.BUILT_IN, - }, - { - name: 'memory', - description: 'Manage memory', - kind: CommandKind.BUILT_IN, - subCommands: [ - { - name: 'show', - description: 'Show memory', - kind: CommandKind.BUILT_IN, - action: vi.fn(), - }, - { - name: 'add', - description: 'Add to memory', - kind: CommandKind.BUILT_IN, - action: vi.fn(), - }, - ], - }, - { - name: 'chat', - description: 'Manage chat history', - kind: CommandKind.BUILT_IN, - subCommands: [ - { - name: 'save', - description: 'Save chat', - kind: CommandKind.BUILT_IN, - - action: vi.fn(), - }, - { - name: 'resume', - description: 'Resume a saved chat', - kind: CommandKind.BUILT_IN, - - action: vi.fn(), - completion: vi.fn().mockResolvedValue(['chat1', 'chat2']), - }, - ], - }, - ]; + getEnableRecursiveFileSearch: vi.fn(() => true), + getFileService: vi.fn(() => new FileDiscoveryService(testRootDir)), + } as unknown as Config; vi.clearAllMocks(); }); - afterEach(() => { + afterEach(async () => { vi.restoreAllMocks(); + await fs.rm(testRootDir, { recursive: true, force: true }); }); - describe('Hook initialization and state', () => { - it('should initialize with default state', () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest(''); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, + describe('Core Hook Behavior', () => { + describe('State Management', () => { + it('should initialize with default state', () => { + const slashCommands = [ + { name: 'dummy', description: 'dummy' }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest(''), + testRootDir, + slashCommands, + mockCommandContext, + mockConfig, + ), + ); + + expect(result.current.suggestions).toEqual([]); + expect(result.current.activeSuggestionIndex).toBe(-1); + expect(result.current.visibleStartIndex).toBe(0); + expect(result.current.showSuggestions).toBe(false); + expect(result.current.isLoadingSuggestions).toBe(false); + }); + + it('should reset state when isActive becomes false', () => { + const slashCommands = [ + { + name: 'help', + altNames: ['?'], + description: 'Show help', + action: vi.fn(), + }, + ] as unknown as SlashCommand[]; + + const { result, rerender } = renderHook( + ({ text }) => { + const textBuffer = useTextBufferForTest(text); + return useCompletion( + textBuffer, + testRootDir, + slashCommands, + mockCommandContext, + mockConfig, + ); + }, + { initialProps: { text: '/help' } }, + ); + + // Inactive because of the leading space + rerender({ text: ' /help' }); + + expect(result.current.suggestions).toEqual([]); + expect(result.current.activeSuggestionIndex).toBe(-1); + expect(result.current.visibleStartIndex).toBe(0); + expect(result.current.showSuggestions).toBe(false); + expect(result.current.isLoadingSuggestions).toBe(false); + }); + + it('should reset all state to default values', () => { + const slashCommands = [ + { + name: 'help', + description: 'Show help', + }, + ] as unknown as SlashCommand[]; + + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('/help'), + testRootDir, + slashCommands, + mockCommandContext, + mockConfig, + ), + ); + + act(() => { + result.current.setActiveSuggestionIndex(5); + result.current.setShowSuggestions(true); + }); + + act(() => { + result.current.resetCompletionState(); + }); + + expect(result.current.suggestions).toEqual([]); + expect(result.current.activeSuggestionIndex).toBe(-1); + expect(result.current.visibleStartIndex).toBe(0); + expect(result.current.showSuggestions).toBe(false); + expect(result.current.isLoadingSuggestions).toBe(false); + }); + }); + + describe('Navigation', () => { + it('should handle navigateUp with no suggestions', () => { + const slashCommands = [ + { name: 'dummy', description: 'dummy' }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest(''), + testRootDir, + slashCommands, + mockCommandContext, + mockConfig, + ), + ); + + act(() => { + result.current.navigateUp(); + }); + + expect(result.current.activeSuggestionIndex).toBe(-1); + }); + + it('should handle navigateDown with no suggestions', () => { + const slashCommands = [ + { name: 'dummy', description: 'dummy' }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest(''), + testRootDir, + slashCommands, + mockCommandContext, + mockConfig, + ), + ); + + act(() => { + result.current.navigateDown(); + }); + + expect(result.current.activeSuggestionIndex).toBe(-1); + }); + + it('should navigate up through suggestions with wrap-around', () => { + const slashCommands = [ + { + name: 'help', + description: 'Show help', + }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('/h'), + testRootDir, + slashCommands, + mockCommandContext, + mockConfig, + ), + ); + + expect(result.current.suggestions.length).toBe(1); + expect(result.current.activeSuggestionIndex).toBe(0); + + act(() => { + result.current.navigateUp(); + }); + + expect(result.current.activeSuggestionIndex).toBe(0); + }); + + it('should navigate down through suggestions with wrap-around', () => { + const slashCommands = [ + { + name: 'help', + description: 'Show help', + }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('/h'), + testRootDir, + slashCommands, + mockCommandContext, + mockConfig, + ), + ); + + expect(result.current.suggestions.length).toBe(1); + expect(result.current.activeSuggestionIndex).toBe(0); + + act(() => { + result.current.navigateDown(); + }); + + expect(result.current.activeSuggestionIndex).toBe(0); + }); + + it('should handle navigation with multiple suggestions', () => { + const slashCommands = [ + { name: 'help', description: 'Show help' }, + { name: 'stats', description: 'Show stats' }, + { name: 'clear', description: 'Clear screen' }, + { name: 'memory', description: 'Manage memory' }, + { name: 'chat', description: 'Manage chat' }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('/'), + testRootDir, + slashCommands, + mockCommandContext, + mockConfig, + ), + ); + + expect(result.current.suggestions.length).toBe(5); + expect(result.current.activeSuggestionIndex).toBe(0); + + act(() => { + result.current.navigateDown(); + }); + expect(result.current.activeSuggestionIndex).toBe(1); + + act(() => { + result.current.navigateDown(); + }); + expect(result.current.activeSuggestionIndex).toBe(2); + + act(() => { + result.current.navigateUp(); + }); + expect(result.current.activeSuggestionIndex).toBe(1); + + act(() => { + result.current.navigateUp(); + }); + expect(result.current.activeSuggestionIndex).toBe(0); + + act(() => { + result.current.navigateUp(); + }); + expect(result.current.activeSuggestionIndex).toBe(4); + }); + + it('should handle navigation with large suggestion lists and scrolling', () => { + const largeMockCommands = Array.from({ length: 15 }, (_, i) => ({ + name: `command${i}`, + description: `Command ${i}`, + })) as unknown as SlashCommand[]; + + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('/command'), + testRootDir, + largeMockCommands, + mockCommandContext, + mockConfig, + ), + ); + + expect(result.current.suggestions.length).toBe(15); + expect(result.current.activeSuggestionIndex).toBe(0); + expect(result.current.visibleStartIndex).toBe(0); + + act(() => { + result.current.navigateUp(); + }); + + expect(result.current.activeSuggestionIndex).toBe(14); + expect(result.current.visibleStartIndex).toBe(Math.max(0, 15 - 8)); + }); + }); + }); + + describe('Slash Command Completion (`/`)', () => { + describe('Top-Level Commands', () => { + it('should suggest all top-level commands for the root slash', async () => { + const slashCommands = [ + { + name: 'help', + altNames: ['?'], + description: 'Show help', + }, + { + name: 'stats', + altNames: ['usage'], + description: 'check session stats. Usage: /stats [model|tools]', + }, + { + name: 'clear', + description: 'Clear the screen', + }, + { + name: 'memory', + description: 'Manage memory', + subCommands: [ + { + name: 'show', + description: 'Show memory', + }, + ], + }, + { + name: 'chat', + description: 'Manage chat history', + }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('/'), + testRootDir, + slashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions.length).toBe(slashCommands.length); + expect(result.current.suggestions.map((s) => s.label)).toEqual( + expect.arrayContaining(['help', 'clear', 'memory', 'chat', 'stats']), ); }); - expect(result.current.suggestions).toEqual([]); - expect(result.current.activeSuggestionIndex).toBe(-1); - expect(result.current.visibleStartIndex).toBe(0); - expect(result.current.showSuggestions).toBe(false); - expect(result.current.isLoadingSuggestions).toBe(false); - }); - - it('should reset state when query becomes inactive', () => { - const { result, rerender } = renderHook( - ({ text }) => { - const textBuffer = useTextBufferForTest(text); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, + it('should filter commands based on partial input', async () => { + const slashCommands = [ + { + name: 'memory', + description: 'Manage memory', + }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('/mem'), + testRootDir, + slashCommands, mockCommandContext, - mockConfig, + ), + ); + + expect(result.current.suggestions).toEqual([ + { label: 'memory', value: 'memory', description: 'Manage memory' }, + ]); + expect(result.current.showSuggestions).toBe(true); + }); + + it('should suggest commands based on partial altNames', async () => { + const slashCommands = [ + { + name: 'stats', + altNames: ['usage'], + description: 'check session stats. Usage: /stats [model|tools]', + }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('/usag'), // part of the word "usage" + testRootDir, + slashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toEqual([ + { + label: 'stats', + value: 'stats', + description: 'check session stats. Usage: /stats [model|tools]', + }, + ]); + }); + + it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => { + const slashCommands = [ + { + name: 'clear', + description: 'Clear the screen', + action: vi.fn(), + }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('/clear'), // No trailing space + testRootDir, + slashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toHaveLength(0); + expect(result.current.showSuggestions).toBe(false); + }); + + it.each([['/?'], ['/usage']])( + 'should not suggest commands when altNames is fully typed', + async (query) => { + const mockSlashCommands = [ + { + name: 'help', + altNames: ['?'], + description: 'Show help', + action: vi.fn(), + }, + { + name: 'stats', + altNames: ['usage'], + description: 'check session stats. Usage: /stats [model|tools]', + action: vi.fn(), + }, + ] as unknown as SlashCommand[]; + + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest(query), + testRootDir, + mockSlashCommands, + mockCommandContext, + ), ); + + expect(result.current.suggestions).toHaveLength(0); }, - { initialProps: { text: '/help' } }, ); - // Inactive because of the leading space - rerender({ text: ' /help' }); + it('should not provide suggestions for a fully typed command that has no sub-commands or argument completion', async () => { + const slashCommands = [ + { + name: 'clear', + description: 'Clear the screen', + }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('/clear '), + testRootDir, + slashCommands, + mockCommandContext, + ), + ); - expect(result.current.suggestions).toEqual([]); - expect(result.current.activeSuggestionIndex).toBe(-1); - expect(result.current.visibleStartIndex).toBe(0); - expect(result.current.showSuggestions).toBe(false); - expect(result.current.isLoadingSuggestions).toBe(false); + expect(result.current.suggestions).toHaveLength(0); + expect(result.current.showSuggestions).toBe(false); + }); + + it('should not provide suggestions for an unknown command', async () => { + const slashCommands = [ + { + name: 'help', + description: 'Show help', + }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('/unknown-command'), + testRootDir, + slashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toHaveLength(0); + expect(result.current.showSuggestions).toBe(false); + }); }); - it('should provide required functions', () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest(''); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, + describe('Sub-Commands', () => { + it('should suggest sub-commands for a parent command', async () => { + const slashCommands = [ + { + name: 'memory', + description: 'Manage memory', + subCommands: [ + { + name: 'show', + description: 'Show memory', + }, + { + name: 'add', + description: 'Add to memory', + }, + ], + }, + ] as unknown as SlashCommand[]; + + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('/memory'), // Note: no trailing space + testRootDir, + slashCommands, + mockCommandContext, + ), + ); + + // Assert that suggestions for sub-commands are shown immediately + expect(result.current.suggestions).toHaveLength(2); + expect(result.current.suggestions).toEqual( + expect.arrayContaining([ + { label: 'show', value: 'show', description: 'Show memory' }, + { label: 'add', value: 'add', description: 'Add to memory' }, + ]), + ); + expect(result.current.showSuggestions).toBe(true); + }); + + it('should suggest all sub-commands when the query ends with the parent command and a space', async () => { + const slashCommands = [ + { + name: 'memory', + description: 'Manage memory', + subCommands: [ + { + name: 'show', + description: 'Show memory', + }, + { + name: 'add', + description: 'Add to memory', + }, + ], + }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('/memory'), + testRootDir, + slashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toHaveLength(2); + expect(result.current.suggestions).toEqual( + expect.arrayContaining([ + { label: 'show', value: 'show', description: 'Show memory' }, + { label: 'add', value: 'add', description: 'Add to memory' }, + ]), ); }); - expect(typeof result.current.setActiveSuggestionIndex).toBe('function'); - expect(typeof result.current.setShowSuggestions).toBe('function'); - expect(typeof result.current.resetCompletionState).toBe('function'); - expect(typeof result.current.navigateUp).toBe('function'); - expect(typeof result.current.navigateDown).toBe('function'); - }); - }); - - describe('resetCompletionState', () => { - it('should reset all state to default values', () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/help'); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, + it('should filter sub-commands by prefix', async () => { + const slashCommands = [ + { + name: 'memory', + description: 'Manage memory', + subCommands: [ + { + name: 'show', + description: 'Show memory', + }, + { + name: 'add', + description: 'Add to memory', + }, + ], + }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('/memory a'), + testRootDir, + slashCommands, + mockCommandContext, + ), ); + + expect(result.current.suggestions).toEqual([ + { label: 'add', value: 'add', description: 'Add to memory' }, + ]); }); - act(() => { - result.current.setActiveSuggestionIndex(5); - result.current.setShowSuggestions(true); - }); - - act(() => { - result.current.resetCompletionState(); - }); - - expect(result.current.suggestions).toEqual([]); - expect(result.current.activeSuggestionIndex).toBe(-1); - expect(result.current.visibleStartIndex).toBe(0); - expect(result.current.showSuggestions).toBe(false); - expect(result.current.isLoadingSuggestions).toBe(false); - }); - }); - - describe('Navigation functions', () => { - it('should handle navigateUp with no suggestions', () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest(''); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, + it('should provide no suggestions for an invalid sub-command', async () => { + const slashCommands = [ + { + name: 'memory', + description: 'Manage memory', + subCommands: [ + { + name: 'show', + description: 'Show memory', + }, + { + name: 'add', + description: 'Add to memory', + }, + ], + }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('/memory dothisnow'), + testRootDir, + slashCommands, + mockCommandContext, + ), ); - }); - act(() => { - result.current.navigateUp(); + expect(result.current.suggestions).toHaveLength(0); + expect(result.current.showSuggestions).toBe(false); }); - - expect(result.current.activeSuggestionIndex).toBe(-1); }); - it('should handle navigateDown with no suggestions', () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest(''); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, + describe('Argument Completion', () => { + it('should call the command.completion function for argument suggestions', async () => { + const availableTags = [ + 'my-chat-tag-1', + 'my-chat-tag-2', + 'another-channel', + ]; + const mockCompletionFn = vi + .fn() + .mockImplementation( + async (_context: CommandContext, partialArg: string) => + availableTags.filter((tag) => tag.startsWith(partialArg)), + ); + + const slashCommands = [ + { + name: 'chat', + description: 'Manage chat history', + subCommands: [ + { + name: 'resume', + description: 'Resume a saved chat', + completion: mockCompletionFn, + }, + ], + }, + ] as unknown as SlashCommand[]; + + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('/chat resume my-ch'), + testRootDir, + slashCommands, + mockCommandContext, + ), ); - }); - act(() => { - result.current.navigateDown(); - }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); - expect(result.current.activeSuggestionIndex).toBe(-1); - }); - - it('should navigate up through suggestions with wrap-around', () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/h'); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, + expect(mockCompletionFn).toHaveBeenCalledWith( mockCommandContext, - mockConfig, + 'my-ch', ); + + expect(result.current.suggestions).toEqual([ + { label: 'my-chat-tag-1', value: 'my-chat-tag-1' }, + { label: 'my-chat-tag-2', value: 'my-chat-tag-2' }, + ]); }); - expect(result.current.suggestions.length).toBe(1); - expect(result.current.activeSuggestionIndex).toBe(0); + it('should call command.completion with an empty string when args start with a space', async () => { + const mockCompletionFn = vi + .fn() + .mockResolvedValue(['my-chat-tag-1', 'my-chat-tag-2', 'my-channel']); - act(() => { - result.current.navigateUp(); - }); + const slashCommands = [ + { + name: 'chat', + description: 'Manage chat history', + subCommands: [ + { + name: 'resume', + description: 'Resume a saved chat', + completion: mockCompletionFn, + }, + ], + }, + ] as unknown as SlashCommand[]; - expect(result.current.activeSuggestionIndex).toBe(0); - }); - - it('should navigate down through suggestions with wrap-around', () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/h'); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('/chat resume '), + testRootDir, + slashCommands, + mockCommandContext, + ), ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + expect(mockCompletionFn).toHaveBeenCalledWith(mockCommandContext, ''); + expect(result.current.suggestions).toHaveLength(3); + expect(result.current.showSuggestions).toBe(true); }); - expect(result.current.suggestions.length).toBe(1); - expect(result.current.activeSuggestionIndex).toBe(0); + it('should handle completion function that returns null', async () => { + const completionFn = vi.fn().mockResolvedValue(null); + const slashCommands = [ + { + name: 'chat', + description: 'Manage chat history', + subCommands: [ + { + name: 'resume', + description: 'Resume a saved chat', + completion: completionFn, + }, + ], + }, + ] as unknown as SlashCommand[]; - act(() => { - result.current.navigateDown(); - }); - - expect(result.current.activeSuggestionIndex).toBe(0); - }); - - it('should handle navigation with multiple suggestions', () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/'); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, - ); - }); - - expect(result.current.suggestions.length).toBe(5); - expect(result.current.activeSuggestionIndex).toBe(0); - - act(() => { - result.current.navigateDown(); - }); - expect(result.current.activeSuggestionIndex).toBe(1); - - act(() => { - result.current.navigateDown(); - }); - expect(result.current.activeSuggestionIndex).toBe(2); - - act(() => { - result.current.navigateUp(); - }); - expect(result.current.activeSuggestionIndex).toBe(1); - - act(() => { - result.current.navigateUp(); - }); - expect(result.current.activeSuggestionIndex).toBe(0); - - act(() => { - result.current.navigateUp(); - }); - expect(result.current.activeSuggestionIndex).toBe(4); - }); - - it('should handle navigation with large suggestion lists and scrolling', () => { - const largeMockCommands = Array.from({ length: 15 }, (_, i) => ({ - name: `command${i}`, - description: `Command ${i}`, - kind: CommandKind.BUILT_IN, - action: vi.fn(), - })); - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/command'); - return useCompletion( - textBuffer, - testCwd, - largeMockCommands, - mockCommandContext, - mockConfig, - ); - }); - - expect(result.current.suggestions.length).toBe(15); - expect(result.current.activeSuggestionIndex).toBe(0); - expect(result.current.visibleStartIndex).toBe(0); - - act(() => { - result.current.navigateUp(); - }); - - expect(result.current.activeSuggestionIndex).toBe(14); - expect(result.current.visibleStartIndex).toBe(Math.max(0, 15 - 8)); - }); - }); - - describe('Slash command completion', () => { - it('should show all commands for root slash', () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/'); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, - ); - }); - - expect(result.current.suggestions).toHaveLength(5); - expect(result.current.suggestions.map((s) => s.label)).toEqual( - expect.arrayContaining(['help', 'clear', 'memory', 'chat', 'stats']), - ); - expect(result.current.showSuggestions).toBe(true); - expect(result.current.activeSuggestionIndex).toBe(0); - }); - - it('should filter commands by prefix', () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/h'); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, - ); - }); - - expect(result.current.suggestions).toHaveLength(1); - expect(result.current.suggestions[0].label).toBe('help'); - expect(result.current.suggestions[0].description).toBe('Show help'); - }); - - it.each([['/?'], ['/usage']])( - 'should not suggest commands when altNames is fully typed', - (altName) => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest(altName); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('/chat resume '), + testRootDir, + slashCommands, mockCommandContext, mockConfig, - ); + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); }); expect(result.current.suggestions).toHaveLength(0); - }, - ); - - it('should suggest commands based on partial altNames matches', () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/usag'); // part of the word "usage" - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, - ); + expect(result.current.showSuggestions).toBe(false); }); - - expect(result.current.suggestions).toHaveLength(1); - expect(result.current.suggestions[0].label).toBe('stats'); - }); - - it('should not show suggestions for exact leaf command match', () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/clear'); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, - ); - }); - - expect(result.current.suggestions).toHaveLength(0); - expect(result.current.showSuggestions).toBe(false); - }); - - it('should show sub-commands for parent commands', () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/memory'); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, - ); - }); - - expect(result.current.suggestions).toHaveLength(2); - expect(result.current.suggestions.map((s) => s.label)).toEqual( - expect.arrayContaining(['show', 'add']), - ); - }); - - it('should show all sub-commands after parent command with space', () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/memory '); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, - ); - }); - - expect(result.current.suggestions).toHaveLength(2); - expect(result.current.suggestions.map((s) => s.label)).toEqual( - expect.arrayContaining(['show', 'add']), - ); - }); - - it('should filter sub-commands by prefix', () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/memory a'); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, - ); - }); - - expect(result.current.suggestions).toHaveLength(1); - expect(result.current.suggestions[0].label).toBe('add'); - }); - - it('should handle unknown command gracefully', () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/unknown'); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, - ); - }); - - expect(result.current.suggestions).toHaveLength(0); - expect(result.current.showSuggestions).toBe(false); }); }); - describe('Command argument completion', () => { - it('should call completion function for command arguments', async () => { - const completionFn = vi.fn().mockResolvedValue(['arg1', 'arg2']); - const commandsWithCompletion = [...mockSlashCommands]; - const chatCommand = commandsWithCompletion.find( - (cmd) => cmd.name === 'chat', - ); - const resumeCommand = chatCommand?.subCommands?.find( - (cmd) => cmd.name === 'resume', - ); - if (resumeCommand) { - resumeCommand.completion = completionFn; - } + describe('File Path Completion (`@`)', () => { + describe('Basic Completion', () => { + it('should use glob for top-level @ completions when available', async () => { + await createTestFile('', 'src', 'index.ts'); + await createTestFile('', 'derp', 'script.ts'); + await createTestFile('', 'README.md'); - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/chat resume '); - return useCompletion( - textBuffer, - testCwd, - commandsWithCompletion, - mockCommandContext, - mockConfig, - ); - }); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(completionFn).toHaveBeenCalledWith(mockCommandContext, ''); - expect(result.current.suggestions).toHaveLength(2); - expect(result.current.suggestions.map((s) => s.label)).toEqual([ - 'arg1', - 'arg2', - ]); - }); - - it('should call completion function with partial argument', async () => { - const completionFn = vi.fn().mockResolvedValue(['arg1', 'arg2']); - const commandsWithCompletion = [...mockSlashCommands]; - const chatCommand = commandsWithCompletion.find( - (cmd) => cmd.name === 'chat', - ); - const resumeCommand = chatCommand?.subCommands?.find( - (cmd) => cmd.name === 'resume', - ); - if (resumeCommand) { - resumeCommand.completion = completionFn; - } - - renderHook(() => { - const textBuffer = useTextBufferForTest('/chat resume ar'); - return useCompletion( - textBuffer, - testCwd, - commandsWithCompletion, - mockCommandContext, - mockConfig, - ); - }); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(completionFn).toHaveBeenCalledWith(mockCommandContext, 'ar'); - }); - - it('should handle completion function that returns null', async () => { - const completionFn = vi.fn().mockResolvedValue(null); - const commandsWithCompletion = [...mockSlashCommands]; - const chatCommand = commandsWithCompletion.find( - (cmd) => cmd.name === 'chat', - ); - const resumeCommand = chatCommand?.subCommands?.find( - (cmd) => cmd.name === 'resume', - ); - if (resumeCommand) { - resumeCommand.completion = completionFn; - } - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/chat resume '); - return useCompletion( - textBuffer, - testCwd, - commandsWithCompletion, - mockCommandContext, - mockConfig, - ); - }); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(result.current.suggestions).toHaveLength(0); - expect(result.current.showSuggestions).toBe(false); - }); - }); - - describe('Slash command completion with namespaced names', () => { - let commandsWithNamespaces: SlashCommand[]; - - beforeEach(() => { - commandsWithNamespaces = [ - ...mockSlashCommands, - { - name: 'git:commit', - description: 'A namespaced git command', - kind: CommandKind.FILE, - action: vi.fn(), - }, - { - name: 'git:push', - description: 'Another namespaced git command', - kind: CommandKind.FILE, - action: vi.fn(), - }, - { - name: 'docker:build', - description: 'A docker command', - kind: CommandKind.FILE, - action: vi.fn(), - }, - ]; - }); - - it('should suggest a namespaced command based on a partial match', () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/git:co'); - return useCompletion( - textBuffer, - testCwd, - commandsWithNamespaces, - mockCommandContext, - mockConfig, - ); - }); - - expect(result.current.suggestions).toHaveLength(1); - expect(result.current.suggestions[0].label).toBe('git:commit'); - }); - - it('should suggest all commands within a namespace when the namespace prefix is typed', () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/git:'); - return useCompletion( - textBuffer, - testCwd, - commandsWithNamespaces, - mockCommandContext, - mockConfig, - ); - }); - - expect(result.current.suggestions).toHaveLength(2); - expect(result.current.suggestions.map((s) => s.label)).toEqual( - expect.arrayContaining(['git:commit', 'git:push']), - ); - - expect(result.current.suggestions.map((s) => s.label)).not.toContain( - 'docker:build', - ); - }); - - it('should not provide suggestions if the namespaced command is a perfect leaf match', () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/git:commit'); - return useCompletion( - textBuffer, - testCwd, - commandsWithNamespaces, - mockCommandContext, - mockConfig, - ); - }); - - expect(result.current.showSuggestions).toBe(false); - expect(result.current.suggestions).toHaveLength(0); - }); - }); - - describe('File path completion (@-syntax)', () => { - beforeEach(() => { - vi.mocked(fs.readdir).mockResolvedValue([ - { name: 'file1.txt', isDirectory: () => false }, - { name: 'file2.js', isDirectory: () => false }, - { name: 'folder1', isDirectory: () => true }, - { name: '.hidden', isDirectory: () => false }, - ] as unknown as Awaited>); - }); - - it('should show file completions for @ prefix', async () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('@'); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, - ); - }); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(result.current.suggestions).toHaveLength(3); - expect(result.current.suggestions.map((s) => s.label)).toEqual( - expect.arrayContaining(['file1.txt', 'file2.js', 'folder1/']), - ); - }); - - it('should filter files by prefix', async () => { - // Mock for recursive search since enableRecursiveFileSearch is true - vi.mocked(glob).mockResolvedValue([ - `${testCwd}/file1.txt`, - `${testCwd}/file2.js`, - ]); - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('@file'); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, - ); - }); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(result.current.suggestions).toHaveLength(2); - expect(result.current.suggestions.map((s) => s.label)).toEqual( - expect.arrayContaining(['file1.txt', 'file2.js']), - ); - }); - - it('should include hidden files when prefix starts with dot', async () => { - // Mock for recursive search since enableRecursiveFileSearch is true - vi.mocked(glob).mockResolvedValue([`${testCwd}/.hidden`]); - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('@.'); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, - ); - }); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(result.current.suggestions).toHaveLength(1); - expect(result.current.suggestions[0].label).toBe('.hidden'); - }); - - it('should handle ENOENT error gracefully', async () => { - const enoentError = new Error('No such file or directory'); - (enoentError as Error & { code: string }).code = 'ENOENT'; - vi.mocked(fs.readdir).mockRejectedValue(enoentError); - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('@nonexistent'); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, - ); - }); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(result.current.suggestions).toHaveLength(0); - expect(result.current.showSuggestions).toBe(false); - }); - - it('should handle other errors by resetting state', async () => { - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - vi.mocked(fs.readdir).mockRejectedValue(new Error('Permission denied')); - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('@'); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, - ); - }); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(consoleErrorSpy).toHaveBeenCalled(); - expect(result.current.suggestions).toHaveLength(0); - expect(result.current.showSuggestions).toBe(false); - expect(result.current.isLoadingSuggestions).toBe(false); - - consoleErrorSpy.mockRestore(); - }); - }); - - describe('Debouncing', () => { - it('should debounce file completion requests', async () => { - // Mock for recursive search since enableRecursiveFileSearch is true - vi.mocked(glob).mockResolvedValue([`${testCwd}/file1.txt`]); - - const { rerender } = renderHook( - ({ text }) => { - const textBuffer = useTextBufferForTest(text); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('@s'), + testRootDir, + [], mockCommandContext, mockConfig, - ); - }, - { initialProps: { text: '@f' } }, - ); + ), + ); - rerender({ text: '@fi' }); - rerender({ text: '@fil' }); - rerender({ text: '@file' }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(glob).toHaveBeenCalledTimes(1); - }); - }); - - describe('Query handling edge cases', () => { - it('should handle empty query', () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest(''); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, + expect(result.current.suggestions).toHaveLength(2); + expect(result.current.suggestions).toEqual( + expect.arrayContaining([ + { + label: 'derp/script.ts', + value: 'derp/script.ts', + }, + { label: 'src', value: 'src' }, + ]), ); }); - expect(result.current.suggestions).toHaveLength(0); - expect(result.current.showSuggestions).toBe(false); + it('should handle directory-specific completions with git filtering', async () => { + await createEmptyDir('.git'); + await createTestFile('*.log', '.gitignore'); + await createTestFile('', 'src', 'component.tsx'); + await createTestFile('', 'src', 'temp.log'); + await createTestFile('', 'src', 'index.ts'); + + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('@src/comp'), + testRootDir, + [], + mockCommandContext, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + // Should filter out .log files but include matching .tsx files + expect(result.current.suggestions).toEqual([ + { label: 'component.tsx', value: 'component.tsx' }, + ]); + }); + + it('should include dotfiles in glob search when input starts with a dot', async () => { + await createTestFile('', '.env'); + await createTestFile('', '.gitignore'); + await createTestFile('', 'src', 'index.ts'); + + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('@.'), + testRootDir, + [], + mockCommandContext, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + expect(result.current.suggestions).toEqual([ + { label: '.env', value: '.env' }, + { label: '.gitignore', value: '.gitignore' }, + ]); + }); }); - it('should handle query without slash or @', () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('regular text'); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, + describe('Configuration-based Behavior', () => { + it('should not perform recursive search when disabled in config', async () => { + const mockConfigNoRecursive = { + ...mockConfig, + getEnableRecursiveFileSearch: vi.fn(() => false), + } as unknown as Config; + + await createEmptyDir('data'); + await createEmptyDir('dist'); + + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('@d'), + testRootDir, + [], + mockCommandContext, + mockConfigNoRecursive, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + expect(result.current.suggestions).toEqual([ + { label: 'data/', value: 'data/' }, + { label: 'dist/', value: 'dist/' }, + ]); + }); + + it('should work without config (fallback behavior)', async () => { + await createEmptyDir('src'); + await createEmptyDir('node_modules'); + await createTestFile('', 'README.md'); + + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('@'), + testRootDir, + [], + mockCommandContext, + undefined, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + // Without config, should include all files + expect(result.current.suggestions).toHaveLength(3); + expect(result.current.suggestions).toEqual( + expect.arrayContaining([ + { label: 'src/', value: 'src/' }, + { label: 'node_modules/', value: 'node_modules/' }, + { label: 'README.md', value: 'README.md' }, + ]), ); }); - expect(result.current.suggestions).toHaveLength(0); - expect(result.current.showSuggestions).toBe(false); + it('should handle git discovery service initialization failure gracefully', async () => { + // Intentionally don't create a .git directory to cause an initialization failure. + await createEmptyDir('src'); + await createTestFile('', 'README.md'); + + const consoleSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('@'), + testRootDir, + [], + mockCommandContext, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + // Since we use centralized service, initialization errors are handled at config level + // This test should verify graceful fallback behavior + expect(result.current.suggestions.length).toBeGreaterThanOrEqual(0); + // Should still show completions even if git discovery fails + expect(result.current.suggestions.length).toBeGreaterThan(0); + + consoleSpy.mockRestore(); + }); }); - it('should handle query with whitespace', () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest(' /hel'); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, + describe('Git-Aware Filtering', () => { + it('should filter git-ignored entries from @ completions', async () => { + await createEmptyDir('.git'); + await createTestFile('dist', '.gitignore'); + await createEmptyDir('data'); + + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('@d'), + testRootDir, + [], + mockCommandContext, + mockConfig, + ), ); - }); - expect(result.current.suggestions).toHaveLength(1); - expect(result.current.suggestions[0].label).toBe('help'); - }); + // Wait for async operations to complete + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); // Account for debounce + }); - it('should handle @ at the end of query', async () => { - // Mock for recursive search since enableRecursiveFileSearch is true - vi.mocked(glob).mockResolvedValue([`${testCwd}/file1.txt`]); - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('some text @'); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, + expect(result.current.suggestions).toEqual( + expect.arrayContaining([{ label: 'data', value: 'data' }]), ); + expect(result.current.showSuggestions).toBe(true); }); - // Wait for completion - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); + it('should filter git-ignored directories from @ completions', async () => { + await createEmptyDir('.git'); + await createTestFile('node_modules\ndist\n.env', '.gitignore'); + // gitignored entries + await createEmptyDir('node_modules'); + await createEmptyDir('dist'); + await createTestFile('', '.env'); - // Should process the @ query and get suggestions - expect(result.current.isLoadingSuggestions).toBe(false); - expect(result.current.suggestions.length).toBeGreaterThanOrEqual(0); - }); - }); + // visible + await createEmptyDir('src'); + await createTestFile('', 'README.md'); - describe('File sorting behavior', () => { - it('should prioritize source files over test files with same base name', async () => { - // Mock glob to return files with same base name but different extensions - vi.mocked(glob).mockResolvedValue([ - `${testCwd}/component.test.ts`, - `${testCwd}/component.ts`, - `${testCwd}/utils.spec.js`, - `${testCwd}/utils.js`, - `${testCwd}/api.test.tsx`, - `${testCwd}/api.tsx`, - ]); - - mockFileDiscoveryService.shouldIgnoreFile.mockReturnValue(false); - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('@comp'); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('@'), + testRootDir, + [], + mockCommandContext, + mockConfig, + ), ); + + // Wait for async operations to complete + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); // Account for debounce + }); + + expect(result.current.suggestions).toEqual([ + { label: 'README.md', value: 'README.md' }, + { label: 'src/', value: 'src/' }, + ]); + expect(result.current.showSuggestions).toBe(true); }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); + it('should handle recursive search with git-aware filtering', async () => { + await createEmptyDir('.git'); + await createTestFile('node_modules/\ntemp/', '.gitignore'); + await createTestFile('', 'data', 'test.txt'); + await createEmptyDir('dist'); + await createEmptyDir('node_modules'); + await createTestFile('', 'src', 'index.ts'); + await createEmptyDir('src', 'components'); + await createTestFile('', 'temp', 'temp.log'); + + const { result } = renderHook(() => + useCompletion( + useTextBufferForTest('@t'), + testRootDir, + [], + mockCommandContext, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + // Should not include anything from node_modules or dist + const suggestionLabels = result.current.suggestions.map((s) => s.label); + expect(suggestionLabels).not.toContain('temp/'); + expect(suggestionLabels).not.toContain('node_modules/'); }); - - expect(result.current.suggestions).toHaveLength(6); - - // Extract labels for easier testing - const labels = result.current.suggestions.map((s) => s.label); - - // Verify the exact sorted order: source files should come before their test counterparts - expect(labels).toEqual([ - 'api.tsx', - 'api.test.tsx', - 'component.ts', - 'component.test.ts', - 'utils.js', - 'utils.spec.js', - ]); }); }); describe('handleAutocomplete', () => { it('should complete a partial command', () => { + const slashCommands = [ + { + name: 'memory', + description: 'Manage memory', + subCommands: [ + { + name: 'show', + description: 'Show memory', + }, + { + name: 'add', + description: 'Add to memory', + }, + ], + }, + ] as unknown as SlashCommand[]; // Create a mock buffer that we can spy on directly const mockBuffer = { text: '/mem', - lines: ['/mem'], - cursor: [0, 4], - preferredCol: null, - selectionAnchor: null, - allVisualLines: ['/mem'], - viewportVisualLines: ['/mem'], - visualCursor: [0, 4], - visualScrollRow: 0, setText: vi.fn(), - insert: vi.fn(), - newline: vi.fn(), - backspace: vi.fn(), - del: vi.fn(), - move: vi.fn(), - undo: vi.fn(), - redo: vi.fn(), - replaceRange: vi.fn(), - replaceRangeByOffset: vi.fn(), - moveToOffset: vi.fn(), - deleteWordLeft: vi.fn(), - deleteWordRight: vi.fn(), - killLineRight: vi.fn(), - killLineLeft: vi.fn(), - handleInput: vi.fn(), - openInExternalEditor: vi.fn(), - }; + } as unknown as TextBuffer; const { result } = renderHook(() => useCompletion( mockBuffer, - testCwd, - mockSlashCommands, + testRootDir, + slashCommands, mockCommandContext, mockConfig, ), @@ -1087,39 +1105,31 @@ describe('useCompletion', () => { it('should append a sub-command when the parent is complete', () => { const mockBuffer = { - text: '/memory ', - lines: ['/memory '], - cursor: [0, 8], - preferredCol: null, - selectionAnchor: null, - allVisualLines: ['/memory '], - viewportVisualLines: ['/memory '], - visualCursor: [0, 8], - visualScrollRow: 0, + text: '/memory', setText: vi.fn(), - insert: vi.fn(), - newline: vi.fn(), - backspace: vi.fn(), - del: vi.fn(), - move: vi.fn(), - undo: vi.fn(), - redo: vi.fn(), - replaceRange: vi.fn(), - replaceRangeByOffset: vi.fn(), - moveToOffset: vi.fn(), - deleteWordLeft: vi.fn(), - deleteWordRight: vi.fn(), - killLineRight: vi.fn(), - killLineLeft: vi.fn(), - handleInput: vi.fn(), - openInExternalEditor: vi.fn(), - }; + } as unknown as TextBuffer; + const slashCommands = [ + { + name: 'memory', + description: 'Manage memory', + subCommands: [ + { + name: 'show', + description: 'Show memory', + }, + { + name: 'add', + description: 'Add to memory', + }, + ], + }, + ] as unknown as SlashCommand[]; const { result } = renderHook(() => useCompletion( mockBuffer, - testCwd, - mockSlashCommands, + testRootDir, + slashCommands, mockCommandContext, mockConfig, ), @@ -1141,38 +1151,30 @@ describe('useCompletion', () => { it('should complete a command with an alternative name', () => { const mockBuffer = { text: '/?', - lines: ['/?'], - cursor: [0, 2], - preferredCol: null, - selectionAnchor: null, - allVisualLines: ['/?'], - viewportVisualLines: ['/?'], - visualCursor: [0, 2], - visualScrollRow: 0, setText: vi.fn(), - insert: vi.fn(), - newline: vi.fn(), - backspace: vi.fn(), - del: vi.fn(), - move: vi.fn(), - undo: vi.fn(), - redo: vi.fn(), - replaceRange: vi.fn(), - replaceRangeByOffset: vi.fn(), - moveToOffset: vi.fn(), - deleteWordLeft: vi.fn(), - deleteWordRight: vi.fn(), - killLineRight: vi.fn(), - killLineLeft: vi.fn(), - handleInput: vi.fn(), - openInExternalEditor: vi.fn(), - }; + } as unknown as TextBuffer; + const slashCommands = [ + { + name: 'memory', + description: 'Manage memory', + subCommands: [ + { + name: 'show', + description: 'Show memory', + }, + { + name: 'add', + description: 'Add to memory', + }, + ], + }, + ] as unknown as SlashCommand[]; const { result } = renderHook(() => useCompletion( mockBuffer, - testCwd, - mockSlashCommands, + testRootDir, + slashCommands, mockCommandContext, mockConfig, ), @@ -1196,36 +1198,31 @@ describe('useCompletion', () => { text: '@src/fi', lines: ['@src/fi'], cursor: [0, 7], - preferredCol: null, - selectionAnchor: null, - allVisualLines: ['@src/fi'], - viewportVisualLines: ['@src/fi'], - visualCursor: [0, 7], - visualScrollRow: 0, setText: vi.fn(), - insert: vi.fn(), - newline: vi.fn(), - backspace: vi.fn(), - del: vi.fn(), - move: vi.fn(), - undo: vi.fn(), - redo: vi.fn(), - replaceRange: vi.fn(), replaceRangeByOffset: vi.fn(), - moveToOffset: vi.fn(), - deleteWordLeft: vi.fn(), - deleteWordRight: vi.fn(), - killLineRight: vi.fn(), - killLineLeft: vi.fn(), - handleInput: vi.fn(), - openInExternalEditor: vi.fn(), - }; + } as unknown as TextBuffer; + const slashCommands = [ + { + name: 'memory', + description: 'Manage memory', + subCommands: [ + { + name: 'show', + description: 'Show memory', + }, + { + name: 'add', + description: 'Add to memory', + }, + ], + }, + ] as unknown as SlashCommand[]; const { result } = renderHook(() => useCompletion( mockBuffer, - testCwd, - mockSlashCommands, + testRootDir, + slashCommands, mockCommandContext, mockConfig, ), @@ -1247,59 +1244,4 @@ describe('useCompletion', () => { ); }); }); - - describe('Config and FileDiscoveryService integration', () => { - it('should work without config', async () => { - vi.mocked(fs.readdir).mockResolvedValue([ - { name: 'file1.txt', isDirectory: () => false }, - ] as unknown as Awaited>); - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('@'); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - undefined, - ); - }); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(result.current.suggestions).toHaveLength(1); - expect(result.current.suggestions[0].label).toBe('file1.txt'); - }); - - it('should respect file filtering when config is provided', async () => { - vi.mocked(fs.readdir).mockResolvedValue([ - { name: 'file1.txt', isDirectory: () => false }, - { name: 'ignored.log', isDirectory: () => false }, - ] as unknown as Awaited>); - - mockFileDiscoveryService.shouldIgnoreFile.mockImplementation( - (path: string) => path.includes('.log'), - ); - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('@'); - return useCompletion( - textBuffer, - testCwd, - mockSlashCommands, - mockCommandContext, - mockConfig, - ); - }); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(result.current.suggestions).toHaveLength(1); - expect(result.current.suggestions[0].label).toBe('file1.txt'); - }); - }); }); diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts index f4ebfac3..dc45222d 100644 --- a/packages/cli/src/ui/hooks/useCompletion.ts +++ b/packages/cli/src/ui/hooks/useCompletion.ts @@ -427,13 +427,10 @@ export function useCompletion( }); const suggestions: Suggestion[] = files - .map((file: string) => { - const relativePath = path.relative(cwd, file); - return { - label: relativePath, - value: escapePath(relativePath), - }; - }) + .map((file: string) => ({ + label: file, + value: escapePath(file), + })) .filter((s) => { if (fileDiscoveryService) { return !fileDiscoveryService.shouldIgnoreFile( @@ -475,7 +472,7 @@ export function useCompletion( fetchedSuggestions = await findFilesRecursively( cwd, prefix, - fileDiscoveryService, + null, filterOptions, ); } @@ -518,6 +515,13 @@ export function useCompletion( }); } + // Like glob, we always return forwardslashes, even in windows. + fetchedSuggestions = fetchedSuggestions.map((suggestion) => ({ + ...suggestion, + label: suggestion.label.replace(/\\/g, '/'), + value: suggestion.value.replace(/\\/g, '/'), + })); + // Sort by depth, then directories first, then alphabetically fetchedSuggestions.sort((a, b) => { const depthA = (a.label.match(/\//g) || []).length;