| |
| |
| |
| |
| |
|
|
| import { |
| describe, |
| it, |
| expect, |
| beforeEach, |
| vi, |
| afterEach, |
| type Mock, |
| } from 'vitest'; |
| import { act, useEffect } from 'react'; |
| import { renderWithProviders } from '../../test-utils/render.js'; |
| import { waitFor } from '../../test-utils/async.js'; |
| import { |
| useCommandCompletion, |
| CompletionMode, |
| } from './useCommandCompletion.js'; |
| import type { CommandContext, SlashCommand } from '../commands/types.js'; |
| import type { Config } from '@google/gemini-cli-core'; |
| import { useTextBuffer } from '../components/shared/text-buffer.js'; |
| import type { Suggestion } from '../components/SuggestionsDisplay.js'; |
| import { |
| useAtCompletion, |
| type UseAtCompletionProps, |
| } from './useAtCompletion.js'; |
| import { |
| useSlashCompletion, |
| type UseSlashCompletionProps, |
| } from './useSlashCompletion.js'; |
| import { useShellCompletion } from './useShellCompletion.js'; |
|
|
| vi.mock('./useAtCompletion', () => ({ |
| useAtCompletion: vi.fn(), |
| })); |
|
|
| vi.mock('./usePromptCompletion', () => ({ |
| usePromptCompletion: vi.fn(() => ({ |
| text: '', |
| isLoading: false, |
| isActive: false, |
| accept: vi.fn(), |
| clear: vi.fn(), |
| markSelected: vi.fn(), |
| })), |
| })); |
|
|
| vi.mock('./useSlashCompletion', () => ({ |
| useSlashCompletion: vi.fn(() => ({ |
| completionStart: 0, |
| completionEnd: 0, |
| })), |
| })); |
|
|
| vi.mock('./useShellCompletion', () => ({ |
| useShellCompletion: vi.fn(() => ({ |
| completionStart: 0, |
| completionEnd: 0, |
| query: '', |
| activeStart: 0, |
| })), |
| })); |
|
|
| |
| const setupMocks = ({ |
| atSuggestions = [], |
| slashSuggestions = [], |
| shellSuggestions = [], |
| isLoading = false, |
| isPerfectMatch = false, |
| slashCompletionRange = { |
| completionStart: 0, |
| completionEnd: 0, |
| getCommandFromSuggestion: () => undefined, |
| }, |
| shellCompletionRange = { |
| completionStart: 0, |
| completionEnd: 0, |
| query: '', |
| activeStart: 0, |
| }, |
| }: { |
| atSuggestions?: Suggestion[]; |
| slashSuggestions?: Suggestion[]; |
| shellSuggestions?: Suggestion[]; |
| isLoading?: boolean; |
| isPerfectMatch?: boolean; |
| slashCompletionRange?: { |
| completionStart: number; |
| completionEnd: number; |
| getCommandFromSuggestion: ( |
| suggestion: Suggestion, |
| ) => SlashCommand | undefined; |
| }; |
| shellCompletionRange?: { |
| completionStart: number; |
| completionEnd: number; |
| query: string; |
| activeStart?: number; |
| }; |
| }) => { |
| |
| (useAtCompletion as Mock).mockImplementation( |
| ({ |
| enabled, |
| setSuggestions, |
| setIsLoadingSuggestions, |
| }: UseAtCompletionProps) => { |
| useEffect(() => { |
| if (enabled) { |
| setIsLoadingSuggestions(isLoading); |
| setSuggestions(atSuggestions); |
| } |
| }, [enabled, setSuggestions, setIsLoadingSuggestions]); |
| }, |
| ); |
|
|
| |
| (useSlashCompletion as Mock).mockImplementation( |
| ({ |
| enabled, |
| setSuggestions, |
| setIsLoadingSuggestions, |
| setIsPerfectMatch, |
| }: UseSlashCompletionProps) => { |
| useEffect(() => { |
| if (enabled) { |
| setIsLoadingSuggestions(isLoading); |
| setSuggestions(slashSuggestions); |
| setIsPerfectMatch(isPerfectMatch); |
| } |
| }, [enabled, setSuggestions, setIsLoadingSuggestions, setIsPerfectMatch]); |
| |
| return slashCompletionRange; |
| }, |
| ); |
|
|
| |
| (useShellCompletion as Mock).mockImplementation( |
| ({ enabled, setSuggestions, setIsLoadingSuggestions }) => { |
| useEffect(() => { |
| if (enabled) { |
| setIsLoadingSuggestions(isLoading); |
| setSuggestions(shellSuggestions); |
| } |
| }, [enabled, setSuggestions, setIsLoadingSuggestions]); |
| return { |
| ...shellCompletionRange, |
| activeStart: shellCompletionRange.activeStart ?? 0, |
| }; |
| }, |
| ); |
| }; |
|
|
| describe('useCommandCompletion', () => { |
| const mockCommandContext = {} as CommandContext; |
| const mockConfig = { |
| getEnablePromptCompletion: () => false, |
| getGeminiClient: vi.fn(), |
| } as unknown as Config; |
| const testRootDir = '/'; |
|
|
| |
| function useTextBufferForTest(text: string, cursorOffset?: number) { |
| return useTextBuffer({ |
| initialText: text, |
| initialCursorOffset: cursorOffset ?? text.length, |
| viewport: { width: 80, height: 20 }, |
| onChange: () => {}, |
| }); |
| } |
|
|
| let hookResult: ReturnType<typeof useCommandCompletion> & { |
| textBuffer: ReturnType<typeof useTextBuffer>; |
| }; |
|
|
| function TestComponent({ |
| initialText, |
| cursorOffset, |
| shellModeActive, |
| active, |
| }: { |
| initialText: string; |
| cursorOffset?: number; |
| shellModeActive: boolean; |
| active: boolean; |
| }) { |
| const textBuffer = useTextBufferForTest(initialText, cursorOffset); |
| const completion = useCommandCompletion({ |
| buffer: textBuffer, |
| cwd: testRootDir, |
| slashCommands: [], |
| commandContext: mockCommandContext, |
| reverseSearchActive: false, |
| shellModeActive, |
| config: mockConfig, |
| active, |
| }); |
| hookResult = { ...completion, textBuffer }; |
| return null; |
| } |
|
|
| const renderCommandCompletionHook = async ( |
| initialText: string, |
| cursorOffset?: number, |
| shellModeActive = false, |
| active = true, |
| ) => { |
| const renderResult = await renderWithProviders( |
| <TestComponent |
| initialText={initialText} |
| cursorOffset={cursorOffset} |
| shellModeActive={shellModeActive} |
| active={active} |
| />, |
| ); |
| return { |
| result: { |
| get current() { |
| return hookResult; |
| }, |
| }, |
| ...renderResult, |
| }; |
| }; |
|
|
| beforeEach(() => { |
| vi.clearAllMocks(); |
| |
| setupMocks({}); |
| }); |
|
|
| afterEach(() => { |
| vi.restoreAllMocks(); |
| }); |
|
|
| describe('Core Hook Behavior', () => { |
| describe('State Management', () => { |
| it('should initialize with default state', async () => { |
| const { result } = await renderCommandCompletionHook(''); |
|
|
| expect(result.current.suggestions).toEqual([]); |
| expect(result.current.activeSuggestionIndex).toBe(-1); |
| expect(result.current.visibleStartIndex).toBe(0); |
| expect(result.current.showSuggestions).toBe(false); |
| expect(result.current.isLoadingSuggestions).toBe(false); |
| expect(result.current.completionMode).toBe(CompletionMode.IDLE); |
| }); |
|
|
| it('should reset state when completion mode becomes IDLE', async () => { |
| setupMocks({ |
| atSuggestions: [{ label: 'src/file.txt', value: 'src/file.txt' }], |
| }); |
|
|
| const { result } = await renderCommandCompletionHook('@file'); |
|
|
| await waitFor(() => { |
| expect(result.current.suggestions).toHaveLength(1); |
| }); |
|
|
| expect(result.current.showSuggestions).toBe(true); |
|
|
| act(() => { |
| result.current.textBuffer.replaceRangeByOffset( |
| 0, |
| 5, |
| 'just some text', |
| ); |
| }); |
|
|
| await waitFor(() => { |
| expect(result.current.showSuggestions).toBe(false); |
| }); |
| }); |
|
|
| it('should reset all state to default values', async () => { |
| const { result } = await renderCommandCompletionHook('@files'); |
|
|
| act(() => { |
| result.current.setActiveSuggestionIndex(5); |
| }); |
|
|
| act(() => { |
| result.current.resetCompletionState(); |
| }); |
|
|
| expect(result.current.activeSuggestionIndex).toBe(-1); |
| expect(result.current.visibleStartIndex).toBe(0); |
| expect(result.current.showSuggestions).toBe(false); |
| }); |
|
|
| it('should call useAtCompletion with the correct query for an escaped space', async () => { |
| const text = '@src/a\\ file.txt'; |
| const { result } = await renderCommandCompletionHook(text); |
|
|
| await waitFor(() => { |
| expect(useAtCompletion).toHaveBeenLastCalledWith( |
| expect.objectContaining({ |
| enabled: true, |
| pattern: 'src/a\\ file.txt', |
| }), |
| ); |
| expect(result.current.completionMode).toBe(CompletionMode.AT); |
| }); |
| }); |
|
|
| it('should correctly identify the completion context with multiple @ symbols', async () => { |
| const text = '@file1 @file2'; |
| const cursorOffset = 3; |
|
|
| await renderCommandCompletionHook(text, cursorOffset); |
|
|
| await waitFor(() => { |
| expect(useAtCompletion).toHaveBeenLastCalledWith( |
| expect.objectContaining({ |
| enabled: true, |
| pattern: 'file1', |
| }), |
| ); |
| }); |
| }); |
|
|
| it.each([ |
| { |
| shellModeActive: false, |
| expectedSuggestions: 1, |
| expectedShowSuggestions: true, |
| description: |
| 'should show slash command suggestions when shellModeActive is false', |
| }, |
| { |
| shellModeActive: true, |
| expectedSuggestions: 0, |
| expectedShowSuggestions: false, |
| description: |
| 'should not show slash command suggestions when shellModeActive is true', |
| }, |
| ])( |
| '$description', |
| async ({ |
| shellModeActive, |
| expectedSuggestions, |
| expectedShowSuggestions, |
| }) => { |
| setupMocks({ |
| slashSuggestions: [{ label: 'clear', value: 'clear' }], |
| }); |
|
|
| const { result } = await renderCommandCompletionHook( |
| '/', |
| undefined, |
| shellModeActive, |
| ); |
|
|
| await waitFor(() => { |
| expect(result.current.suggestions.length).toBe(expectedSuggestions); |
| expect(result.current.showSuggestions).toBe( |
| expectedShowSuggestions, |
| ); |
| if (!shellModeActive) { |
| expect(result.current.completionMode).toBe(CompletionMode.SLASH); |
| } |
| }); |
| }, |
| ); |
| }); |
|
|
| describe('Navigation', () => { |
| const mockSuggestions = [ |
| { label: 'cmd1', value: 'cmd1' }, |
| { label: 'cmd2', value: 'cmd2' }, |
| { label: 'cmd3', value: 'cmd3' }, |
| { label: 'cmd4', value: 'cmd4' }, |
| { label: 'cmd5', value: 'cmd5' }, |
| ]; |
|
|
| beforeEach(() => { |
| setupMocks({ slashSuggestions: mockSuggestions }); |
| }); |
|
|
| it('should handle navigateUp with no suggestions', async () => { |
| setupMocks({ slashSuggestions: [] }); |
|
|
| const { result } = await renderCommandCompletionHook('/'); |
|
|
| act(() => { |
| result.current.navigateUp(); |
| }); |
|
|
| expect(result.current.activeSuggestionIndex).toBe(-1); |
| }); |
|
|
| it('should handle navigateDown with no suggestions', async () => { |
| setupMocks({ slashSuggestions: [] }); |
| const { result } = await renderCommandCompletionHook('/'); |
|
|
| act(() => { |
| result.current.navigateDown(); |
| }); |
|
|
| expect(result.current.activeSuggestionIndex).toBe(-1); |
| }); |
|
|
| it('should navigate up through suggestions with wrap-around', async () => { |
| const { result } = await renderCommandCompletionHook('/'); |
|
|
| await waitFor(() => { |
| expect(result.current.suggestions.length).toBe(5); |
| }); |
|
|
| expect(result.current.activeSuggestionIndex).toBe(0); |
|
|
| act(() => { |
| result.current.navigateUp(); |
| }); |
|
|
| expect(result.current.activeSuggestionIndex).toBe(4); |
| }); |
|
|
| it('should navigate down through suggestions with wrap-around', async () => { |
| const { result } = await renderCommandCompletionHook('/'); |
|
|
| await waitFor(() => { |
| expect(result.current.suggestions.length).toBe(5); |
| }); |
|
|
| act(() => { |
| result.current.setActiveSuggestionIndex(4); |
| }); |
| expect(result.current.activeSuggestionIndex).toBe(4); |
|
|
| act(() => { |
| result.current.navigateDown(); |
| }); |
|
|
| expect(result.current.activeSuggestionIndex).toBe(0); |
| }); |
|
|
| it('should handle navigation with multiple suggestions', async () => { |
| const { result } = await renderCommandCompletionHook('/'); |
|
|
| await waitFor(() => { |
| expect(result.current.suggestions.length).toBe(5); |
| }); |
|
|
| expect(result.current.activeSuggestionIndex).toBe(0); |
|
|
| act(() => result.current.navigateDown()); |
| expect(result.current.activeSuggestionIndex).toBe(1); |
|
|
| act(() => result.current.navigateDown()); |
| expect(result.current.activeSuggestionIndex).toBe(2); |
|
|
| act(() => result.current.navigateUp()); |
| expect(result.current.activeSuggestionIndex).toBe(1); |
|
|
| act(() => result.current.navigateUp()); |
| expect(result.current.activeSuggestionIndex).toBe(0); |
|
|
| act(() => result.current.navigateUp()); |
| expect(result.current.activeSuggestionIndex).toBe(4); |
| }); |
|
|
| it('should automatically select the first item when suggestions are available', async () => { |
| setupMocks({ slashSuggestions: mockSuggestions }); |
|
|
| const { result } = await renderCommandCompletionHook('/'); |
|
|
| await waitFor(() => { |
| expect(result.current.suggestions.length).toBe( |
| mockSuggestions.length, |
| ); |
| expect(result.current.activeSuggestionIndex).toBe(0); |
| }); |
| }); |
| }); |
| }); |
|
|
| describe('handleAutocomplete', () => { |
| it('should complete a partial command and ADD a space even if it has an action', async () => { |
| setupMocks({ |
| slashSuggestions: [{ label: 'memory', value: 'memory' }], |
| slashCompletionRange: { |
| completionStart: 1, |
| completionEnd: 4, |
| getCommandFromSuggestion: () => |
| ({ action: vi.fn() }) as unknown as SlashCommand, |
| }, |
| }); |
|
|
| const { result } = await renderCommandCompletionHook('/mem'); |
|
|
| await waitFor(() => { |
| expect(result.current.suggestions.length).toBe(1); |
| }); |
|
|
| act(() => { |
| result.current.handleAutocomplete(0); |
| }); |
|
|
| expect(result.current.textBuffer.text).toBe('/memory '); |
| }); |
|
|
| it('should complete a partial command and ADD a space if it has NO action (e.g. just a parent)', async () => { |
| setupMocks({ |
| slashSuggestions: [{ label: 'chat', value: 'chat' }], |
| slashCompletionRange: { |
| completionStart: 1, |
| completionEnd: 5, |
| getCommandFromSuggestion: () => ({}) as unknown as SlashCommand, |
| }, |
| }); |
|
|
| const { result } = await renderCommandCompletionHook('/chat'); |
|
|
| await waitFor(() => { |
| expect(result.current.suggestions.length).toBe(1); |
| }); |
|
|
| act(() => { |
| result.current.handleAutocomplete(0); |
| }); |
|
|
| expect(result.current.textBuffer.text).toBe('/chat '); |
| }); |
|
|
| it('should complete a file path', async () => { |
| setupMocks({ |
| atSuggestions: [{ label: 'src/file1.txt', value: 'src/file1.txt' }], |
| slashCompletionRange: { |
| completionStart: 0, |
| completionEnd: 0, |
| getCommandFromSuggestion: () => undefined, |
| }, |
| }); |
|
|
| const { result } = await renderCommandCompletionHook('@src/fi'); |
|
|
| await waitFor(() => { |
| expect(result.current.suggestions.length).toBe(1); |
| }); |
|
|
| act(() => { |
| result.current.handleAutocomplete(0); |
| }); |
|
|
| expect(result.current.textBuffer.text).toBe('@src/file1.txt '); |
| }); |
|
|
| it('should insert canonical slash command text when suggestion provides insertValue', async () => { |
| setupMocks({ |
| slashSuggestions: [ |
| { |
| label: 'list', |
| value: 'list', |
| insertValue: 'resume list', |
| }, |
| ], |
| slashCompletionRange: { |
| completionStart: 1, |
| completionEnd: 5, |
| getCommandFromSuggestion: () => undefined, |
| }, |
| }); |
|
|
| const { result } = await renderCommandCompletionHook('/resu'); |
|
|
| await waitFor(() => { |
| expect(result.current.suggestions.length).toBe(1); |
| }); |
|
|
| act(() => { |
| result.current.handleAutocomplete(0); |
| }); |
|
|
| expect(result.current.textBuffer.text).toBe('/resume list '); |
| }); |
|
|
| 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; |
|
|
| setupMocks({ |
| atSuggestions: [{ label: 'src/file1.txt', value: 'src/file1.txt' }], |
| slashCompletionRange: { |
| completionStart: 0, |
| completionEnd: 0, |
| getCommandFromSuggestion: () => undefined, |
| }, |
| }); |
|
|
| const { result } = await renderCommandCompletionHook(text, cursorOffset); |
|
|
| await waitFor(() => { |
| expect(result.current.suggestions.length).toBe(1); |
| }); |
|
|
| act(() => { |
| result.current.handleAutocomplete(0); |
| }); |
|
|
| expect(result.current.textBuffer.text).toBe( |
| '@src/file1.txt is a good file', |
| ); |
| }); |
|
|
| it('should complete a directory path ending with / without a trailing space', async () => { |
| setupMocks({ |
| atSuggestions: [{ label: 'src/components/', value: 'src/components/' }], |
| slashCompletionRange: { |
| completionStart: 0, |
| completionEnd: 0, |
| getCommandFromSuggestion: () => undefined, |
| }, |
| }); |
|
|
| const { result } = await renderCommandCompletionHook('@src/comp'); |
|
|
| await waitFor(() => { |
| expect(result.current.suggestions.length).toBe(1); |
| }); |
|
|
| act(() => { |
| result.current.handleAutocomplete(0); |
| }); |
|
|
| expect(result.current.textBuffer.text).toBe('@src/components/'); |
| }); |
|
|
| it('should complete a directory path ending with \\ without a trailing space', async () => { |
| setupMocks({ |
| atSuggestions: [ |
| { label: 'src\\components\\', value: 'src\\components\\' }, |
| ], |
| slashCompletionRange: { |
| completionStart: 0, |
| completionEnd: 0, |
| getCommandFromSuggestion: () => undefined, |
| }, |
| }); |
|
|
| const { result } = await renderCommandCompletionHook('@src\\comp'); |
|
|
| await waitFor(() => { |
| expect(result.current.suggestions.length).toBe(1); |
| }); |
|
|
| act(() => { |
| result.current.handleAutocomplete(0); |
| }); |
|
|
| expect(result.current.textBuffer.text).toBe('@src\\components\\'); |
| }); |
|
|
| it('should ADD a space for AT completion even if name matches a command with an action', async () => { |
| |
| |
| setupMocks({ |
| atSuggestions: [{ label: 'memory', value: 'memory' }], |
| slashCompletionRange: { |
| completionStart: 0, |
| completionEnd: 0, |
| getCommandFromSuggestion: () => |
| ({ action: vi.fn() }) as unknown as SlashCommand, |
| }, |
| }); |
|
|
| const { result } = await renderCommandCompletionHook('@mem'); |
|
|
| await waitFor(() => { |
| expect(result.current.suggestions.length).toBe(1); |
| }); |
|
|
| act(() => { |
| result.current.handleAutocomplete(0); |
| }); |
|
|
| |
| expect(result.current.textBuffer.text).toBe('@memory '); |
| }); |
|
|
| it('should show ghost text for a single shell completion', async () => { |
| const text = 'l'; |
| setupMocks({ |
| shellSuggestions: [{ label: 'ls', value: 'ls' }], |
| shellCompletionRange: { |
| completionStart: 0, |
| completionEnd: 1, |
| query: 'l', |
| activeStart: 0, |
| }, |
| }); |
|
|
| const { result } = await renderCommandCompletionHook( |
| text, |
| text.length, |
| true, |
| ); |
|
|
| await waitFor(() => { |
| expect(result.current.isLoadingSuggestions).toBe(false); |
| }); |
|
|
| |
| expect(result.current.promptCompletion.text).toBe('ls '); |
| }); |
|
|
| it('should not show ghost text if there are multiple completions', async () => { |
| const text = 'l'; |
| setupMocks({ |
| shellSuggestions: [ |
| { label: 'ls', value: 'ls' }, |
| { label: 'ln', value: 'ln' }, |
| ], |
| shellCompletionRange: { |
| completionStart: 0, |
| completionEnd: 1, |
| query: 'l', |
| activeStart: 0, |
| }, |
| }); |
|
|
| const { result } = await renderCommandCompletionHook( |
| text, |
| text.length, |
| true, |
| ); |
|
|
| await waitFor(() => { |
| expect(result.current.isLoadingSuggestions).toBe(false); |
| }); |
|
|
| expect(result.current.promptCompletion.text).toBe(''); |
| }); |
|
|
| it('should not show ghost text if the typed text extends past the completion', async () => { |
| |
| const text = 'ls '; |
| const cursorOffset = text.length; |
|
|
| const { result } = await renderCommandCompletionHook( |
| text, |
| cursorOffset, |
| true, |
| ); |
|
|
| await waitFor(() => { |
| expect(result.current.isLoadingSuggestions).toBe(false); |
| }); |
|
|
| expect(result.current.promptCompletion.text).toBe(''); |
| }); |
|
|
| it('should clear ghost text after user types a space when exact match ghost text was showing', async () => { |
| const textWithoutSpace = 'ls'; |
|
|
| setupMocks({ |
| shellSuggestions: [{ label: 'ls', value: 'ls' }], |
| shellCompletionRange: { |
| completionStart: 0, |
| completionEnd: 2, |
| query: 'ls', |
| activeStart: 0, |
| }, |
| }); |
|
|
| const { result } = await renderCommandCompletionHook( |
| textWithoutSpace, |
| textWithoutSpace.length, |
| true, |
| ); |
|
|
| await waitFor(() => { |
| expect(result.current.isLoadingSuggestions).toBe(false); |
| }); |
|
|
| |
| expect(result.current.promptCompletion.text).toBe(''); |
|
|
| |
| |
| |
| setupMocks({ |
| shellSuggestions: [{ label: 'ls', value: 'ls' }], |
| shellCompletionRange: { |
| completionStart: 3, |
| completionEnd: 3, |
| query: '', |
| activeStart: 0, |
| }, |
| }); |
|
|
| act(() => { |
| result.current.textBuffer.setText('ls ', 'end'); |
| }); |
|
|
| await waitFor(() => { |
| expect(result.current.isLoadingSuggestions).toBe(false); |
| }); |
|
|
| |
| expect(result.current.promptCompletion.text).toBe(''); |
| }); |
| }); |
|
|
| describe('prompt completion filtering', () => { |
| it('should not trigger prompt completion for line comments', async () => { |
| const mockConfig = { |
| getEnablePromptCompletion: () => true, |
| getGeminiClient: vi.fn(), |
| } as unknown as Config; |
|
|
| let hookResult: ReturnType<typeof useCommandCompletion> & { |
| textBuffer: ReturnType<typeof useTextBuffer>; |
| }; |
|
|
| function TestComponent() { |
| const textBuffer = useTextBufferForTest('// This is a line comment'); |
| const completion = useCommandCompletion({ |
| buffer: textBuffer, |
| cwd: testRootDir, |
| slashCommands: [], |
| commandContext: mockCommandContext, |
| reverseSearchActive: false, |
| shellModeActive: false, |
| config: mockConfig, |
| active: true, |
| }); |
| hookResult = { ...completion, textBuffer }; |
| return null; |
| } |
| await renderWithProviders(<TestComponent />); |
|
|
| |
| await waitFor(() => { |
| expect(hookResult!.suggestions.length).toBe(0); |
| }); |
| }); |
|
|
| it('should not trigger prompt completion for block comments', async () => { |
| const mockConfig = { |
| getEnablePromptCompletion: () => true, |
| getGeminiClient: vi.fn(), |
| } as unknown as Config; |
|
|
| let hookResult: ReturnType<typeof useCommandCompletion> & { |
| textBuffer: ReturnType<typeof useTextBuffer>; |
| }; |
|
|
| function TestComponent() { |
| const textBuffer = useTextBufferForTest( |
| '/* This is a block comment */', |
| ); |
| const completion = useCommandCompletion({ |
| buffer: textBuffer, |
| cwd: testRootDir, |
| slashCommands: [], |
| commandContext: mockCommandContext, |
| reverseSearchActive: false, |
| shellModeActive: false, |
| config: mockConfig, |
| active: true, |
| }); |
| hookResult = { ...completion, textBuffer }; |
| return null; |
| } |
| await renderWithProviders(<TestComponent />); |
|
|
| |
| await waitFor(() => { |
| expect(hookResult!.suggestions.length).toBe(0); |
| }); |
| }); |
|
|
| it('should trigger prompt completion for regular text when enabled', async () => { |
| const mockConfig = { |
| getEnablePromptCompletion: () => true, |
| getGeminiClient: vi.fn(), |
| } as unknown as Config; |
|
|
| let hookResult: ReturnType<typeof useCommandCompletion> & { |
| textBuffer: ReturnType<typeof useTextBuffer>; |
| }; |
|
|
| function TestComponent() { |
| const textBuffer = useTextBufferForTest( |
| 'This is regular text that should trigger completion', |
| ); |
| const completion = useCommandCompletion({ |
| buffer: textBuffer, |
| cwd: testRootDir, |
| slashCommands: [], |
| commandContext: mockCommandContext, |
| reverseSearchActive: false, |
| shellModeActive: false, |
| config: mockConfig, |
| active: true, |
| }); |
| hookResult = { ...completion, textBuffer }; |
| return null; |
| } |
| await renderWithProviders(<TestComponent />); |
|
|
| |
| await waitFor(() => { |
| expect(hookResult!.textBuffer.text).toBe( |
| 'This is regular text that should trigger completion', |
| ); |
| }); |
| }); |
| }); |
|
|
| describe('@ completion after slash commands (issue #14420)', () => { |
| it('should show file suggestions when typing @path after a slash command', async () => { |
| setupMocks({ |
| atSuggestions: [{ label: 'src/file.txt', value: 'src/file.txt' }], |
| }); |
|
|
| const text = '/mycommand @src/fi'; |
| const cursorOffset = text.length; |
|
|
| await renderCommandCompletionHook(text, cursorOffset); |
|
|
| await waitFor(() => { |
| expect(useAtCompletion).toHaveBeenLastCalledWith( |
| expect.objectContaining({ |
| enabled: true, |
| pattern: 'src/fi', |
| }), |
| ); |
| }); |
| }); |
|
|
| it('should show slash suggestions when cursor is on command part (no @)', async () => { |
| setupMocks({ |
| slashSuggestions: [{ label: 'mycommand', value: 'mycommand' }], |
| }); |
|
|
| const text = '/mycom'; |
| const cursorOffset = text.length; |
|
|
| const { result } = await renderCommandCompletionHook(text, cursorOffset); |
|
|
| await waitFor(() => { |
| expect(result.current.suggestions).toHaveLength(1); |
| expect(result.current.suggestions[0]?.label).toBe('mycommand'); |
| }); |
| }); |
|
|
| it('should switch to @ completion when typing @ after slash command', async () => { |
| setupMocks({ |
| atSuggestions: [{ label: 'file.txt', value: 'file.txt' }], |
| }); |
|
|
| const text = '/command @'; |
| const cursorOffset = text.length; |
|
|
| await renderCommandCompletionHook(text, cursorOffset); |
|
|
| await waitFor(() => { |
| expect(useAtCompletion).toHaveBeenLastCalledWith( |
| expect.objectContaining({ |
| enabled: true, |
| pattern: '', |
| }), |
| ); |
| }); |
| }); |
|
|
| it('should handle multiple @ references in a slash command', async () => { |
| setupMocks({ |
| atSuggestions: [{ label: 'src/bar.ts', value: 'src/bar.ts' }], |
| }); |
|
|
| const text = '/diff @src/foo.ts @src/ba'; |
| const cursorOffset = text.length; |
|
|
| await renderCommandCompletionHook(text, cursorOffset); |
|
|
| await waitFor(() => { |
| expect(useAtCompletion).toHaveBeenLastCalledWith( |
| expect.objectContaining({ |
| enabled: true, |
| pattern: 'src/ba', |
| }), |
| ); |
| }); |
| }); |
|
|
| it('should complete file path and add trailing space', async () => { |
| setupMocks({ |
| atSuggestions: [{ label: 'src/file.txt', value: 'src/file.txt' }], |
| slashCompletionRange: { |
| completionStart: 0, |
| completionEnd: 0, |
| getCommandFromSuggestion: () => undefined, |
| }, |
| }); |
|
|
| const { result } = await renderCommandCompletionHook('/cmd @src/fi'); |
|
|
| await waitFor(() => { |
| expect(result.current.suggestions.length).toBe(1); |
| }); |
|
|
| act(() => { |
| result.current.handleAutocomplete(0); |
| }); |
|
|
| expect(result.current.textBuffer.text).toBe('/cmd @src/file.txt '); |
| }); |
|
|
| it('should stay in slash mode when slash command has trailing space but no @', async () => { |
| setupMocks({ |
| slashSuggestions: [{ label: 'help', value: 'help' }], |
| }); |
|
|
| const text = '/help '; |
| await renderCommandCompletionHook(text); |
|
|
| await waitFor(() => { |
| expect(useSlashCompletion).toHaveBeenLastCalledWith( |
| expect.objectContaining({ |
| enabled: true, |
| }), |
| ); |
| }); |
| }); |
| }); |
| }); |
|
|