From 8da6d23688646dde2011fc3577faea1093077a3e Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Mon, 4 Aug 2025 13:35:26 -0700 Subject: refactor(core): Rename useSlashCompletion to useCommandCompletion (#5532) --- .../cli/src/ui/components/InputPrompt.test.tsx | 176 +-- packages/cli/src/ui/components/InputPrompt.tsx | 4 +- .../cli/src/ui/hooks/useCommandCompletion.test.ts | 1616 ++++++++++++++++++++ packages/cli/src/ui/hooks/useCommandCompletion.tsx | 661 ++++++++ .../cli/src/ui/hooks/useSlashCompletion.test.ts | 1616 -------------------- packages/cli/src/ui/hooks/useSlashCompletion.tsx | 661 -------- 6 files changed, 2367 insertions(+), 2367 deletions(-) create mode 100644 packages/cli/src/ui/hooks/useCommandCompletion.test.ts create mode 100644 packages/cli/src/ui/hooks/useCommandCompletion.tsx delete mode 100644 packages/cli/src/ui/hooks/useSlashCompletion.test.ts delete mode 100644 packages/cli/src/ui/hooks/useSlashCompletion.tsx (limited to 'packages/cli/src') diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 6b7bc7ce..2291b5a1 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -20,9 +20,9 @@ import { UseShellHistoryReturn, } from '../hooks/useShellHistory.js'; import { - useSlashCompletion, - UseSlashCompletionReturn, -} from '../hooks/useSlashCompletion.js'; + useCommandCompletion, + UseCommandCompletionReturn, +} from '../hooks/useCommandCompletion.js'; import { useInputHistory, UseInputHistoryReturn, @@ -31,7 +31,7 @@ import * as clipboardUtils from '../utils/clipboardUtils.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; vi.mock('../hooks/useShellHistory.js'); -vi.mock('../hooks/useSlashCompletion.js'); +vi.mock('../hooks/useCommandCompletion.js'); vi.mock('../hooks/useInputHistory.js'); vi.mock('../utils/clipboardUtils.js'); @@ -86,13 +86,13 @@ const mockSlashCommands: SlashCommand[] = [ describe('InputPrompt', () => { let props: InputPromptProps; let mockShellHistory: UseShellHistoryReturn; - let mockSlashCompletion: UseSlashCompletionReturn; + let mockCommandCompletion: UseCommandCompletionReturn; let mockInputHistory: UseInputHistoryReturn; let mockBuffer: TextBuffer; let mockCommandContext: CommandContext; const mockedUseShellHistory = vi.mocked(useShellHistory); - const mockedUseSlashCompletion = vi.mocked(useSlashCompletion); + const mockedUseCommandCompletion = vi.mocked(useCommandCompletion); const mockedUseInputHistory = vi.mocked(useInputHistory); beforeEach(() => { @@ -146,7 +146,7 @@ describe('InputPrompt', () => { }; mockedUseShellHistory.mockReturnValue(mockShellHistory); - mockSlashCompletion = { + mockCommandCompletion = { suggestions: [], activeSuggestionIndex: -1, isLoadingSuggestions: false, @@ -160,7 +160,7 @@ describe('InputPrompt', () => { setShowSuggestions: vi.fn(), handleAutocomplete: vi.fn(), }; - mockedUseSlashCompletion.mockReturnValue(mockSlashCompletion); + mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion); mockInputHistory = { navigateUp: vi.fn(), @@ -271,8 +271,8 @@ describe('InputPrompt', () => { }); it('should call completion.navigateUp for both up arrow and Ctrl+P when suggestions are showing', async () => { - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [ { label: 'memory', value: 'memory' }, @@ -291,15 +291,15 @@ describe('InputPrompt', () => { stdin.write('\u0010'); // Ctrl+P await wait(); - expect(mockSlashCompletion.navigateUp).toHaveBeenCalledTimes(2); - expect(mockSlashCompletion.navigateDown).not.toHaveBeenCalled(); + expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(2); + expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled(); unmount(); }); it('should call completion.navigateDown for both down arrow and Ctrl+N when suggestions are showing', async () => { - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [ { label: 'memory', value: 'memory' }, @@ -317,15 +317,15 @@ describe('InputPrompt', () => { stdin.write('\u000E'); // Ctrl+N await wait(); - expect(mockSlashCompletion.navigateDown).toHaveBeenCalledTimes(2); - expect(mockSlashCompletion.navigateUp).not.toHaveBeenCalled(); + expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(2); + expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled(); unmount(); }); it('should NOT call completion navigation when suggestions are not showing', async () => { - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: false, }); props.buffer.setText('some text'); @@ -342,8 +342,8 @@ describe('InputPrompt', () => { stdin.write('\u000E'); // Ctrl+N await wait(); - expect(mockSlashCompletion.navigateUp).not.toHaveBeenCalled(); - expect(mockSlashCompletion.navigateDown).not.toHaveBeenCalled(); + expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled(); + expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled(); unmount(); }); @@ -472,8 +472,8 @@ describe('InputPrompt', () => { it('should complete a partial parent command', async () => { // SCENARIO: /mem -> Tab - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [{ label: 'memory', value: 'memory', description: '...' }], activeSuggestionIndex: 0, @@ -486,14 +486,14 @@ describe('InputPrompt', () => { stdin.write('\t'); // Press Tab await wait(); - expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(0); + expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0); unmount(); }); it('should append a sub-command when the parent command is already complete', async () => { // SCENARIO: /memory -> Tab (to accept 'add') - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [ { label: 'show', value: 'show' }, @@ -509,14 +509,14 @@ describe('InputPrompt', () => { stdin.write('\t'); // Press Tab await wait(); - expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(1); + expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(1); unmount(); }); it('should handle the "backspace" edge case correctly', async () => { // SCENARIO: /memory -> Backspace -> /memory -> Tab (to accept 'show') - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [ { label: 'show', value: 'show' }, @@ -534,14 +534,14 @@ describe('InputPrompt', () => { await wait(); // It should NOT become '/show'. It should correctly become '/memory show'. - expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(0); + expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0); unmount(); }); it('should complete a partial argument for a command', async () => { // SCENARIO: /chat resume fi- -> Tab - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [{ label: 'fix-foo', value: 'fix-foo' }], activeSuggestionIndex: 0, @@ -554,13 +554,13 @@ describe('InputPrompt', () => { stdin.write('\t'); // Press Tab await wait(); - expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(0); + expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0); unmount(); }); it('should autocomplete on Enter when suggestions are active, without submitting', async () => { - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [{ label: 'memory', value: 'memory' }], activeSuggestionIndex: 0, @@ -574,7 +574,7 @@ describe('InputPrompt', () => { await wait(); // The app should autocomplete the text, NOT submit. - expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(0); + expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0); expect(props.onSubmit).not.toHaveBeenCalled(); unmount(); @@ -590,8 +590,8 @@ describe('InputPrompt', () => { }, ]; - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [{ label: 'help', value: 'help' }], activeSuggestionIndex: 0, @@ -604,7 +604,7 @@ describe('InputPrompt', () => { stdin.write('\t'); // Press Tab for autocomplete await wait(); - expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(0); + expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0); unmount(); }); @@ -622,8 +622,8 @@ describe('InputPrompt', () => { }); it('should submit directly on Enter when isPerfectMatch is true', async () => { - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: false, isPerfectMatch: true, }); @@ -640,8 +640,8 @@ describe('InputPrompt', () => { }); it('should submit directly on Enter when a complete leaf command is typed', async () => { - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: false, isPerfectMatch: false, // Added explicit isPerfectMatch false }); @@ -658,8 +658,8 @@ describe('InputPrompt', () => { }); it('should autocomplete an @-path on Enter without submitting', async () => { - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [{ label: 'index.ts', value: 'index.ts' }], activeSuggestionIndex: 0, @@ -672,7 +672,7 @@ describe('InputPrompt', () => { stdin.write('\r'); await wait(); - expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(0); + expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0); expect(props.onSubmit).not.toHaveBeenCalled(); unmount(); }); @@ -704,7 +704,7 @@ describe('InputPrompt', () => { await wait(); expect(props.buffer.setText).toHaveBeenCalledWith(''); - expect(mockSlashCompletion.resetCompletionState).toHaveBeenCalled(); + expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled(); expect(props.onSubmit).not.toHaveBeenCalled(); unmount(); }); @@ -728,8 +728,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['@src/components']; mockBuffer.cursor = [0, 15]; - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [{ label: 'Button.tsx', value: 'Button.tsx' }], }); @@ -738,7 +738,7 @@ describe('InputPrompt', () => { await wait(); // Verify useCompletion was called with correct signature - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -756,8 +756,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['/memory']; mockBuffer.cursor = [0, 7]; - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [{ label: 'show', value: 'show' }], }); @@ -765,7 +765,7 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -783,8 +783,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['@src/file.ts hello']; mockBuffer.cursor = [0, 18]; - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: false, suggestions: [], }); @@ -792,7 +792,7 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -810,8 +810,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['/memory add']; mockBuffer.cursor = [0, 11]; - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: false, suggestions: [], }); @@ -819,7 +819,7 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -837,8 +837,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['hello world']; mockBuffer.cursor = [0, 5]; - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: false, suggestions: [], }); @@ -846,7 +846,7 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -864,8 +864,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['first line', '/memory']; mockBuffer.cursor = [1, 7]; - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: false, suggestions: [], }); @@ -874,7 +874,7 @@ describe('InputPrompt', () => { await wait(); // Verify useCompletion was called with the buffer - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -892,8 +892,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['/memory']; mockBuffer.cursor = [0, 7]; - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [{ label: 'show', value: 'show' }], }); @@ -901,7 +901,7 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -920,8 +920,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['@src/file๐Ÿ‘.txt']; mockBuffer.cursor = [0, 14]; // After the emoji character - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [{ label: 'file๐Ÿ‘.txt', value: 'file๐Ÿ‘.txt' }], }); @@ -929,7 +929,7 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -948,8 +948,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['@src/file๐Ÿ‘.txt hello']; mockBuffer.cursor = [0, 20]; // After the space - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: false, suggestions: [], }); @@ -957,7 +957,7 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -976,8 +976,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['@src/my\\ file.txt']; mockBuffer.cursor = [0, 16]; // After the escaped space and filename - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [{ label: 'my file.txt', value: 'my file.txt' }], }); @@ -985,7 +985,7 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -1004,8 +1004,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['@path/my\\ file.txt hello']; mockBuffer.cursor = [0, 24]; // After "hello" - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: false, suggestions: [], }); @@ -1013,7 +1013,7 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -1032,8 +1032,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['@docs/my\\ long\\ file\\ name.md']; mockBuffer.cursor = [0, 29]; // At the end - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [ { label: 'my long file name.md', value: 'my long file name.md' }, @@ -1043,7 +1043,7 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -1062,8 +1062,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['/memory\\ test']; mockBuffer.cursor = [0, 13]; // At the end - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [{ label: 'test-command', value: 'test-command' }], }); @@ -1071,7 +1071,7 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -1090,8 +1090,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['@' + path.join('files', 'emoji\\ ๐Ÿ‘\\ test.txt')]; mockBuffer.cursor = [0, 25]; // After the escaped space and emoji - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [ { label: 'emoji ๐Ÿ‘ test.txt', value: 'emoji ๐Ÿ‘ test.txt' }, @@ -1101,7 +1101,7 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index db4eec1b..b405b684 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -15,7 +15,7 @@ import chalk from 'chalk'; import stringWidth from 'string-width'; import { useShellHistory } from '../hooks/useShellHistory.js'; import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js'; -import { useSlashCompletion } from '../hooks/useSlashCompletion.js'; +import { useCommandCompletion } from '../hooks/useCommandCompletion.js'; import { useKeypress, Key } from '../hooks/useKeypress.js'; import { CommandContext, SlashCommand } from '../commands/types.js'; import { Config } from '@google/gemini-cli-core'; @@ -78,7 +78,7 @@ export const InputPrompt: React.FC = ({ const shellHistory = useShellHistory(config.getProjectRoot()); const historyData = shellHistory.history; - const completion = useSlashCompletion( + const completion = useCommandCompletion( buffer, dirs, config.getTargetDir(), diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.test.ts b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts new file mode 100644 index 00000000..005b4e7d --- /dev/null +++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts @@ -0,0 +1,1616 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @vitest-environment jsdom */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useCommandCompletion } from './useCommandCompletion.js'; +import * as fs from 'fs/promises'; +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'; + +describe('useCommandCompletion', () => { + let testRootDir: string; + let mockConfig: Config; + + // A minimal mock is sufficient for these tests. + const mockCommandContext = {} as CommandContext; + let testDirs: string[]; + + 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, cursorOffset?: number) { + return useTextBuffer({ + initialText: text, + initialCursorOffset: cursorOffset ?? text.length, + viewport: { width: 80, height: 20 }, + isValidPath: () => false, + onChange: () => {}, + }); + } + + beforeEach(async () => { + testRootDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'slash-completion-unit-test-'), + ); + testDirs = [testRootDir]; + mockConfig = { + getTargetDir: () => testRootDir, + getWorkspaceContext: () => ({ + getDirectories: () => testDirs, + }), + getProjectRoot: () => testRootDir, + getFileFilteringOptions: vi.fn(() => ({ + respectGitIgnore: true, + respectGeminiIgnore: true, + })), + getEnableRecursiveFileSearch: vi.fn(() => true), + getFileService: vi.fn(() => new FileDiscoveryService(testRootDir)), + } as unknown as Config; + + vi.clearAllMocks(); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(testRootDir, { recursive: true, force: true }); + }); + + 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(() => + useCommandCompletion( + useTextBufferForTest(''), + testDirs, + testRootDir, + slashCommands, + mockCommandContext, + false, + 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 useCommandCompletion( + textBuffer, + testDirs, + testRootDir, + slashCommands, + mockCommandContext, + false, + 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', async () => { + const slashCommands = [ + { + name: 'help', + description: 'Show help', + }, + ] as unknown as SlashCommand[]; + + const { result } = renderHook(() => + useCommandCompletion( + useTextBufferForTest('/help'), + testDirs, + testRootDir, + slashCommands, + mockCommandContext, + false, + mockConfig, + ), + ); + + act(() => { + result.current.setActiveSuggestionIndex(5); + result.current.setShowSuggestions(true); + }); + + act(() => { + result.current.resetCompletionState(); + }); + + // Wait for async suggestions clearing + await waitFor(() => { + expect(result.current.suggestions).toEqual([]); + }); + + 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(() => + useCommandCompletion( + useTextBufferForTest(''), + testDirs, + testRootDir, + slashCommands, + mockCommandContext, + false, + 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(() => + useCommandCompletion( + useTextBufferForTest(''), + testDirs, + testRootDir, + slashCommands, + mockCommandContext, + false, + + 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(() => + useCommandCompletion( + useTextBufferForTest('/h'), + testDirs, + testRootDir, + slashCommands, + mockCommandContext, + false, + + 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(() => + useCommandCompletion( + useTextBufferForTest('/h'), + testDirs, + testRootDir, + slashCommands, + mockCommandContext, + false, + + 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(() => + useCommandCompletion( + useTextBufferForTest('/'), + testDirs, + testRootDir, + slashCommands, + mockCommandContext, + false, + + 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(() => + useCommandCompletion( + useTextBufferForTest('/command'), + testDirs, + testRootDir, + largeMockCommands, + mockCommandContext, + false, + + 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(() => + useCommandCompletion( + useTextBufferForTest('/'), + testDirs, + 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']), + ); + }); + + it('should filter commands based on partial input', async () => { + const slashCommands = [ + { + name: 'memory', + description: 'Manage memory', + }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useCommandCompletion( + useTextBufferForTest('/mem'), + testDirs, + testRootDir, + slashCommands, + mockCommandContext, + ), + ); + + 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(() => + useCommandCompletion( + useTextBufferForTest('/usag'), // part of the word "usage" + testDirs, + 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(() => + useCommandCompletion( + useTextBufferForTest('/clear'), // No trailing space + testDirs, + 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(() => + useCommandCompletion( + useTextBufferForTest(query), + testDirs, + testRootDir, + mockSlashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toHaveLength(0); + }, + ); + + 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(() => + useCommandCompletion( + useTextBufferForTest('/clear '), + testDirs, + testRootDir, + slashCommands, + mockCommandContext, + ), + ); + + 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(() => + useCommandCompletion( + useTextBufferForTest('/unknown-command'), + testDirs, + testRootDir, + slashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toHaveLength(0); + expect(result.current.showSuggestions).toBe(false); + }); + }); + + 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(() => + useCommandCompletion( + useTextBufferForTest('/memory'), // Note: no trailing space + testDirs, + 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(() => + useCommandCompletion( + useTextBufferForTest('/memory'), + testDirs, + 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' }, + ]), + ); + }); + + 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(() => + useCommandCompletion( + useTextBufferForTest('/memory a'), + testDirs, + testRootDir, + slashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toEqual([ + { label: 'add', value: 'add', description: 'Add to memory' }, + ]); + }); + + 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(() => + useCommandCompletion( + useTextBufferForTest('/memory dothisnow'), + testDirs, + testRootDir, + slashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toHaveLength(0); + expect(result.current.showSuggestions).toBe(false); + }); + }); + + 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(() => + useCommandCompletion( + useTextBufferForTest('/chat resume my-ch'), + testDirs, + testRootDir, + slashCommands, + 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 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 slashCommands = [ + { + name: 'chat', + description: 'Manage chat history', + subCommands: [ + { + name: 'resume', + description: 'Resume a saved chat', + completion: mockCompletionFn, + }, + ], + }, + ] as unknown as SlashCommand[]; + + const { result } = renderHook(() => + useCommandCompletion( + useTextBufferForTest('/chat resume '), + testDirs, + 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); + }); + + 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[]; + + const { result } = renderHook(() => + useCommandCompletion( + useTextBufferForTest('/chat resume '), + testDirs, + testRootDir, + slashCommands, + mockCommandContext, + false, + + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + expect(result.current.suggestions).toHaveLength(0); + expect(result.current.showSuggestions).toBe(false); + }); + }); + }); + + 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(() => + useCommandCompletion( + useTextBufferForTest('@s'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + 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' }, + ]), + ); + }); + + 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(() => + useCommandCompletion( + useTextBufferForTest('@src/comp'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + + 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(() => + useCommandCompletion( + useTextBufferForTest('@.'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + expect(result.current.suggestions).toEqual([ + { label: '.env', value: '.env' }, + { label: '.gitignore', value: '.gitignore' }, + ]); + }); + }); + + 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(() => + useCommandCompletion( + useTextBufferForTest('@d'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + + 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(() => + useCommandCompletion( + useTextBufferForTest('@'), + testDirs, + 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' }, + ]), + ); + }); + + 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(() => + useCommandCompletion( + useTextBufferForTest('@'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + + 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(); + }); + }); + + 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(() => + useCommandCompletion( + useTextBufferForTest('@d'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + + 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( + expect.arrayContaining([{ label: 'data', value: 'data' }]), + ); + expect(result.current.showSuggestions).toBe(true); + }); + + 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'); + + // visible + await createEmptyDir('src'); + await createTestFile('', 'README.md'); + + const { result } = renderHook(() => + useCommandCompletion( + useTextBufferForTest('@'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + + 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); + }); + + 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(() => + useCommandCompletion( + useTextBufferForTest('@t'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + + 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/'); + }); + }); + }); + + 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[]; + + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/mem'); + const completion = useCommandCompletion( + textBuffer, + testDirs, + testRootDir, + slashCommands, + mockCommandContext, + false, + + mockConfig, + ); + return { ...completion, textBuffer }; + }); + + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'memory', + ]); + + act(() => { + result.current.handleAutocomplete(0); + }); + + expect(result.current.textBuffer.text).toBe('/memory '); + }); + + it('should append a sub-command when the parent is complete', () => { + 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(() => { + const textBuffer = useTextBufferForTest('/memory'); + const completion = useCommandCompletion( + textBuffer, + testDirs, + testRootDir, + slashCommands, + mockCommandContext, + false, + + mockConfig, + ); + return { ...completion, textBuffer }; + }); + + // Suggestions are populated by useEffect + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'show', + 'add', + ]); + + act(() => { + result.current.handleAutocomplete(1); // index 1 is 'add' + }); + + expect(result.current.textBuffer.text).toBe('/memory add '); + }); + + it('should complete a command with an alternative name', () => { + 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(() => { + const textBuffer = useTextBufferForTest('/?'); + const completion = useCommandCompletion( + textBuffer, + testDirs, + testRootDir, + slashCommands, + mockCommandContext, + false, + + mockConfig, + ); + return { ...completion, textBuffer }; + }); + + result.current.suggestions.push({ + label: 'help', + value: 'help', + description: 'Show help', + }); + + act(() => { + result.current.handleAutocomplete(0); + }); + + expect(result.current.textBuffer.text).toBe('/help '); + }); + + it('should complete a file path', () => { + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('@src/fi'); + const completion = useCommandCompletion( + textBuffer, + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ); + return { ...completion, textBuffer }; + }); + + result.current.suggestions.push({ + label: 'file1.txt', + value: 'file1.txt', + }); + + act(() => { + result.current.handleAutocomplete(0); + }); + + expect(result.current.textBuffer.text).toBe('@src/file1.txt '); + }); + + it('should complete a file path when cursor is not at the end of the line', () => { + const text = '@src/fi le.txt'; + const cursorOffset = 7; // after "i" + + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest(text, cursorOffset); + const completion = useCommandCompletion( + textBuffer, + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ); + return { ...completion, textBuffer }; + }); + + result.current.suggestions.push({ + label: 'file1.txt', + value: 'file1.txt', + }); + + act(() => { + result.current.handleAutocomplete(0); + }); + + expect(result.current.textBuffer.text).toBe('@src/file1.txt le.txt'); + }); + + it('should complete the correct file path with multiple @-commands', () => { + const text = '@file1.txt @src/fi'; + + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest(text); + const completion = useCommandCompletion( + textBuffer, + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ); + return { ...completion, textBuffer }; + }); + + result.current.suggestions.push({ + label: 'file2.txt', + value: 'file2.txt', + }); + + act(() => { + result.current.handleAutocomplete(0); + }); + + expect(result.current.textBuffer.text).toBe('@file1.txt @src/file2.txt '); + }); + }); + + describe('File Path Escaping', () => { + it('should escape special characters in file names', async () => { + await createTestFile('', 'my file.txt'); + await createTestFile('', 'file(1).txt'); + await createTestFile('', 'backup[old].txt'); + + const { result } = renderHook(() => + useCommandCompletion( + useTextBufferForTest('@my'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + const suggestion = result.current.suggestions.find( + (s) => s.label === 'my file.txt', + ); + expect(suggestion).toBeDefined(); + expect(suggestion!.value).toBe('my\\ file.txt'); + }); + + it('should escape parentheses in file names', async () => { + await createTestFile('', 'document(final).docx'); + await createTestFile('', 'script(v2).sh'); + + const { result } = renderHook(() => + useCommandCompletion( + useTextBufferForTest('@doc'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + const suggestion = result.current.suggestions.find( + (s) => s.label === 'document(final).docx', + ); + expect(suggestion).toBeDefined(); + expect(suggestion!.value).toBe('document\\(final\\).docx'); + }); + + it('should escape square brackets in file names', async () => { + await createTestFile('', 'backup[2024-01-01].zip'); + await createTestFile('', 'config[dev].json'); + + const { result } = renderHook(() => + useCommandCompletion( + useTextBufferForTest('@backup'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + const suggestion = result.current.suggestions.find( + (s) => s.label === 'backup[2024-01-01].zip', + ); + expect(suggestion).toBeDefined(); + expect(suggestion!.value).toBe('backup\\[2024-01-01\\].zip'); + }); + + it('should escape multiple special characters in file names', async () => { + await createTestFile('', 'my file (backup) [v1.2].txt'); + await createTestFile('', 'data & config {prod}.json'); + + const { result } = renderHook(() => + useCommandCompletion( + useTextBufferForTest('@my'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + const suggestion = result.current.suggestions.find( + (s) => s.label === 'my file (backup) [v1.2].txt', + ); + expect(suggestion).toBeDefined(); + expect(suggestion!.value).toBe( + 'my\\ file\\ \\(backup\\)\\ \\[v1.2\\].txt', + ); + }); + + it('should preserve path separators while escaping special characters', async () => { + await createTestFile( + '', + 'projects', + 'my project (2024)', + 'file with spaces.txt', + ); + + const { result } = renderHook(() => + useCommandCompletion( + useTextBufferForTest('@projects/my'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + const suggestion = result.current.suggestions.find((s) => + s.label.includes('my project'), + ); + expect(suggestion).toBeDefined(); + // Should escape spaces and parentheses but preserve forward slashes + expect(suggestion!.value).toMatch(/my\\ project\\ \\\(2024\\\)/); + expect(suggestion!.value).toContain('/'); // Should contain forward slash for path separator + }); + + it('should normalize Windows path separators to forward slashes while preserving escaping', async () => { + // Create test with complex nested structure + await createTestFile( + '', + 'deep', + 'nested', + 'special folder', + 'file with (parentheses).txt', + ); + + const { result } = renderHook(() => + useCommandCompletion( + useTextBufferForTest('@deep/nested/special'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + const suggestion = result.current.suggestions.find((s) => + s.label.includes('special folder'), + ); + expect(suggestion).toBeDefined(); + // Should use forward slashes for path separators and escape spaces + expect(suggestion!.value).toContain('special\\ folder/'); + expect(suggestion!.value).not.toContain('\\\\'); // Should not contain double backslashes for path separators + }); + + it('should handle directory names with special characters', async () => { + await createEmptyDir('my documents (personal)'); + await createEmptyDir('config [production]'); + await createEmptyDir('data & logs'); + + const { result } = renderHook(() => + useCommandCompletion( + useTextBufferForTest('@'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + const suggestions = result.current.suggestions; + + const docSuggestion = suggestions.find( + (s) => s.label === 'my documents (personal)/', + ); + expect(docSuggestion).toBeDefined(); + expect(docSuggestion!.value).toBe('my\\ documents\\ \\(personal\\)/'); + + const configSuggestion = suggestions.find( + (s) => s.label === 'config [production]/', + ); + expect(configSuggestion).toBeDefined(); + expect(configSuggestion!.value).toBe('config\\ \\[production\\]/'); + + const dataSuggestion = suggestions.find( + (s) => s.label === 'data & logs/', + ); + expect(dataSuggestion).toBeDefined(); + expect(dataSuggestion!.value).toBe('data\\ \\&\\ logs/'); + }); + + it('should handle files with various shell metacharacters', async () => { + await createTestFile('', 'file$var.txt'); + await createTestFile('', 'important!.md'); + + const { result } = renderHook(() => + useCommandCompletion( + useTextBufferForTest('@'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + const suggestions = result.current.suggestions; + + const dollarSuggestion = suggestions.find( + (s) => s.label === 'file$var.txt', + ); + expect(dollarSuggestion).toBeDefined(); + expect(dollarSuggestion!.value).toBe('file\\$var.txt'); + + const importantSuggestion = suggestions.find( + (s) => s.label === 'important!.md', + ); + expect(importantSuggestion).toBeDefined(); + expect(importantSuggestion!.value).toBe('important\\!.md'); + }); + }); +}); diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx new file mode 100644 index 00000000..9227be39 --- /dev/null +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -0,0 +1,661 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useCallback, useMemo, useRef } from 'react'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { glob } from 'glob'; +import { + isNodeError, + escapePath, + unescapePath, + getErrorMessage, + Config, + FileDiscoveryService, + DEFAULT_FILE_FILTERING_OPTIONS, + SHELL_SPECIAL_CHARS, +} from '@google/gemini-cli-core'; +import { Suggestion } from '../components/SuggestionsDisplay.js'; +import { CommandContext, SlashCommand } from '../commands/types.js'; +import { + logicalPosToOffset, + TextBuffer, +} from '../components/shared/text-buffer.js'; +import { isSlashCommand } from '../utils/commandUtils.js'; +import { toCodePoints } from '../utils/textUtils.js'; +import { useCompletion } from './useCompletion.js'; + +export interface UseCommandCompletionReturn { + suggestions: Suggestion[]; + activeSuggestionIndex: number; + visibleStartIndex: number; + showSuggestions: boolean; + isLoadingSuggestions: boolean; + isPerfectMatch: boolean; + setActiveSuggestionIndex: React.Dispatch>; + setShowSuggestions: React.Dispatch>; + resetCompletionState: () => void; + navigateUp: () => void; + navigateDown: () => void; + handleAutocomplete: (indexToUse: number) => void; +} + +export function useCommandCompletion( + buffer: TextBuffer, + dirs: readonly string[], + cwd: string, + slashCommands: readonly SlashCommand[], + commandContext: CommandContext, + reverseSearchActive: boolean = false, + config?: Config, +): UseCommandCompletionReturn { + const { + suggestions, + activeSuggestionIndex, + visibleStartIndex, + showSuggestions, + isLoadingSuggestions, + isPerfectMatch, + + setSuggestions, + setShowSuggestions, + setActiveSuggestionIndex, + setIsLoadingSuggestions, + setIsPerfectMatch, + setVisibleStartIndex, + + resetCompletionState, + navigateUp, + navigateDown, + } = useCompletion(); + + const completionStart = useRef(-1); + const completionEnd = useRef(-1); + + const cursorRow = buffer.cursor[0]; + const cursorCol = buffer.cursor[1]; + + // Check if cursor is after @ or / without unescaped spaces + const commandIndex = useMemo(() => { + const currentLine = buffer.lines[cursorRow] || ''; + if (cursorRow === 0 && isSlashCommand(currentLine.trim())) { + return currentLine.indexOf('/'); + } + + // For other completions like '@', we search backwards from the cursor. + + const codePoints = toCodePoints(currentLine); + for (let i = cursorCol - 1; i >= 0; i--) { + const char = codePoints[i]; + + if (char === ' ') { + // Check for unescaped spaces. + let backslashCount = 0; + for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { + backslashCount++; + } + if (backslashCount % 2 === 0) { + return -1; // Inactive on unescaped space. + } + } else if (char === '@') { + // Active if we find an '@' before any unescaped space. + return i; + } + } + + return -1; + }, [cursorRow, cursorCol, buffer.lines]); + + useEffect(() => { + if (commandIndex === -1 || reverseSearchActive) { + setTimeout(resetCompletionState, 0); + return; + } + + const currentLine = buffer.lines[cursorRow] || ''; + const codePoints = toCodePoints(currentLine); + + if (codePoints[commandIndex] === '/') { + // Always reset perfect match at the beginning of processing. + setIsPerfectMatch(false); + + const fullPath = currentLine.substring(commandIndex + 1); + const hasTrailingSpace = currentLine.endsWith(' '); + + // Get all non-empty parts of the command. + const rawParts = fullPath.split(/\s+/).filter((p) => p); + + let commandPathParts = rawParts; + let partial = ''; + + // If there's no trailing space, the last part is potentially a partial segment. + // We tentatively separate it. + if (!hasTrailingSpace && rawParts.length > 0) { + partial = rawParts[rawParts.length - 1]; + commandPathParts = rawParts.slice(0, -1); + } + + // Traverse the Command Tree using the tentative completed path + let currentLevel: readonly SlashCommand[] | undefined = slashCommands; + let leafCommand: SlashCommand | null = null; + + for (const part of commandPathParts) { + if (!currentLevel) { + leafCommand = null; + currentLevel = []; + break; + } + const found: SlashCommand | undefined = currentLevel.find( + (cmd) => cmd.name === part || cmd.altNames?.includes(part), + ); + if (found) { + leafCommand = found; + currentLevel = found.subCommands as + | readonly SlashCommand[] + | undefined; + } else { + leafCommand = null; + currentLevel = []; + break; + } + } + + let exactMatchAsParent: SlashCommand | undefined; + // Handle the Ambiguous Case + if (!hasTrailingSpace && currentLevel) { + exactMatchAsParent = currentLevel.find( + (cmd) => + (cmd.name === partial || cmd.altNames?.includes(partial)) && + cmd.subCommands, + ); + + if (exactMatchAsParent) { + // It's a perfect match for a parent command. Override our initial guess. + // Treat it as a completed command path. + leafCommand = exactMatchAsParent; + currentLevel = exactMatchAsParent.subCommands; + partial = ''; // We now want to suggest ALL of its sub-commands. + } + } + + // Check for perfect, executable match + if (!hasTrailingSpace) { + if (leafCommand && partial === '' && leafCommand.action) { + // Case: /command - command has action, no sub-commands were suggested + setIsPerfectMatch(true); + } else if (currentLevel) { + // Case: /command subcommand + const perfectMatch = currentLevel.find( + (cmd) => + (cmd.name === partial || cmd.altNames?.includes(partial)) && + cmd.action, + ); + if (perfectMatch) { + setIsPerfectMatch(true); + } + } + } + + const depth = commandPathParts.length; + const isArgumentCompletion = + leafCommand?.completion && + (hasTrailingSpace || + (rawParts.length > depth && depth > 0 && partial !== '')); + + // Set completion range + if (hasTrailingSpace || exactMatchAsParent) { + completionStart.current = currentLine.length; + completionEnd.current = currentLine.length; + } else if (partial) { + if (isArgumentCompletion) { + const commandSoFar = `/${commandPathParts.join(' ')}`; + const argStartIndex = + commandSoFar.length + (commandPathParts.length > 0 ? 1 : 0); + completionStart.current = argStartIndex; + } else { + completionStart.current = currentLine.length - partial.length; + } + completionEnd.current = currentLine.length; + } else { + // e.g. / + completionStart.current = commandIndex + 1; + completionEnd.current = currentLine.length; + } + + // Provide Suggestions based on the now-corrected context + if (isArgumentCompletion) { + const fetchAndSetSuggestions = async () => { + setIsLoadingSuggestions(true); + const argString = rawParts.slice(depth).join(' '); + const results = + (await leafCommand!.completion!(commandContext, argString)) || []; + const finalSuggestions = results.map((s) => ({ label: s, value: s })); + setSuggestions(finalSuggestions); + setShowSuggestions(finalSuggestions.length > 0); + setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1); + setIsLoadingSuggestions(false); + }; + fetchAndSetSuggestions(); + return; + } + + // Command/Sub-command Completion + const commandsToSearch = currentLevel || []; + if (commandsToSearch.length > 0) { + let potentialSuggestions = commandsToSearch.filter( + (cmd) => + cmd.description && + (cmd.name.startsWith(partial) || + cmd.altNames?.some((alt) => alt.startsWith(partial))), + ); + + // If a user's input is an exact match and it is a leaf command, + // enter should submit immediately. + if (potentialSuggestions.length > 0 && !hasTrailingSpace) { + const perfectMatch = potentialSuggestions.find( + (s) => s.name === partial || s.altNames?.includes(partial), + ); + if (perfectMatch && perfectMatch.action) { + potentialSuggestions = []; + } + } + + const finalSuggestions = potentialSuggestions.map((cmd) => ({ + label: cmd.name, + value: cmd.name, + description: cmd.description, + })); + + setSuggestions(finalSuggestions); + setShowSuggestions(finalSuggestions.length > 0); + setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1); + setIsLoadingSuggestions(false); + return; + } + + // If we fall through, no suggestions are available. + resetCompletionState(); + return; + } + + // Handle At Command Completion + completionEnd.current = codePoints.length; + for (let i = cursorCol; i < codePoints.length; i++) { + if (codePoints[i] === ' ') { + let backslashCount = 0; + for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { + backslashCount++; + } + + if (backslashCount % 2 === 0) { + completionEnd.current = i; + break; + } + } + } + + const pathStart = commandIndex + 1; + const partialPath = currentLine.substring(pathStart, completionEnd.current); + const lastSlashIndex = partialPath.lastIndexOf('/'); + completionStart.current = + lastSlashIndex === -1 ? pathStart : pathStart + lastSlashIndex + 1; + const baseDirRelative = + lastSlashIndex === -1 + ? '.' + : partialPath.substring(0, lastSlashIndex + 1); + const prefix = unescapePath( + lastSlashIndex === -1 + ? partialPath + : partialPath.substring(lastSlashIndex + 1), + ); + + let isMounted = true; + + const findFilesRecursively = async ( + startDir: string, + searchPrefix: string, + fileDiscovery: FileDiscoveryService | null, + filterOptions: { + respectGitIgnore?: boolean; + respectGeminiIgnore?: boolean; + }, + currentRelativePath = '', + depth = 0, + maxDepth = 10, // Limit recursion depth + maxResults = 50, // Limit number of results + ): Promise => { + if (depth > maxDepth) { + return []; + } + + const lowerSearchPrefix = searchPrefix.toLowerCase(); + let foundSuggestions: Suggestion[] = []; + try { + const entries = await fs.readdir(startDir, { withFileTypes: true }); + for (const entry of entries) { + if (foundSuggestions.length >= maxResults) break; + + const entryPathRelative = path.join(currentRelativePath, entry.name); + const entryPathFromRoot = path.relative( + startDir, + path.join(startDir, entry.name), + ); + + // Conditionally ignore dotfiles + if (!searchPrefix.startsWith('.') && entry.name.startsWith('.')) { + continue; + } + + // Check if this entry should be ignored by filtering options + if ( + fileDiscovery && + fileDiscovery.shouldIgnoreFile(entryPathFromRoot, filterOptions) + ) { + continue; + } + + if (entry.name.toLowerCase().startsWith(lowerSearchPrefix)) { + foundSuggestions.push({ + label: entryPathRelative + (entry.isDirectory() ? '/' : ''), + value: escapePath( + entryPathRelative + (entry.isDirectory() ? '/' : ''), + ), + }); + } + if ( + entry.isDirectory() && + entry.name !== 'node_modules' && + !entry.name.startsWith('.') + ) { + if (foundSuggestions.length < maxResults) { + foundSuggestions = foundSuggestions.concat( + await findFilesRecursively( + path.join(startDir, entry.name), + searchPrefix, // Pass original searchPrefix for recursive calls + fileDiscovery, + filterOptions, + entryPathRelative, + depth + 1, + maxDepth, + maxResults - foundSuggestions.length, + ), + ); + } + } + } + } catch (_err) { + // Ignore errors like permission denied or ENOENT during recursive search + } + return foundSuggestions.slice(0, maxResults); + }; + + const findFilesWithGlob = async ( + searchPrefix: string, + fileDiscoveryService: FileDiscoveryService, + filterOptions: { + respectGitIgnore?: boolean; + respectGeminiIgnore?: boolean; + }, + searchDir: string, + maxResults = 50, + ): Promise => { + const globPattern = `**/${searchPrefix}*`; + const files = await glob(globPattern, { + cwd: searchDir, + dot: searchPrefix.startsWith('.'), + nocase: true, + }); + + const suggestions: Suggestion[] = files + .filter((file) => { + if (fileDiscoveryService) { + return !fileDiscoveryService.shouldIgnoreFile(file, filterOptions); + } + return true; + }) + .map((file: string) => { + const absolutePath = path.resolve(searchDir, file); + const label = path.relative(cwd, absolutePath); + return { + label, + value: escapePath(label), + }; + }) + .slice(0, maxResults); + + return suggestions; + }; + + const fetchSuggestions = async () => { + setIsLoadingSuggestions(true); + let fetchedSuggestions: Suggestion[] = []; + + const fileDiscoveryService = config ? config.getFileService() : null; + const enableRecursiveSearch = + config?.getEnableRecursiveFileSearch() ?? true; + const filterOptions = + config?.getFileFilteringOptions() ?? DEFAULT_FILE_FILTERING_OPTIONS; + + try { + // If there's no slash, or it's the root, do a recursive search from workspace directories + for (const dir of dirs) { + let fetchedSuggestionsPerDir: Suggestion[] = []; + if ( + partialPath.indexOf('/') === -1 && + prefix && + enableRecursiveSearch + ) { + if (fileDiscoveryService) { + fetchedSuggestionsPerDir = await findFilesWithGlob( + prefix, + fileDiscoveryService, + filterOptions, + dir, + ); + } else { + fetchedSuggestionsPerDir = await findFilesRecursively( + dir, + prefix, + null, + filterOptions, + ); + } + } else { + // Original behavior: list files in the specific directory + const lowerPrefix = prefix.toLowerCase(); + const baseDirAbsolute = path.resolve(dir, baseDirRelative); + const entries = await fs.readdir(baseDirAbsolute, { + withFileTypes: true, + }); + + // Filter entries using git-aware filtering + const filteredEntries = []; + for (const entry of entries) { + // Conditionally ignore dotfiles + if (!prefix.startsWith('.') && entry.name.startsWith('.')) { + continue; + } + if (!entry.name.toLowerCase().startsWith(lowerPrefix)) continue; + + const relativePath = path.relative( + dir, + path.join(baseDirAbsolute, entry.name), + ); + if ( + fileDiscoveryService && + fileDiscoveryService.shouldIgnoreFile( + relativePath, + filterOptions, + ) + ) { + continue; + } + + filteredEntries.push(entry); + } + + fetchedSuggestionsPerDir = filteredEntries.map((entry) => { + const absolutePath = path.resolve(baseDirAbsolute, entry.name); + const label = + cwd === dir ? entry.name : path.relative(cwd, absolutePath); + const suggestionLabel = entry.isDirectory() ? label + '/' : label; + return { + label: suggestionLabel, + value: escapePath(suggestionLabel), + }; + }); + } + fetchedSuggestions = [ + ...fetchedSuggestions, + ...fetchedSuggestionsPerDir, + ]; + } + + // Like glob, we always return forward slashes for path separators, even on Windows. + // But preserve backslash escaping for special characters. + const specialCharsLookahead = `(?![${SHELL_SPECIAL_CHARS.source.slice(1, -1)}])`; + const pathSeparatorRegex = new RegExp( + `\\\\${specialCharsLookahead}`, + 'g', + ); + fetchedSuggestions = fetchedSuggestions.map((suggestion) => ({ + ...suggestion, + label: suggestion.label.replace(pathSeparatorRegex, '/'), + value: suggestion.value.replace(pathSeparatorRegex, '/'), + })); + + // Sort by depth, then directories first, then alphabetically + fetchedSuggestions.sort((a, b) => { + const depthA = (a.label.match(/\//g) || []).length; + const depthB = (b.label.match(/\//g) || []).length; + + if (depthA !== depthB) { + return depthA - depthB; + } + + const aIsDir = a.label.endsWith('/'); + const bIsDir = b.label.endsWith('/'); + if (aIsDir && !bIsDir) return -1; + if (!aIsDir && bIsDir) return 1; + + // exclude extension when comparing + const filenameA = a.label.substring( + 0, + a.label.length - path.extname(a.label).length, + ); + const filenameB = b.label.substring( + 0, + b.label.length - path.extname(b.label).length, + ); + + return ( + filenameA.localeCompare(filenameB) || a.label.localeCompare(b.label) + ); + }); + + if (isMounted) { + setSuggestions(fetchedSuggestions); + setShowSuggestions(fetchedSuggestions.length > 0); + setActiveSuggestionIndex(fetchedSuggestions.length > 0 ? 0 : -1); + setVisibleStartIndex(0); + } + } catch (error: unknown) { + if (isNodeError(error) && error.code === 'ENOENT') { + if (isMounted) { + setSuggestions([]); + setShowSuggestions(false); + } + } else { + console.error( + `Error fetching completion suggestions for ${partialPath}: ${getErrorMessage(error)}`, + ); + if (isMounted) { + resetCompletionState(); + } + } + } + if (isMounted) { + setIsLoadingSuggestions(false); + } + }; + + const debounceTimeout = setTimeout(fetchSuggestions, 100); + + return () => { + isMounted = false; + clearTimeout(debounceTimeout); + }; + }, [ + buffer.text, + cursorRow, + cursorCol, + buffer.lines, + dirs, + cwd, + commandIndex, + resetCompletionState, + slashCommands, + commandContext, + config, + reverseSearchActive, + setSuggestions, + setShowSuggestions, + setActiveSuggestionIndex, + setIsLoadingSuggestions, + setIsPerfectMatch, + setVisibleStartIndex, + ]); + + const handleAutocomplete = useCallback( + (indexToUse: number) => { + if (indexToUse < 0 || indexToUse >= suggestions.length) { + return; + } + const suggestion = suggestions[indexToUse].value; + + if (completionStart.current === -1 || completionEnd.current === -1) { + return; + } + + const isSlash = (buffer.lines[cursorRow] || '')[commandIndex] === '/'; + let suggestionText = suggestion; + if (isSlash) { + // If we are inserting (not replacing), and the preceding character is not a space, add one. + if ( + completionStart.current === completionEnd.current && + completionStart.current > commandIndex + 1 && + (buffer.lines[cursorRow] || '')[completionStart.current - 1] !== ' ' + ) { + suggestionText = ' ' + suggestionText; + } + } + + suggestionText += ' '; + + buffer.replaceRangeByOffset( + logicalPosToOffset(buffer.lines, cursorRow, completionStart.current), + logicalPosToOffset(buffer.lines, cursorRow, completionEnd.current), + suggestionText, + ); + }, + [cursorRow, buffer, suggestions, commandIndex], + ); + + return { + suggestions, + activeSuggestionIndex, + visibleStartIndex, + showSuggestions, + isLoadingSuggestions, + isPerfectMatch, + setActiveSuggestionIndex, + setShowSuggestions, + resetCompletionState, + navigateUp, + navigateDown, + handleAutocomplete, + }; +} diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts deleted file mode 100644 index da4dc87b..00000000 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ /dev/null @@ -1,1616 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** @vitest-environment jsdom */ - -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { renderHook, act, waitFor } from '@testing-library/react'; -import { useSlashCompletion } from './useSlashCompletion.js'; -import * as fs from 'fs/promises'; -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'; - -describe('useSlashCompletion', () => { - let testRootDir: string; - let mockConfig: Config; - - // A minimal mock is sufficient for these tests. - const mockCommandContext = {} as CommandContext; - let testDirs: string[]; - - 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, cursorOffset?: number) { - return useTextBuffer({ - initialText: text, - initialCursorOffset: cursorOffset ?? text.length, - viewport: { width: 80, height: 20 }, - isValidPath: () => false, - onChange: () => {}, - }); - } - - beforeEach(async () => { - testRootDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'slash-completion-unit-test-'), - ); - testDirs = [testRootDir]; - mockConfig = { - getTargetDir: () => testRootDir, - getWorkspaceContext: () => ({ - getDirectories: () => testDirs, - }), - getProjectRoot: () => testRootDir, - getFileFilteringOptions: vi.fn(() => ({ - respectGitIgnore: true, - respectGeminiIgnore: true, - })), - getEnableRecursiveFileSearch: vi.fn(() => true), - getFileService: vi.fn(() => new FileDiscoveryService(testRootDir)), - } as unknown as Config; - - vi.clearAllMocks(); - }); - - afterEach(async () => { - vi.restoreAllMocks(); - await fs.rm(testRootDir, { recursive: true, force: true }); - }); - - 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(() => - useSlashCompletion( - useTextBufferForTest(''), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - false, - 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 useSlashCompletion( - textBuffer, - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - false, - 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', async () => { - const slashCommands = [ - { - name: 'help', - description: 'Show help', - }, - ] as unknown as SlashCommand[]; - - const { result } = renderHook(() => - useSlashCompletion( - useTextBufferForTest('/help'), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - false, - mockConfig, - ), - ); - - act(() => { - result.current.setActiveSuggestionIndex(5); - result.current.setShowSuggestions(true); - }); - - act(() => { - result.current.resetCompletionState(); - }); - - // Wait for async suggestions clearing - await waitFor(() => { - expect(result.current.suggestions).toEqual([]); - }); - - 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(() => - useSlashCompletion( - useTextBufferForTest(''), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - false, - 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(() => - useSlashCompletion( - useTextBufferForTest(''), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - false, - - 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(() => - useSlashCompletion( - useTextBufferForTest('/h'), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - false, - - 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(() => - useSlashCompletion( - useTextBufferForTest('/h'), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - false, - - 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(() => - useSlashCompletion( - useTextBufferForTest('/'), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - false, - - 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(() => - useSlashCompletion( - useTextBufferForTest('/command'), - testDirs, - testRootDir, - largeMockCommands, - mockCommandContext, - false, - - 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(() => - useSlashCompletion( - useTextBufferForTest('/'), - testDirs, - 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']), - ); - }); - - it('should filter commands based on partial input', async () => { - const slashCommands = [ - { - name: 'memory', - description: 'Manage memory', - }, - ] as unknown as SlashCommand[]; - const { result } = renderHook(() => - useSlashCompletion( - useTextBufferForTest('/mem'), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - ), - ); - - 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(() => - useSlashCompletion( - useTextBufferForTest('/usag'), // part of the word "usage" - testDirs, - 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(() => - useSlashCompletion( - useTextBufferForTest('/clear'), // No trailing space - testDirs, - 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(() => - useSlashCompletion( - useTextBufferForTest(query), - testDirs, - testRootDir, - mockSlashCommands, - mockCommandContext, - ), - ); - - expect(result.current.suggestions).toHaveLength(0); - }, - ); - - 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(() => - useSlashCompletion( - useTextBufferForTest('/clear '), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - ), - ); - - 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(() => - useSlashCompletion( - useTextBufferForTest('/unknown-command'), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - ), - ); - - expect(result.current.suggestions).toHaveLength(0); - expect(result.current.showSuggestions).toBe(false); - }); - }); - - 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(() => - useSlashCompletion( - useTextBufferForTest('/memory'), // Note: no trailing space - testDirs, - 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(() => - useSlashCompletion( - useTextBufferForTest('/memory'), - testDirs, - 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' }, - ]), - ); - }); - - 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(() => - useSlashCompletion( - useTextBufferForTest('/memory a'), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - ), - ); - - expect(result.current.suggestions).toEqual([ - { label: 'add', value: 'add', description: 'Add to memory' }, - ]); - }); - - 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(() => - useSlashCompletion( - useTextBufferForTest('/memory dothisnow'), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - ), - ); - - expect(result.current.suggestions).toHaveLength(0); - expect(result.current.showSuggestions).toBe(false); - }); - }); - - 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(() => - useSlashCompletion( - useTextBufferForTest('/chat resume my-ch'), - testDirs, - testRootDir, - slashCommands, - 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 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 slashCommands = [ - { - name: 'chat', - description: 'Manage chat history', - subCommands: [ - { - name: 'resume', - description: 'Resume a saved chat', - completion: mockCompletionFn, - }, - ], - }, - ] as unknown as SlashCommand[]; - - const { result } = renderHook(() => - useSlashCompletion( - useTextBufferForTest('/chat resume '), - testDirs, - 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); - }); - - 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[]; - - const { result } = renderHook(() => - useSlashCompletion( - useTextBufferForTest('/chat resume '), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - false, - - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(result.current.suggestions).toHaveLength(0); - expect(result.current.showSuggestions).toBe(false); - }); - }); - }); - - 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(() => - useSlashCompletion( - useTextBufferForTest('@s'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - 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' }, - ]), - ); - }); - - 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(() => - useSlashCompletion( - useTextBufferForTest('@src/comp'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - - 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(() => - useSlashCompletion( - useTextBufferForTest('@.'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(result.current.suggestions).toEqual([ - { label: '.env', value: '.env' }, - { label: '.gitignore', value: '.gitignore' }, - ]); - }); - }); - - 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(() => - useSlashCompletion( - useTextBufferForTest('@d'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - - 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(() => - useSlashCompletion( - useTextBufferForTest('@'), - testDirs, - 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' }, - ]), - ); - }); - - 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(() => - useSlashCompletion( - useTextBufferForTest('@'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - - 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(); - }); - }); - - 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(() => - useSlashCompletion( - useTextBufferForTest('@d'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - - 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( - expect.arrayContaining([{ label: 'data', value: 'data' }]), - ); - expect(result.current.showSuggestions).toBe(true); - }); - - 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'); - - // visible - await createEmptyDir('src'); - await createTestFile('', 'README.md'); - - const { result } = renderHook(() => - useSlashCompletion( - useTextBufferForTest('@'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - - 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); - }); - - 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(() => - useSlashCompletion( - useTextBufferForTest('@t'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - - 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/'); - }); - }); - }); - - 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[]; - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/mem'); - const completion = useSlashCompletion( - textBuffer, - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - false, - - mockConfig, - ); - return { ...completion, textBuffer }; - }); - - expect(result.current.suggestions.map((s) => s.value)).toEqual([ - 'memory', - ]); - - act(() => { - result.current.handleAutocomplete(0); - }); - - expect(result.current.textBuffer.text).toBe('/memory '); - }); - - it('should append a sub-command when the parent is complete', () => { - 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(() => { - const textBuffer = useTextBufferForTest('/memory'); - const completion = useSlashCompletion( - textBuffer, - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - false, - - mockConfig, - ); - return { ...completion, textBuffer }; - }); - - // Suggestions are populated by useEffect - expect(result.current.suggestions.map((s) => s.value)).toEqual([ - 'show', - 'add', - ]); - - act(() => { - result.current.handleAutocomplete(1); // index 1 is 'add' - }); - - expect(result.current.textBuffer.text).toBe('/memory add '); - }); - - it('should complete a command with an alternative name', () => { - 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(() => { - const textBuffer = useTextBufferForTest('/?'); - const completion = useSlashCompletion( - textBuffer, - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - false, - - mockConfig, - ); - return { ...completion, textBuffer }; - }); - - result.current.suggestions.push({ - label: 'help', - value: 'help', - description: 'Show help', - }); - - act(() => { - result.current.handleAutocomplete(0); - }); - - expect(result.current.textBuffer.text).toBe('/help '); - }); - - it('should complete a file path', () => { - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('@src/fi'); - const completion = useSlashCompletion( - textBuffer, - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ); - return { ...completion, textBuffer }; - }); - - result.current.suggestions.push({ - label: 'file1.txt', - value: 'file1.txt', - }); - - act(() => { - result.current.handleAutocomplete(0); - }); - - expect(result.current.textBuffer.text).toBe('@src/file1.txt '); - }); - - it('should complete a file path when cursor is not at the end of the line', () => { - const text = '@src/fi le.txt'; - const cursorOffset = 7; // after "i" - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest(text, cursorOffset); - const completion = useSlashCompletion( - textBuffer, - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ); - return { ...completion, textBuffer }; - }); - - result.current.suggestions.push({ - label: 'file1.txt', - value: 'file1.txt', - }); - - act(() => { - result.current.handleAutocomplete(0); - }); - - expect(result.current.textBuffer.text).toBe('@src/file1.txt le.txt'); - }); - - it('should complete the correct file path with multiple @-commands', () => { - const text = '@file1.txt @src/fi'; - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest(text); - const completion = useSlashCompletion( - textBuffer, - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ); - return { ...completion, textBuffer }; - }); - - result.current.suggestions.push({ - label: 'file2.txt', - value: 'file2.txt', - }); - - act(() => { - result.current.handleAutocomplete(0); - }); - - expect(result.current.textBuffer.text).toBe('@file1.txt @src/file2.txt '); - }); - }); - - describe('File Path Escaping', () => { - it('should escape special characters in file names', async () => { - await createTestFile('', 'my file.txt'); - await createTestFile('', 'file(1).txt'); - await createTestFile('', 'backup[old].txt'); - - const { result } = renderHook(() => - useSlashCompletion( - useTextBufferForTest('@my'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - const suggestion = result.current.suggestions.find( - (s) => s.label === 'my file.txt', - ); - expect(suggestion).toBeDefined(); - expect(suggestion!.value).toBe('my\\ file.txt'); - }); - - it('should escape parentheses in file names', async () => { - await createTestFile('', 'document(final).docx'); - await createTestFile('', 'script(v2).sh'); - - const { result } = renderHook(() => - useSlashCompletion( - useTextBufferForTest('@doc'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - const suggestion = result.current.suggestions.find( - (s) => s.label === 'document(final).docx', - ); - expect(suggestion).toBeDefined(); - expect(suggestion!.value).toBe('document\\(final\\).docx'); - }); - - it('should escape square brackets in file names', async () => { - await createTestFile('', 'backup[2024-01-01].zip'); - await createTestFile('', 'config[dev].json'); - - const { result } = renderHook(() => - useSlashCompletion( - useTextBufferForTest('@backup'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - const suggestion = result.current.suggestions.find( - (s) => s.label === 'backup[2024-01-01].zip', - ); - expect(suggestion).toBeDefined(); - expect(suggestion!.value).toBe('backup\\[2024-01-01\\].zip'); - }); - - it('should escape multiple special characters in file names', async () => { - await createTestFile('', 'my file (backup) [v1.2].txt'); - await createTestFile('', 'data & config {prod}.json'); - - const { result } = renderHook(() => - useSlashCompletion( - useTextBufferForTest('@my'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - const suggestion = result.current.suggestions.find( - (s) => s.label === 'my file (backup) [v1.2].txt', - ); - expect(suggestion).toBeDefined(); - expect(suggestion!.value).toBe( - 'my\\ file\\ \\(backup\\)\\ \\[v1.2\\].txt', - ); - }); - - it('should preserve path separators while escaping special characters', async () => { - await createTestFile( - '', - 'projects', - 'my project (2024)', - 'file with spaces.txt', - ); - - const { result } = renderHook(() => - useSlashCompletion( - useTextBufferForTest('@projects/my'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - const suggestion = result.current.suggestions.find((s) => - s.label.includes('my project'), - ); - expect(suggestion).toBeDefined(); - // Should escape spaces and parentheses but preserve forward slashes - expect(suggestion!.value).toMatch(/my\\ project\\ \\\(2024\\\)/); - expect(suggestion!.value).toContain('/'); // Should contain forward slash for path separator - }); - - it('should normalize Windows path separators to forward slashes while preserving escaping', async () => { - // Create test with complex nested structure - await createTestFile( - '', - 'deep', - 'nested', - 'special folder', - 'file with (parentheses).txt', - ); - - const { result } = renderHook(() => - useSlashCompletion( - useTextBufferForTest('@deep/nested/special'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - const suggestion = result.current.suggestions.find((s) => - s.label.includes('special folder'), - ); - expect(suggestion).toBeDefined(); - // Should use forward slashes for path separators and escape spaces - expect(suggestion!.value).toContain('special\\ folder/'); - expect(suggestion!.value).not.toContain('\\\\'); // Should not contain double backslashes for path separators - }); - - it('should handle directory names with special characters', async () => { - await createEmptyDir('my documents (personal)'); - await createEmptyDir('config [production]'); - await createEmptyDir('data & logs'); - - const { result } = renderHook(() => - useSlashCompletion( - useTextBufferForTest('@'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - const suggestions = result.current.suggestions; - - const docSuggestion = suggestions.find( - (s) => s.label === 'my documents (personal)/', - ); - expect(docSuggestion).toBeDefined(); - expect(docSuggestion!.value).toBe('my\\ documents\\ \\(personal\\)/'); - - const configSuggestion = suggestions.find( - (s) => s.label === 'config [production]/', - ); - expect(configSuggestion).toBeDefined(); - expect(configSuggestion!.value).toBe('config\\ \\[production\\]/'); - - const dataSuggestion = suggestions.find( - (s) => s.label === 'data & logs/', - ); - expect(dataSuggestion).toBeDefined(); - expect(dataSuggestion!.value).toBe('data\\ \\&\\ logs/'); - }); - - it('should handle files with various shell metacharacters', async () => { - await createTestFile('', 'file$var.txt'); - await createTestFile('', 'important!.md'); - - const { result } = renderHook(() => - useSlashCompletion( - useTextBufferForTest('@'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - const suggestions = result.current.suggestions; - - const dollarSuggestion = suggestions.find( - (s) => s.label === 'file$var.txt', - ); - expect(dollarSuggestion).toBeDefined(); - expect(dollarSuggestion!.value).toBe('file\\$var.txt'); - - const importantSuggestion = suggestions.find( - (s) => s.label === 'important!.md', - ); - expect(importantSuggestion).toBeDefined(); - expect(importantSuggestion!.value).toBe('important\\!.md'); - }); - }); -}); diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.tsx b/packages/cli/src/ui/hooks/useSlashCompletion.tsx deleted file mode 100644 index 3b59bd45..00000000 --- a/packages/cli/src/ui/hooks/useSlashCompletion.tsx +++ /dev/null @@ -1,661 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useEffect, useCallback, useMemo, useRef } from 'react'; -import * as fs from 'fs/promises'; -import * as path from 'path'; -import { glob } from 'glob'; -import { - isNodeError, - escapePath, - unescapePath, - getErrorMessage, - Config, - FileDiscoveryService, - DEFAULT_FILE_FILTERING_OPTIONS, - SHELL_SPECIAL_CHARS, -} from '@google/gemini-cli-core'; -import { Suggestion } from '../components/SuggestionsDisplay.js'; -import { CommandContext, SlashCommand } from '../commands/types.js'; -import { - logicalPosToOffset, - TextBuffer, -} from '../components/shared/text-buffer.js'; -import { isSlashCommand } from '../utils/commandUtils.js'; -import { toCodePoints } from '../utils/textUtils.js'; -import { useCompletion } from './useCompletion.js'; - -export interface UseSlashCompletionReturn { - suggestions: Suggestion[]; - activeSuggestionIndex: number; - visibleStartIndex: number; - showSuggestions: boolean; - isLoadingSuggestions: boolean; - isPerfectMatch: boolean; - setActiveSuggestionIndex: React.Dispatch>; - setShowSuggestions: React.Dispatch>; - resetCompletionState: () => void; - navigateUp: () => void; - navigateDown: () => void; - handleAutocomplete: (indexToUse: number) => void; -} - -export function useSlashCompletion( - buffer: TextBuffer, - dirs: readonly string[], - cwd: string, - slashCommands: readonly SlashCommand[], - commandContext: CommandContext, - reverseSearchActive: boolean = false, - config?: Config, -): UseSlashCompletionReturn { - const { - suggestions, - activeSuggestionIndex, - visibleStartIndex, - showSuggestions, - isLoadingSuggestions, - isPerfectMatch, - - setSuggestions, - setShowSuggestions, - setActiveSuggestionIndex, - setIsLoadingSuggestions, - setIsPerfectMatch, - setVisibleStartIndex, - - resetCompletionState, - navigateUp, - navigateDown, - } = useCompletion(); - - const completionStart = useRef(-1); - const completionEnd = useRef(-1); - - const cursorRow = buffer.cursor[0]; - const cursorCol = buffer.cursor[1]; - - // Check if cursor is after @ or / without unescaped spaces - const commandIndex = useMemo(() => { - const currentLine = buffer.lines[cursorRow] || ''; - if (cursorRow === 0 && isSlashCommand(currentLine.trim())) { - return currentLine.indexOf('/'); - } - - // For other completions like '@', we search backwards from the cursor. - - const codePoints = toCodePoints(currentLine); - for (let i = cursorCol - 1; i >= 0; i--) { - const char = codePoints[i]; - - if (char === ' ') { - // Check for unescaped spaces. - let backslashCount = 0; - for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { - backslashCount++; - } - if (backslashCount % 2 === 0) { - return -1; // Inactive on unescaped space. - } - } else if (char === '@') { - // Active if we find an '@' before any unescaped space. - return i; - } - } - - return -1; - }, [cursorRow, cursorCol, buffer.lines]); - - useEffect(() => { - if (commandIndex === -1 || reverseSearchActive) { - setTimeout(resetCompletionState, 0); - return; - } - - const currentLine = buffer.lines[cursorRow] || ''; - const codePoints = toCodePoints(currentLine); - - if (codePoints[commandIndex] === '/') { - // Always reset perfect match at the beginning of processing. - setIsPerfectMatch(false); - - const fullPath = currentLine.substring(commandIndex + 1); - const hasTrailingSpace = currentLine.endsWith(' '); - - // Get all non-empty parts of the command. - const rawParts = fullPath.split(/\s+/).filter((p) => p); - - let commandPathParts = rawParts; - let partial = ''; - - // If there's no trailing space, the last part is potentially a partial segment. - // We tentatively separate it. - if (!hasTrailingSpace && rawParts.length > 0) { - partial = rawParts[rawParts.length - 1]; - commandPathParts = rawParts.slice(0, -1); - } - - // Traverse the Command Tree using the tentative completed path - let currentLevel: readonly SlashCommand[] | undefined = slashCommands; - let leafCommand: SlashCommand | null = null; - - for (const part of commandPathParts) { - if (!currentLevel) { - leafCommand = null; - currentLevel = []; - break; - } - const found: SlashCommand | undefined = currentLevel.find( - (cmd) => cmd.name === part || cmd.altNames?.includes(part), - ); - if (found) { - leafCommand = found; - currentLevel = found.subCommands as - | readonly SlashCommand[] - | undefined; - } else { - leafCommand = null; - currentLevel = []; - break; - } - } - - let exactMatchAsParent: SlashCommand | undefined; - // Handle the Ambiguous Case - if (!hasTrailingSpace && currentLevel) { - exactMatchAsParent = currentLevel.find( - (cmd) => - (cmd.name === partial || cmd.altNames?.includes(partial)) && - cmd.subCommands, - ); - - if (exactMatchAsParent) { - // It's a perfect match for a parent command. Override our initial guess. - // Treat it as a completed command path. - leafCommand = exactMatchAsParent; - currentLevel = exactMatchAsParent.subCommands; - partial = ''; // We now want to suggest ALL of its sub-commands. - } - } - - // Check for perfect, executable match - if (!hasTrailingSpace) { - if (leafCommand && partial === '' && leafCommand.action) { - // Case: /command - command has action, no sub-commands were suggested - setIsPerfectMatch(true); - } else if (currentLevel) { - // Case: /command subcommand - const perfectMatch = currentLevel.find( - (cmd) => - (cmd.name === partial || cmd.altNames?.includes(partial)) && - cmd.action, - ); - if (perfectMatch) { - setIsPerfectMatch(true); - } - } - } - - const depth = commandPathParts.length; - const isArgumentCompletion = - leafCommand?.completion && - (hasTrailingSpace || - (rawParts.length > depth && depth > 0 && partial !== '')); - - // Set completion range - if (hasTrailingSpace || exactMatchAsParent) { - completionStart.current = currentLine.length; - completionEnd.current = currentLine.length; - } else if (partial) { - if (isArgumentCompletion) { - const commandSoFar = `/${commandPathParts.join(' ')}`; - const argStartIndex = - commandSoFar.length + (commandPathParts.length > 0 ? 1 : 0); - completionStart.current = argStartIndex; - } else { - completionStart.current = currentLine.length - partial.length; - } - completionEnd.current = currentLine.length; - } else { - // e.g. / - completionStart.current = commandIndex + 1; - completionEnd.current = currentLine.length; - } - - // Provide Suggestions based on the now-corrected context - if (isArgumentCompletion) { - const fetchAndSetSuggestions = async () => { - setIsLoadingSuggestions(true); - const argString = rawParts.slice(depth).join(' '); - const results = - (await leafCommand!.completion!(commandContext, argString)) || []; - const finalSuggestions = results.map((s) => ({ label: s, value: s })); - setSuggestions(finalSuggestions); - setShowSuggestions(finalSuggestions.length > 0); - setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1); - setIsLoadingSuggestions(false); - }; - fetchAndSetSuggestions(); - return; - } - - // Command/Sub-command Completion - const commandsToSearch = currentLevel || []; - if (commandsToSearch.length > 0) { - let potentialSuggestions = commandsToSearch.filter( - (cmd) => - cmd.description && - (cmd.name.startsWith(partial) || - cmd.altNames?.some((alt) => alt.startsWith(partial))), - ); - - // If a user's input is an exact match and it is a leaf command, - // enter should submit immediately. - if (potentialSuggestions.length > 0 && !hasTrailingSpace) { - const perfectMatch = potentialSuggestions.find( - (s) => s.name === partial || s.altNames?.includes(partial), - ); - if (perfectMatch && perfectMatch.action) { - potentialSuggestions = []; - } - } - - const finalSuggestions = potentialSuggestions.map((cmd) => ({ - label: cmd.name, - value: cmd.name, - description: cmd.description, - })); - - setSuggestions(finalSuggestions); - setShowSuggestions(finalSuggestions.length > 0); - setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1); - setIsLoadingSuggestions(false); - return; - } - - // If we fall through, no suggestions are available. - resetCompletionState(); - return; - } - - // Handle At Command Completion - completionEnd.current = codePoints.length; - for (let i = cursorCol; i < codePoints.length; i++) { - if (codePoints[i] === ' ') { - let backslashCount = 0; - for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { - backslashCount++; - } - - if (backslashCount % 2 === 0) { - completionEnd.current = i; - break; - } - } - } - - const pathStart = commandIndex + 1; - const partialPath = currentLine.substring(pathStart, completionEnd.current); - const lastSlashIndex = partialPath.lastIndexOf('/'); - completionStart.current = - lastSlashIndex === -1 ? pathStart : pathStart + lastSlashIndex + 1; - const baseDirRelative = - lastSlashIndex === -1 - ? '.' - : partialPath.substring(0, lastSlashIndex + 1); - const prefix = unescapePath( - lastSlashIndex === -1 - ? partialPath - : partialPath.substring(lastSlashIndex + 1), - ); - - let isMounted = true; - - const findFilesRecursively = async ( - startDir: string, - searchPrefix: string, - fileDiscovery: FileDiscoveryService | null, - filterOptions: { - respectGitIgnore?: boolean; - respectGeminiIgnore?: boolean; - }, - currentRelativePath = '', - depth = 0, - maxDepth = 10, // Limit recursion depth - maxResults = 50, // Limit number of results - ): Promise => { - if (depth > maxDepth) { - return []; - } - - const lowerSearchPrefix = searchPrefix.toLowerCase(); - let foundSuggestions: Suggestion[] = []; - try { - const entries = await fs.readdir(startDir, { withFileTypes: true }); - for (const entry of entries) { - if (foundSuggestions.length >= maxResults) break; - - const entryPathRelative = path.join(currentRelativePath, entry.name); - const entryPathFromRoot = path.relative( - startDir, - path.join(startDir, entry.name), - ); - - // Conditionally ignore dotfiles - if (!searchPrefix.startsWith('.') && entry.name.startsWith('.')) { - continue; - } - - // Check if this entry should be ignored by filtering options - if ( - fileDiscovery && - fileDiscovery.shouldIgnoreFile(entryPathFromRoot, filterOptions) - ) { - continue; - } - - if (entry.name.toLowerCase().startsWith(lowerSearchPrefix)) { - foundSuggestions.push({ - label: entryPathRelative + (entry.isDirectory() ? '/' : ''), - value: escapePath( - entryPathRelative + (entry.isDirectory() ? '/' : ''), - ), - }); - } - if ( - entry.isDirectory() && - entry.name !== 'node_modules' && - !entry.name.startsWith('.') - ) { - if (foundSuggestions.length < maxResults) { - foundSuggestions = foundSuggestions.concat( - await findFilesRecursively( - path.join(startDir, entry.name), - searchPrefix, // Pass original searchPrefix for recursive calls - fileDiscovery, - filterOptions, - entryPathRelative, - depth + 1, - maxDepth, - maxResults - foundSuggestions.length, - ), - ); - } - } - } - } catch (_err) { - // Ignore errors like permission denied or ENOENT during recursive search - } - return foundSuggestions.slice(0, maxResults); - }; - - const findFilesWithGlob = async ( - searchPrefix: string, - fileDiscoveryService: FileDiscoveryService, - filterOptions: { - respectGitIgnore?: boolean; - respectGeminiIgnore?: boolean; - }, - searchDir: string, - maxResults = 50, - ): Promise => { - const globPattern = `**/${searchPrefix}*`; - const files = await glob(globPattern, { - cwd: searchDir, - dot: searchPrefix.startsWith('.'), - nocase: true, - }); - - const suggestions: Suggestion[] = files - .filter((file) => { - if (fileDiscoveryService) { - return !fileDiscoveryService.shouldIgnoreFile(file, filterOptions); - } - return true; - }) - .map((file: string) => { - const absolutePath = path.resolve(searchDir, file); - const label = path.relative(cwd, absolutePath); - return { - label, - value: escapePath(label), - }; - }) - .slice(0, maxResults); - - return suggestions; - }; - - const fetchSuggestions = async () => { - setIsLoadingSuggestions(true); - let fetchedSuggestions: Suggestion[] = []; - - const fileDiscoveryService = config ? config.getFileService() : null; - const enableRecursiveSearch = - config?.getEnableRecursiveFileSearch() ?? true; - const filterOptions = - config?.getFileFilteringOptions() ?? DEFAULT_FILE_FILTERING_OPTIONS; - - try { - // If there's no slash, or it's the root, do a recursive search from workspace directories - for (const dir of dirs) { - let fetchedSuggestionsPerDir: Suggestion[] = []; - if ( - partialPath.indexOf('/') === -1 && - prefix && - enableRecursiveSearch - ) { - if (fileDiscoveryService) { - fetchedSuggestionsPerDir = await findFilesWithGlob( - prefix, - fileDiscoveryService, - filterOptions, - dir, - ); - } else { - fetchedSuggestionsPerDir = await findFilesRecursively( - dir, - prefix, - null, - filterOptions, - ); - } - } else { - // Original behavior: list files in the specific directory - const lowerPrefix = prefix.toLowerCase(); - const baseDirAbsolute = path.resolve(dir, baseDirRelative); - const entries = await fs.readdir(baseDirAbsolute, { - withFileTypes: true, - }); - - // Filter entries using git-aware filtering - const filteredEntries = []; - for (const entry of entries) { - // Conditionally ignore dotfiles - if (!prefix.startsWith('.') && entry.name.startsWith('.')) { - continue; - } - if (!entry.name.toLowerCase().startsWith(lowerPrefix)) continue; - - const relativePath = path.relative( - dir, - path.join(baseDirAbsolute, entry.name), - ); - if ( - fileDiscoveryService && - fileDiscoveryService.shouldIgnoreFile( - relativePath, - filterOptions, - ) - ) { - continue; - } - - filteredEntries.push(entry); - } - - fetchedSuggestionsPerDir = filteredEntries.map((entry) => { - const absolutePath = path.resolve(baseDirAbsolute, entry.name); - const label = - cwd === dir ? entry.name : path.relative(cwd, absolutePath); - const suggestionLabel = entry.isDirectory() ? label + '/' : label; - return { - label: suggestionLabel, - value: escapePath(suggestionLabel), - }; - }); - } - fetchedSuggestions = [ - ...fetchedSuggestions, - ...fetchedSuggestionsPerDir, - ]; - } - - // Like glob, we always return forward slashes for path separators, even on Windows. - // But preserve backslash escaping for special characters. - const specialCharsLookahead = `(?![${SHELL_SPECIAL_CHARS.source.slice(1, -1)}])`; - const pathSeparatorRegex = new RegExp( - `\\\\${specialCharsLookahead}`, - 'g', - ); - fetchedSuggestions = fetchedSuggestions.map((suggestion) => ({ - ...suggestion, - label: suggestion.label.replace(pathSeparatorRegex, '/'), - value: suggestion.value.replace(pathSeparatorRegex, '/'), - })); - - // Sort by depth, then directories first, then alphabetically - fetchedSuggestions.sort((a, b) => { - const depthA = (a.label.match(/\//g) || []).length; - const depthB = (b.label.match(/\//g) || []).length; - - if (depthA !== depthB) { - return depthA - depthB; - } - - const aIsDir = a.label.endsWith('/'); - const bIsDir = b.label.endsWith('/'); - if (aIsDir && !bIsDir) return -1; - if (!aIsDir && bIsDir) return 1; - - // exclude extension when comparing - const filenameA = a.label.substring( - 0, - a.label.length - path.extname(a.label).length, - ); - const filenameB = b.label.substring( - 0, - b.label.length - path.extname(b.label).length, - ); - - return ( - filenameA.localeCompare(filenameB) || a.label.localeCompare(b.label) - ); - }); - - if (isMounted) { - setSuggestions(fetchedSuggestions); - setShowSuggestions(fetchedSuggestions.length > 0); - setActiveSuggestionIndex(fetchedSuggestions.length > 0 ? 0 : -1); - setVisibleStartIndex(0); - } - } catch (error: unknown) { - if (isNodeError(error) && error.code === 'ENOENT') { - if (isMounted) { - setSuggestions([]); - setShowSuggestions(false); - } - } else { - console.error( - `Error fetching completion suggestions for ${partialPath}: ${getErrorMessage(error)}`, - ); - if (isMounted) { - resetCompletionState(); - } - } - } - if (isMounted) { - setIsLoadingSuggestions(false); - } - }; - - const debounceTimeout = setTimeout(fetchSuggestions, 100); - - return () => { - isMounted = false; - clearTimeout(debounceTimeout); - }; - }, [ - buffer.text, - cursorRow, - cursorCol, - buffer.lines, - dirs, - cwd, - commandIndex, - resetCompletionState, - slashCommands, - commandContext, - config, - reverseSearchActive, - setSuggestions, - setShowSuggestions, - setActiveSuggestionIndex, - setIsLoadingSuggestions, - setIsPerfectMatch, - setVisibleStartIndex, - ]); - - const handleAutocomplete = useCallback( - (indexToUse: number) => { - if (indexToUse < 0 || indexToUse >= suggestions.length) { - return; - } - const suggestion = suggestions[indexToUse].value; - - if (completionStart.current === -1 || completionEnd.current === -1) { - return; - } - - const isSlash = (buffer.lines[cursorRow] || '')[commandIndex] === '/'; - let suggestionText = suggestion; - if (isSlash) { - // If we are inserting (not replacing), and the preceding character is not a space, add one. - if ( - completionStart.current === completionEnd.current && - completionStart.current > commandIndex + 1 && - (buffer.lines[cursorRow] || '')[completionStart.current - 1] !== ' ' - ) { - suggestionText = ' ' + suggestionText; - } - } - - suggestionText += ' '; - - buffer.replaceRangeByOffset( - logicalPosToOffset(buffer.lines, cursorRow, completionStart.current), - logicalPosToOffset(buffer.lines, cursorRow, completionEnd.current), - suggestionText, - ); - }, - [cursorRow, buffer, suggestions, commandIndex], - ); - - return { - suggestions, - activeSuggestionIndex, - visibleStartIndex, - showSuggestions, - isLoadingSuggestions, - isPerfectMatch, - setActiveSuggestionIndex, - setShowSuggestions, - resetCompletionState, - navigateUp, - navigateDown, - handleAutocomplete, - }; -} -- cgit v1.2.3