diff options
Diffstat (limited to 'packages/cli/src')
| -rw-r--r-- | packages/cli/src/ui/hooks/useAtCompletion.test.ts | 380 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useAtCompletion.ts | 228 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useCommandCompletion.test.ts | 1538 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useCommandCompletion.tsx | 649 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx | 9 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useSlashCompletion.test.ts | 434 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useSlashCompletion.ts | 187 |
7 files changed, 1569 insertions, 1856 deletions
diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts new file mode 100644 index 00000000..bf2453f5 --- /dev/null +++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts @@ -0,0 +1,380 @@ +/** + * @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, waitFor, act } from '@testing-library/react'; +import { useAtCompletion } from './useAtCompletion.js'; +import { Config, FileSearch } from '@google/gemini-cli-core'; +import { + createTmpDir, + cleanupTmpDir, + FileSystemStructure, +} from '@google/gemini-cli-test-utils'; +import { useState } from 'react'; +import { Suggestion } from '../components/SuggestionsDisplay.js'; + +// Test harness to capture the state from the hook's callbacks. +function useTestHarnessForAtCompletion( + enabled: boolean, + pattern: string, + config: Config | undefined, + cwd: string, +) { + const [suggestions, setSuggestions] = useState<Suggestion[]>([]); + const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false); + + useAtCompletion({ + enabled, + pattern, + config, + cwd, + setSuggestions, + setIsLoadingSuggestions, + }); + + return { suggestions, isLoadingSuggestions }; +} + +describe('useAtCompletion', () => { + let testRootDir: string; + let mockConfig: Config; + + beforeEach(() => { + mockConfig = { + getFileFilteringOptions: vi.fn(() => ({ + respectGitIgnore: true, + respectGeminiIgnore: true, + })), + } as unknown as Config; + vi.clearAllMocks(); + }); + + afterEach(async () => { + if (testRootDir) { + await cleanupTmpDir(testRootDir); + } + vi.restoreAllMocks(); + }); + + describe('File Search Logic', () => { + it('should perform a recursive search for an empty pattern', async () => { + const structure: FileSystemStructure = { + 'file.txt': '', + src: { + 'index.js': '', + components: ['Button.tsx', 'Button with spaces.tsx'], + }, + }; + testRootDir = await createTmpDir(structure); + + const { result } = renderHook(() => + useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir), + ); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'src/', + 'src/components/', + 'file.txt', + 'src/components/Button\\ with\\ spaces.tsx', + 'src/components/Button.tsx', + 'src/index.js', + ]); + }); + + it('should correctly filter the recursive list based on a pattern', async () => { + const structure: FileSystemStructure = { + 'file.txt': '', + src: { + 'index.js': '', + components: { + 'Button.tsx': '', + }, + }, + }; + testRootDir = await createTmpDir(structure); + + const { result } = renderHook(() => + useTestHarnessForAtCompletion(true, 'src/', mockConfig, testRootDir), + ); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'src/', + 'src/components/', + 'src/components/Button.tsx', + 'src/index.js', + ]); + }); + + it('should append a trailing slash to directory paths in suggestions', async () => { + const structure: FileSystemStructure = { + 'file.txt': '', + dir: {}, + }; + testRootDir = await createTmpDir(structure); + + const { result } = renderHook(() => + useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir), + ); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'dir/', + 'file.txt', + ]); + }); + }); + + describe('UI State and Loading Behavior', () => { + it('should be in a loading state during initial file system crawl', async () => { + testRootDir = await createTmpDir({}); + const { result } = renderHook(() => + useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir), + ); + + // It's initially true because the effect runs synchronously. + expect(result.current.isLoadingSuggestions).toBe(true); + + // Wait for the loading to complete. + await waitFor(() => { + expect(result.current.isLoadingSuggestions).toBe(false); + }); + }); + + it('should NOT show a loading indicator for subsequent searches that complete under 100ms', async () => { + const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' }; + testRootDir = await createTmpDir(structure); + + const { result, rerender } = renderHook( + ({ pattern }) => + useTestHarnessForAtCompletion(true, pattern, mockConfig, testRootDir), + { initialProps: { pattern: 'a' } }, + ); + + await waitFor(() => { + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'a.txt', + ]); + }); + expect(result.current.isLoadingSuggestions).toBe(false); + + rerender({ pattern: 'b' }); + + // Wait for the final result + await waitFor(() => { + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'b.txt', + ]); + }); + + expect(result.current.isLoadingSuggestions).toBe(false); + }); + + it('should show a loading indicator and clear old suggestions for subsequent searches that take longer than 100ms', async () => { + const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' }; + testRootDir = await createTmpDir(structure); + + // Spy on the search method to introduce an artificial delay + const originalSearch = FileSearch.prototype.search; + vi.spyOn(FileSearch.prototype, 'search').mockImplementation( + async function (...args) { + await new Promise((resolve) => setTimeout(resolve, 200)); + return originalSearch.apply(this, args); + }, + ); + + const { result, rerender } = renderHook( + ({ pattern }) => + useTestHarnessForAtCompletion(true, pattern, mockConfig, testRootDir), + { initialProps: { pattern: 'a' } }, + ); + + // Wait for the initial (slow) search to complete + await waitFor(() => { + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'a.txt', + ]); + }); + + // Now, rerender to trigger the second search + rerender({ pattern: 'b' }); + + // Wait for the loading indicator to appear + await waitFor(() => { + expect(result.current.isLoadingSuggestions).toBe(true); + }); + + // Suggestions should be cleared while loading + expect(result.current.suggestions).toEqual([]); + + // Wait for the final (slow) search to complete + await waitFor( + () => { + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'b.txt', + ]); + }, + { timeout: 1000 }, + ); // Increase timeout for the slow search + + expect(result.current.isLoadingSuggestions).toBe(false); + }); + + it('should abort the previous search when a new one starts', async () => { + const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' }; + testRootDir = await createTmpDir(structure); + + const abortSpy = vi.spyOn(AbortController.prototype, 'abort'); + const searchSpy = vi + .spyOn(FileSearch.prototype, 'search') + .mockImplementation(async (...args) => { + const delay = args[0] === 'a' ? 500 : 50; + await new Promise((resolve) => setTimeout(resolve, delay)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return [args[0] as any]; + }); + + const { result, rerender } = renderHook( + ({ pattern }) => + useTestHarnessForAtCompletion(true, pattern, mockConfig, testRootDir), + { initialProps: { pattern: 'a' } }, + ); + + // Wait for the hook to be ready (initialization is complete) + await waitFor(() => { + expect(searchSpy).toHaveBeenCalledWith('a', expect.any(Object)); + }); + + // Now that the first search is in-flight, trigger the second one. + act(() => { + rerender({ pattern: 'b' }); + }); + + // The abort should have been called for the first search. + expect(abortSpy).toHaveBeenCalledTimes(1); + + // Wait for the final result, which should be from the second, faster search. + await waitFor( + () => { + expect(result.current.suggestions.map((s) => s.value)).toEqual(['b']); + }, + { timeout: 1000 }, + ); + + // The search spy should have been called for both patterns. + expect(searchSpy).toHaveBeenCalledWith('b', expect.any(Object)); + + vi.restoreAllMocks(); + }); + }); + + describe('Filtering and Configuration', () => { + it('should respect .gitignore files', async () => { + const gitignoreContent = ['dist/', '*.log'].join('\n'); + const structure: FileSystemStructure = { + '.git': {}, + '.gitignore': gitignoreContent, + dist: {}, + 'test.log': '', + src: {}, + }; + testRootDir = await createTmpDir(structure); + + const { result } = renderHook(() => + useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir), + ); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'src/', + '.gitignore', + ]); + }); + + it('should work correctly when config is undefined', async () => { + const structure: FileSystemStructure = { + node_modules: {}, + src: {}, + }; + testRootDir = await createTmpDir(structure); + + const { result } = renderHook(() => + useTestHarnessForAtCompletion(true, '', undefined, testRootDir), + ); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'node_modules/', + 'src/', + ]); + }); + + it('should reset and re-initialize when the cwd changes', async () => { + const structure1: FileSystemStructure = { 'file1.txt': '' }; + const rootDir1 = await createTmpDir(structure1); + const structure2: FileSystemStructure = { 'file2.txt': '' }; + const rootDir2 = await createTmpDir(structure2); + + const { result, rerender } = renderHook( + ({ cwd, pattern }) => + useTestHarnessForAtCompletion(true, pattern, mockConfig, cwd), + { + initialProps: { + cwd: rootDir1, + pattern: 'file', + }, + }, + ); + + // Wait for initial suggestions from the first directory + await waitFor(() => { + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'file1.txt', + ]); + }); + + // Change the CWD + act(() => { + rerender({ cwd: rootDir2, pattern: 'file' }); + }); + + // After CWD changes, suggestions should be cleared and it should load again. + await waitFor(() => { + expect(result.current.isLoadingSuggestions).toBe(true); + expect(result.current.suggestions).toEqual([]); + }); + + // Wait for the new suggestions from the second directory + await waitFor(() => { + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'file2.txt', + ]); + }); + expect(result.current.isLoadingSuggestions).toBe(false); + + await cleanupTmpDir(rootDir1); + await cleanupTmpDir(rootDir2); + }); + }); +}); diff --git a/packages/cli/src/ui/hooks/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts new file mode 100644 index 00000000..eaa2a5e6 --- /dev/null +++ b/packages/cli/src/ui/hooks/useAtCompletion.ts @@ -0,0 +1,228 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useReducer, useRef } from 'react'; +import { Config, FileSearch, escapePath } from '@google/gemini-cli-core'; +import { + Suggestion, + MAX_SUGGESTIONS_TO_SHOW, +} from '../components/SuggestionsDisplay.js'; + +export enum AtCompletionStatus { + IDLE = 'idle', + INITIALIZING = 'initializing', + READY = 'ready', + SEARCHING = 'searching', + ERROR = 'error', +} + +interface AtCompletionState { + status: AtCompletionStatus; + suggestions: Suggestion[]; + isLoading: boolean; + pattern: string | null; +} + +type AtCompletionAction = + | { type: 'INITIALIZE' } + | { type: 'INITIALIZE_SUCCESS' } + | { type: 'SEARCH'; payload: string } + | { type: 'SEARCH_SUCCESS'; payload: Suggestion[] } + | { type: 'SET_LOADING'; payload: boolean } + | { type: 'ERROR' } + | { type: 'RESET' }; + +const initialState: AtCompletionState = { + status: AtCompletionStatus.IDLE, + suggestions: [], + isLoading: false, + pattern: null, +}; + +function atCompletionReducer( + state: AtCompletionState, + action: AtCompletionAction, +): AtCompletionState { + switch (action.type) { + case 'INITIALIZE': + return { + ...state, + status: AtCompletionStatus.INITIALIZING, + isLoading: true, + }; + case 'INITIALIZE_SUCCESS': + return { ...state, status: AtCompletionStatus.READY, isLoading: false }; + case 'SEARCH': + // Keep old suggestions, don't set loading immediately + return { + ...state, + status: AtCompletionStatus.SEARCHING, + pattern: action.payload, + }; + case 'SEARCH_SUCCESS': + return { + ...state, + status: AtCompletionStatus.READY, + suggestions: action.payload, + isLoading: false, + }; + case 'SET_LOADING': + // Only show loading if we are still in a searching state + if (state.status === AtCompletionStatus.SEARCHING) { + return { ...state, isLoading: action.payload, suggestions: [] }; + } + return state; + case 'ERROR': + return { + ...state, + status: AtCompletionStatus.ERROR, + isLoading: false, + suggestions: [], + }; + case 'RESET': + return initialState; + default: + return state; + } +} + +export interface UseAtCompletionProps { + enabled: boolean; + pattern: string; + config: Config | undefined; + cwd: string; + setSuggestions: (suggestions: Suggestion[]) => void; + setIsLoadingSuggestions: (isLoading: boolean) => void; +} + +export function useAtCompletion(props: UseAtCompletionProps): void { + const { + enabled, + pattern, + config, + cwd, + setSuggestions, + setIsLoadingSuggestions, + } = props; + const [state, dispatch] = useReducer(atCompletionReducer, initialState); + const fileSearch = useRef<FileSearch | null>(null); + const searchAbortController = useRef<AbortController | null>(null); + const slowSearchTimer = useRef<NodeJS.Timeout | null>(null); + + useEffect(() => { + setSuggestions(state.suggestions); + }, [state.suggestions, setSuggestions]); + + useEffect(() => { + setIsLoadingSuggestions(state.isLoading); + }, [state.isLoading, setIsLoadingSuggestions]); + + useEffect(() => { + dispatch({ type: 'RESET' }); + }, [cwd, config]); + + // Reacts to user input (`pattern`) ONLY. + useEffect(() => { + if (!enabled) { + return; + } + if (pattern === null) { + dispatch({ type: 'RESET' }); + return; + } + + if (state.status === AtCompletionStatus.IDLE) { + dispatch({ type: 'INITIALIZE' }); + } else if ( + (state.status === AtCompletionStatus.READY || + state.status === AtCompletionStatus.SEARCHING) && + pattern !== state.pattern // Only search if the pattern has changed + ) { + dispatch({ type: 'SEARCH', payload: pattern }); + } + }, [enabled, pattern, state.status, state.pattern]); + + // The "Worker" that performs async operations based on status. + useEffect(() => { + const initialize = async () => { + try { + const searcher = new FileSearch({ + projectRoot: cwd, + ignoreDirs: [], + useGitignore: + config?.getFileFilteringOptions()?.respectGitIgnore ?? true, + useGeminiignore: + config?.getFileFilteringOptions()?.respectGeminiIgnore ?? true, + cache: true, + cacheTtl: 30, // 30 seconds + }); + await searcher.initialize(); + fileSearch.current = searcher; + dispatch({ type: 'INITIALIZE_SUCCESS' }); + if (state.pattern !== null) { + dispatch({ type: 'SEARCH', payload: state.pattern }); + } + } catch (_) { + dispatch({ type: 'ERROR' }); + } + }; + + const search = async () => { + if (!fileSearch.current || state.pattern === null) { + return; + } + + if (slowSearchTimer.current) { + clearTimeout(slowSearchTimer.current); + } + + const controller = new AbortController(); + searchAbortController.current = controller; + + slowSearchTimer.current = setTimeout(() => { + dispatch({ type: 'SET_LOADING', payload: true }); + }, 100); + + try { + const results = await fileSearch.current.search(state.pattern, { + signal: controller.signal, + maxResults: MAX_SUGGESTIONS_TO_SHOW * 3, + }); + + if (slowSearchTimer.current) { + clearTimeout(slowSearchTimer.current); + } + + if (controller.signal.aborted) { + return; + } + + const suggestions = results.map((p) => ({ + label: p, + value: escapePath(p), + })); + dispatch({ type: 'SEARCH_SUCCESS', payload: suggestions }); + } catch (error) { + if (!(error instanceof Error && error.name === 'AbortError')) { + dispatch({ type: 'ERROR' }); + } + } + }; + + if (state.status === AtCompletionStatus.INITIALIZING) { + initialize(); + } else if (state.status === AtCompletionStatus.SEARCHING) { + search(); + } + + return () => { + searchAbortController.current?.abort(); + if (slowSearchTimer.current) { + clearTimeout(slowSearchTimer.current); + } + }; + }, [state.status, state.pattern, config, cwd]); +} diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.test.ts b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts index 005b4e7d..a3c96935 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts @@ -9,33 +9,84 @@ 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 { CommandContext } from '../commands/types.js'; +import { Config } from '@google/gemini-cli-core'; import { useTextBuffer } from '../components/shared/text-buffer.js'; +import { useEffect } from 'react'; +import { Suggestion } from '../components/SuggestionsDisplay.js'; +import { UseAtCompletionProps, useAtCompletion } from './useAtCompletion.js'; +import { + UseSlashCompletionProps, + useSlashCompletion, +} from './useSlashCompletion.js'; -describe('useCommandCompletion', () => { - let testRootDir: string; - let mockConfig: Config; +vi.mock('./useAtCompletion', () => ({ + useAtCompletion: vi.fn(), +})); - // A minimal mock is sufficient for these tests. - const mockCommandContext = {} as CommandContext; - let testDirs: string[]; +vi.mock('./useSlashCompletion', () => ({ + useSlashCompletion: vi.fn(() => ({ + completionStart: 0, + completionEnd: 0, + })), +})); - async function createEmptyDir(...pathSegments: string[]) { - const fullPath = path.join(testRootDir, ...pathSegments); - await fs.mkdir(fullPath, { recursive: true }); - return fullPath; - } +// Helper to set up mocks in a consistent way for both child hooks +const setupMocks = ({ + atSuggestions = [], + slashSuggestions = [], + isLoading = false, + isPerfectMatch = false, + slashCompletionRange = { completionStart: 0, completionEnd: 0 }, +}: { + atSuggestions?: Suggestion[]; + slashSuggestions?: Suggestion[]; + isLoading?: boolean; + isPerfectMatch?: boolean; + slashCompletionRange?: { completionStart: number; completionEnd: number }; +}) => { + // Mock for @-completions + (useAtCompletion as vi.Mock).mockImplementation( + ({ + enabled, + setSuggestions, + setIsLoadingSuggestions, + }: UseAtCompletionProps) => { + useEffect(() => { + if (enabled) { + setIsLoadingSuggestions(isLoading); + setSuggestions(atSuggestions); + } + }, [enabled, setSuggestions, setIsLoadingSuggestions]); + }, + ); - 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; - } + // Mock for /-completions + (useSlashCompletion as vi.Mock).mockImplementation( + ({ + enabled, + setSuggestions, + setIsLoadingSuggestions, + setIsPerfectMatch, + }: UseSlashCompletionProps) => { + useEffect(() => { + if (enabled) { + setIsLoadingSuggestions(isLoading); + setSuggestions(slashSuggestions); + setIsPerfectMatch(isPerfectMatch); + } + }, [enabled, setSuggestions, setIsLoadingSuggestions, setIsPerfectMatch]); + // The hook returns a range, which we can mock simply + return slashCompletionRange; + }, + ); +}; + +describe('useCommandCompletion', () => { + const mockCommandContext = {} as CommandContext; + const mockConfig = {} as Config; + const testDirs: string[] = []; + const testRootDir = '/'; // Helper to create real TextBuffer objects within renderHook function useTextBufferForTest(text: string, cursorOffset?: number) { @@ -48,45 +99,25 @@ describe('useCommandCompletion', () => { }); } - 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; - + beforeEach(() => { vi.clearAllMocks(); + // Reset to default mocks before each test + setupMocks({}); }); - afterEach(async () => { + afterEach(() => { 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, @@ -100,1056 +131,299 @@ describe('useCommandCompletion', () => { 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[]; + it('should reset state when completion mode becomes IDLE', async () => { + setupMocks({ + atSuggestions: [{ label: 'src/file.txt', value: 'src/file.txt' }], + }); - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/help'), + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('@file'); + const completion = useCommandCompletion( + textBuffer, testDirs, testRootDir, - slashCommands, + [], mockCommandContext, false, mockConfig, - ), - ); - - act(() => { - result.current.setActiveSuggestionIndex(5); - result.current.setShowSuggestions(true); - }); - - act(() => { - result.current.resetCompletionState(); + ); + return { completion, textBuffer }; }); - // Wait for async suggestions clearing await waitFor(() => { - expect(result.current.suggestions).toEqual([]); + expect(result.current.completion.suggestions).toHaveLength(1); }); - 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, - ), - ); + expect(result.current.completion.showSuggestions).toBe(true); act(() => { - result.current.navigateUp(); + result.current.textBuffer.replaceRangeByOffset( + 0, + 5, + 'just some text', + ); }); - 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(); + await waitFor(() => { + expect(result.current.completion.showSuggestions).toBe(false); }); - - 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[]; + it('should reset all state to default values', () => { const { result } = renderHook(() => useCommandCompletion( - useTextBufferForTest('/h'), + useTextBufferForTest('@files'), testDirs, testRootDir, - slashCommands, + [], mockCommandContext, false, - mockConfig, ), ); - expect(result.current.suggestions.length).toBe(1); - expect(result.current.activeSuggestionIndex).toBe(0); - act(() => { - result.current.navigateUp(); + result.current.setActiveSuggestionIndex(5); + result.current.setShowSuggestions(true); }); - 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(); + result.current.resetCompletionState(); }); - expect(result.current.activeSuggestionIndex).toBe(0); + expect(result.current.activeSuggestionIndex).toBe(-1); + expect(result.current.visibleStartIndex).toBe(0); + expect(result.current.showSuggestions).toBe(false); }); - 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(() => + it('should call useAtCompletion with the correct query for an escaped space', async () => { + const text = '@src/a\\ file.txt'; + renderHook(() => useCommandCompletion( - useTextBufferForTest('/'), + useTextBufferForTest(text), 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(); + await waitFor(() => { + expect(useAtCompletion).toHaveBeenLastCalledWith( + expect.objectContaining({ + enabled: true, + pattern: 'src/a\\ file.txt', + }), + ); }); - 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[]; + it('should correctly identify the completion context with multiple @ symbols', async () => { + const text = '@file1 @file2'; + const cursorOffset = 3; // @fi|le1 @file2 - const { result } = renderHook(() => + renderHook(() => useCommandCompletion( - useTextBufferForTest('/command'), + useTextBufferForTest(text, cursorOffset), 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)), + await waitFor(() => { + expect(useAtCompletion).toHaveBeenLastCalledWith( + expect.objectContaining({ + enabled: true, + pattern: 'file1', + }), ); - - 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)); - }); + describe('Navigation', () => { + const mockSuggestions = [ + { label: 'cmd1', value: 'cmd1' }, + { label: 'cmd2', value: 'cmd2' }, + { label: 'cmd3', value: 'cmd3' }, + { label: 'cmd4', value: 'cmd4' }, + { label: 'cmd5', value: 'cmd5' }, + ]; - expect(mockCompletionFn).toHaveBeenCalledWith(mockCommandContext, ''); - expect(result.current.suggestions).toHaveLength(3); - expect(result.current.showSuggestions).toBe(true); + beforeEach(() => { + setupMocks({ slashSuggestions: mockSuggestions }); }); - 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[]; + it('should handle navigateUp with no suggestions', () => { + setupMocks({ slashSuggestions: [] }); const { result } = renderHook(() => useCommandCompletion( - useTextBufferForTest('/chat resume '), + useTextBufferForTest('/'), testDirs, testRootDir, - slashCommands, + [], mockCommandContext, false, - mockConfig, ), ); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); + act(() => { + result.current.navigateUp(); }); - expect(result.current.suggestions).toHaveLength(0); - expect(result.current.showSuggestions).toBe(false); + expect(result.current.activeSuggestionIndex).toBe(-1); }); - }); - }); - - 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'); + it('should handle navigateDown with no suggestions', () => { + setupMocks({ slashSuggestions: [] }); const { result } = renderHook(() => useCommandCompletion( - useTextBufferForTest('@s'), + useTextBufferForTest('/'), testDirs, testRootDir, [], mockCommandContext, false, - mockConfig, ), ); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); + act(() => { + result.current.navigateDown(); }); - expect(result.current.suggestions).toHaveLength(2); - expect(result.current.suggestions).toEqual( - expect.arrayContaining([ - { - label: 'derp/script.ts', - value: 'derp/script.ts', - }, - { label: 'src', value: 'src' }, - ]), - ); + expect(result.current.activeSuggestionIndex).toBe(-1); }); - 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'); - + it('should navigate up through suggestions with wrap-around', async () => { const { result } = renderHook(() => useCommandCompletion( - useTextBufferForTest('@src/comp'), + useTextBufferForTest('/'), testDirs, testRootDir, [], mockCommandContext, false, - mockConfig, ), ); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); + await waitFor(() => { + expect(result.current.suggestions.length).toBe(5); }); - // 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, - ), - ); + expect(result.current.activeSuggestionIndex).toBe(0); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); + act(() => { + result.current.navigateUp(); }); - expect(result.current.suggestions).toEqual([ - { label: '.env', value: '.env' }, - { label: '.gitignore', value: '.gitignore' }, - ]); + expect(result.current.activeSuggestionIndex).toBe(4); }); - }); - - 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'); + it('should navigate down through suggestions with wrap-around', async () => { const { result } = renderHook(() => useCommandCompletion( - useTextBufferForTest('@d'), + useTextBufferForTest('/'), testDirs, testRootDir, [], mockCommandContext, false, - - mockConfigNoRecursive, + mockConfig, ), ); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); + await waitFor(() => { + expect(result.current.suggestions.length).toBe(5); }); - 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)); + act(() => { + result.current.setActiveSuggestionIndex(4); }); + expect(result.current.activeSuggestionIndex).toBe(4); - // 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)); + act(() => { + result.current.navigateDown(); }); - // 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(); + expect(result.current.activeSuggestionIndex).toBe(0); }); - }); - - describe('Git-Aware Filtering', () => { - it('should filter git-ignored entries from @ completions', async () => { - await createEmptyDir('.git'); - await createTestFile('dist', '.gitignore'); - await createEmptyDir('data'); + it('should handle navigation with multiple suggestions', async () => { const { result } = renderHook(() => useCommandCompletion( - useTextBufferForTest('@d'), + 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 + await waitFor(() => { + expect(result.current.suggestions.length).toBe(5); }); - 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'); + expect(result.current.activeSuggestionIndex).toBe(0); - // visible - await createEmptyDir('src'); - await createTestFile('', 'README.md'); + act(() => result.current.navigateDown()); + expect(result.current.activeSuggestionIndex).toBe(1); - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, + act(() => result.current.navigateDown()); + expect(result.current.activeSuggestionIndex).toBe(2); - mockConfig, - ), - ); + act(() => result.current.navigateUp()); + expect(result.current.activeSuggestionIndex).toBe(1); - // Wait for async operations to complete - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); // Account for debounce - }); + act(() => result.current.navigateUp()); + expect(result.current.activeSuggestionIndex).toBe(0); - expect(result.current.suggestions).toEqual([ - { label: 'README.md', value: 'README.md' }, - { label: 'src/', value: 'src/' }, - ]); - expect(result.current.showSuggestions).toBe(true); + act(() => result.current.navigateUp()); + expect(result.current.activeSuggestionIndex).toBe(4); }); - 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'); + it('should automatically select the first item when suggestions are available', async () => { + setupMocks({ slashSuggestions: mockSuggestions }); const { result } = renderHook(() => useCommandCompletion( - useTextBufferForTest('@t'), + useTextBufferForTest('/'), testDirs, testRootDir, [], mockCommandContext, false, - mockConfig, ), ); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); + await waitFor(() => { + expect(result.current.suggestions.length).toBe( + mockSuggestions.length, + ); + expect(result.current.activeSuggestionIndex).toBe(0); }); - - // 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[]; + it('should complete a partial command', async () => { + setupMocks({ + slashSuggestions: [{ label: 'memory', value: 'memory' }], + slashCompletionRange: { completionStart: 1, completionEnd: 4 }, + }); const { result } = renderHook(() => { const textBuffer = useTextBufferForTest('/mem'); @@ -1157,18 +431,17 @@ describe('useCommandCompletion', () => { textBuffer, testDirs, testRootDir, - slashCommands, + [], mockCommandContext, false, - mockConfig, ); return { ...completion, textBuffer }; }); - expect(result.current.suggestions.map((s) => s.value)).toEqual([ - 'memory', - ]); + await waitFor(() => { + expect(result.current.suggestions.length).toBe(1); + }); act(() => { result.current.handleAutocomplete(0); @@ -1177,99 +450,11 @@ describe('useCommandCompletion', () => { 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 }; + it('should complete a file path', async () => { + setupMocks({ + atSuggestions: [{ label: 'src/file1.txt', value: 'src/file1.txt' }], }); - // 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( @@ -1284,9 +469,8 @@ describe('useCommandCompletion', () => { return { ...completion, textBuffer }; }); - result.current.suggestions.push({ - label: 'file1.txt', - value: 'file1.txt', + await waitFor(() => { + expect(result.current.suggestions.length).toBe(1); }); act(() => { @@ -1296,41 +480,16 @@ describe('useCommandCompletion', () => { 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'; + it('should complete a file path when cursor is not at the end of the line', async () => { + const text = '@src/fi is a good file'; 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); + setupMocks({ + atSuggestions: [{ label: 'src/file1.txt', value: 'src/file1.txt' }], }); - 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 textBuffer = useTextBufferForTest(text, cursorOffset); const completion = useCommandCompletion( textBuffer, testDirs, @@ -1343,274 +502,17 @@ describe('useCommandCompletion', () => { return { ...completion, textBuffer }; }); - result.current.suggestions.push({ - label: 'file2.txt', - value: 'file2.txt', + await waitFor(() => { + expect(result.current.suggestions.length).toBe(1); }); 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(result.current.textBuffer.text).toBe( + '@src/file1.txt is a good file', ); - 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 index 9227be39..07d0e056 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -4,20 +4,7 @@ * 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 { useCallback, useMemo, useEffect } from 'react'; import { Suggestion } from '../components/SuggestionsDisplay.js'; import { CommandContext, SlashCommand } from '../commands/types.js'; import { @@ -26,8 +13,17 @@ import { } from '../components/shared/text-buffer.js'; import { isSlashCommand } from '../utils/commandUtils.js'; import { toCodePoints } from '../utils/textUtils.js'; +import { useAtCompletion } from './useAtCompletion.js'; +import { useSlashCompletion } from './useSlashCompletion.js'; +import { Config } from '@google/gemini-cli-core'; import { useCompletion } from './useCompletion.js'; +export enum CompletionMode { + IDLE = 'IDLE', + AT = 'AT', + SLASH = 'SLASH', +} + export interface UseCommandCompletionReturn { suggestions: Suggestion[]; activeSuggestionIndex: number; @@ -72,541 +68,109 @@ export function useCommandCompletion( 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<enter> - command has action, no sub-commands were suggested - setIsPerfectMatch(true); - } else if (currentLevel) { - // Case: /command subcommand<enter> - 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); + const { completionMode, query, completionStart, completionEnd } = + useMemo(() => { + const currentLine = buffer.lines[cursorRow] || ''; + if (cursorRow === 0 && isSlashCommand(currentLine.trim())) { + return { + completionMode: CompletionMode.SLASH, + query: currentLine, + completionStart: 0, + completionEnd: currentLine.length, }; - 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))), - ); + const codePoints = toCodePoints(currentLine); + for (let i = cursorCol - 1; i >= 0; i--) { + const char = codePoints[i]; - // 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 = []; + if (char === ' ') { + let backslashCount = 0; + for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { + backslashCount++; } - } - - 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<Suggestion[]> => { - 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<Suggestion[]> => { - 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); + if (backslashCount % 2 === 0) { + return { + completionMode: CompletionMode.IDLE, + query: null, + completionStart: -1, + completionEnd: -1, + }; } - 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; + } else if (char === '@') { + let end = 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 (!entry.name.toLowerCase().startsWith(lowerPrefix)) continue; - const relativePath = path.relative( - dir, - path.join(baseDirAbsolute, entry.name), - ); - if ( - fileDiscoveryService && - fileDiscoveryService.shouldIgnoreFile( - relativePath, - filterOptions, - ) - ) { - continue; + if (backslashCount % 2 === 0) { + end = i; + break; } - - 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(); } + const pathStart = i + 1; + const partialPath = currentLine.substring(pathStart, end); + return { + completionMode: CompletionMode.AT, + query: partialPath, + completionStart: pathStart, + completionEnd: end, + }; } } - if (isMounted) { - setIsLoadingSuggestions(false); - } - }; - - const debounceTimeout = setTimeout(fetchSuggestions, 100); + return { + completionMode: CompletionMode.IDLE, + query: null, + completionStart: -1, + completionEnd: -1, + }; + }, [cursorRow, cursorCol, buffer.lines]); - return () => { - isMounted = false; - clearTimeout(debounceTimeout); - }; - }, [ - buffer.text, - cursorRow, - cursorCol, - buffer.lines, - dirs, + useAtCompletion({ + enabled: completionMode === CompletionMode.AT, + pattern: query || '', + config, cwd, - commandIndex, - resetCompletionState, + setSuggestions, + setIsLoadingSuggestions, + }); + + const slashCompletionRange = useSlashCompletion({ + enabled: completionMode === CompletionMode.SLASH, + query, slashCommands, commandContext, - config, - reverseSearchActive, setSuggestions, - setShowSuggestions, - setActiveSuggestionIndex, setIsLoadingSuggestions, setIsPerfectMatch, - setVisibleStartIndex, + }); + + useEffect(() => { + setActiveSuggestionIndex(suggestions.length > 0 ? 0 : -1); + setVisibleStartIndex(0); + }, [suggestions, setActiveSuggestionIndex, setVisibleStartIndex]); + + useEffect(() => { + if (completionMode === CompletionMode.IDLE || reverseSearchActive) { + resetCompletionState(); + return; + } + // Show suggestions if we are loading OR if there are results to display. + setShowSuggestions(isLoadingSuggestions || suggestions.length > 0); + }, [ + completionMode, + suggestions.length, + isLoadingSuggestions, + reverseSearchActive, + resetCompletionState, + setShowSuggestions, ]); const handleAutocomplete = useCallback( @@ -616,18 +180,23 @@ export function useCommandCompletion( } const suggestion = suggestions[indexToUse].value; - if (completionStart.current === -1 || completionEnd.current === -1) { + let start = completionStart; + let end = completionEnd; + if (completionMode === CompletionMode.SLASH) { + start = slashCompletionRange.completionStart; + end = slashCompletionRange.completionEnd; + } + + if (start === -1 || end === -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 (completionMode === CompletionMode.SLASH) { if ( - completionStart.current === completionEnd.current && - completionStart.current > commandIndex + 1 && - (buffer.lines[cursorRow] || '')[completionStart.current - 1] !== ' ' + start === end && + start > 1 && + (buffer.lines[cursorRow] || '')[start - 1] !== ' ' ) { suggestionText = ' ' + suggestionText; } @@ -636,12 +205,20 @@ export function useCommandCompletion( suggestionText += ' '; buffer.replaceRangeByOffset( - logicalPosToOffset(buffer.lines, cursorRow, completionStart.current), - logicalPosToOffset(buffer.lines, cursorRow, completionEnd.current), + logicalPosToOffset(buffer.lines, cursorRow, start), + logicalPosToOffset(buffer.lines, cursorRow, end), suggestionText, ); }, - [cursorRow, buffer, suggestions, commandIndex], + [ + cursorRow, + buffer, + suggestions, + completionMode, + completionStart, + completionEnd, + slashCompletionRange, + ], ); return { diff --git a/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx b/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx index 1cc7e602..3fb9217e 100644 --- a/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx +++ b/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx @@ -41,12 +41,17 @@ export function useReverseSearchCompletion( navigateDown, } = useCompletion(); - // whenever reverseSearchActive is on, filter history useEffect(() => { if (!reverseSearchActive) { resetCompletionState(); + } + }, [reverseSearchActive, resetCompletionState]); + + useEffect(() => { + if (!reverseSearchActive) { return; } + const q = buffer.text.toLowerCase(); const matches = shellHistory.reduce<Suggestion[]>((acc, cmd) => { const idx = cmd.toLowerCase().indexOf(q); @@ -55,6 +60,7 @@ export function useReverseSearchCompletion( } return acc; }, []); + setSuggestions(matches); setShowSuggestions(matches.length > 0); setActiveSuggestionIndex(matches.length > 0 ? 0 : -1); @@ -62,7 +68,6 @@ export function useReverseSearchCompletion( buffer.text, shellHistory, reverseSearchActive, - resetCompletionState, setActiveSuggestionIndex, setShowSuggestions, setSuggestions, diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts new file mode 100644 index 00000000..ba26f2d2 --- /dev/null +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -0,0 +1,434 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @vitest-environment jsdom */ + +import { describe, it, expect, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useSlashCompletion } from './useSlashCompletion.js'; +import { CommandContext, SlashCommand } from '../commands/types.js'; +import { useState } from 'react'; +import { Suggestion } from '../components/SuggestionsDisplay.js'; + +// Test harness to capture the state from the hook's callbacks. +function useTestHarnessForSlashCompletion( + enabled: boolean, + query: string | null, + slashCommands: readonly SlashCommand[], + commandContext: CommandContext, +) { + const [suggestions, setSuggestions] = useState<Suggestion[]>([]); + const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false); + const [isPerfectMatch, setIsPerfectMatch] = useState(false); + + const { completionStart, completionEnd } = useSlashCompletion({ + enabled, + query, + slashCommands, + commandContext, + setSuggestions, + setIsLoadingSuggestions, + setIsPerfectMatch, + }); + + return { + suggestions, + isLoadingSuggestions, + isPerfectMatch, + completionStart, + completionEnd, + }; +} + +describe('useSlashCompletion', () => { + // A minimal mock is sufficient for these tests. + const mockCommandContext = {} as CommandContext; + + 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(() => + useTestHarnessForSlashCompletion( + true, + '/', + 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(() => + useTestHarnessForSlashCompletion( + true, + '/mem', + slashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toEqual([ + { label: 'memory', value: 'memory', description: 'Manage memory' }, + ]); + }); + + 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(() => + useTestHarnessForSlashCompletion( + true, + '/usag', + 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(() => + useTestHarnessForSlashCompletion( + true, + '/clear', + slashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toHaveLength(0); + }); + + 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(() => + useTestHarnessForSlashCompletion( + true, + query, + 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(() => + useTestHarnessForSlashCompletion( + true, + '/clear ', + slashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toHaveLength(0); + }); + + it('should not provide suggestions for an unknown command', async () => { + const slashCommands = [ + { name: 'help', description: 'Show help' }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/unknown-command', + slashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toHaveLength(0); + }); + }); + + 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(() => + useTestHarnessForSlashCompletion( + true, + '/memory', + 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 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(() => + useTestHarnessForSlashCompletion( + true, + '/memory ', + 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(() => + useTestHarnessForSlashCompletion( + true, + '/memory a', + 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(() => + useTestHarnessForSlashCompletion( + true, + '/memory dothisnow', + slashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toHaveLength(0); + }); + }); + + 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(() => + useTestHarnessForSlashCompletion( + true, + '/chat resume my-ch', + slashCommands, + mockCommandContext, + ), + ); + + await waitFor(() => { + expect(mockCompletionFn).toHaveBeenCalledWith( + mockCommandContext, + 'my-ch', + ); + }); + + await waitFor(() => { + 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(() => + useTestHarnessForSlashCompletion( + true, + '/chat resume ', + slashCommands, + mockCommandContext, + ), + ); + + await waitFor(() => { + expect(mockCompletionFn).toHaveBeenCalledWith(mockCommandContext, ''); + }); + + await waitFor(() => { + expect(result.current.suggestions).toHaveLength(3); + }); + }); + + 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(() => + useTestHarnessForSlashCompletion( + true, + '/chat resume ', + slashCommands, + mockCommandContext, + ), + ); + + await waitFor(() => { + expect(result.current.suggestions).toHaveLength(0); + }); + }); + }); +}); diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts new file mode 100644 index 00000000..9836362f --- /dev/null +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -0,0 +1,187 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect } from 'react'; +import { Suggestion } from '../components/SuggestionsDisplay.js'; +import { CommandContext, SlashCommand } from '../commands/types.js'; + +export interface UseSlashCompletionProps { + enabled: boolean; + query: string | null; + slashCommands: readonly SlashCommand[]; + commandContext: CommandContext; + setSuggestions: (suggestions: Suggestion[]) => void; + setIsLoadingSuggestions: (isLoading: boolean) => void; + setIsPerfectMatch: (isMatch: boolean) => void; +} + +export function useSlashCompletion(props: UseSlashCompletionProps): { + completionStart: number; + completionEnd: number; +} { + const { + enabled, + query, + slashCommands, + commandContext, + setSuggestions, + setIsLoadingSuggestions, + setIsPerfectMatch, + } = props; + const [completionStart, setCompletionStart] = useState(-1); + const [completionEnd, setCompletionEnd] = useState(-1); + + useEffect(() => { + if (!enabled || query === null) { + return; + } + + const fullPath = query?.substring(1) || ''; + const hasTrailingSpace = !!query?.endsWith(' '); + const rawParts = fullPath.split(/\s+/).filter((p) => p); + let commandPathParts = rawParts; + let partial = ''; + + if (!hasTrailingSpace && rawParts.length > 0) { + partial = rawParts[rawParts.length - 1]; + commandPathParts = rawParts.slice(0, -1); + } + + 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; + if (!hasTrailingSpace && currentLevel) { + exactMatchAsParent = currentLevel.find( + (cmd) => + (cmd.name === partial || cmd.altNames?.includes(partial)) && + cmd.subCommands, + ); + + if (exactMatchAsParent) { + leafCommand = exactMatchAsParent; + currentLevel = exactMatchAsParent.subCommands; + partial = ''; + } + } + + setIsPerfectMatch(false); + if (!hasTrailingSpace) { + if (leafCommand && partial === '' && leafCommand.action) { + setIsPerfectMatch(true); + } else if (currentLevel) { + 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 !== '')); + + if (hasTrailingSpace || exactMatchAsParent) { + setCompletionStart(query.length); + setCompletionEnd(query.length); + } else if (partial) { + if (isArgumentCompletion) { + const commandSoFar = `/${commandPathParts.join(' ')}`; + const argStartIndex = + commandSoFar.length + (commandPathParts.length > 0 ? 1 : 0); + setCompletionStart(argStartIndex); + } else { + setCompletionStart(query.length - partial.length); + } + setCompletionEnd(query.length); + } else { + setCompletionStart(1); + setCompletionEnd(query.length); + } + + 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); + setIsLoadingSuggestions(false); + }; + fetchAndSetSuggestions(); + return; + } + + 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 (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); + return; + } + + setSuggestions([]); + }, [ + enabled, + query, + slashCommands, + commandContext, + setSuggestions, + setIsLoadingSuggestions, + setIsPerfectMatch, + ]); + + return { + completionStart, + completionEnd, + }; +} |
