From 1d7eb0d25078f34b37a0cbd8a6a869d3e61a2602 Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Thu, 24 Jul 2025 21:41:35 -0700 Subject: [PATCH] [Refactor] Centralizes autocompletion logic within useCompletion (#4740) --- .../src/ui/components/InputPrompt.test.tsx | 91 ++- .../cli/src/ui/components/InputPrompt.tsx | 124 +--- .../hooks/useCompletion.integration.test.ts | 284 +++---- .../cli/src/ui/hooks/useCompletion.test.ts | 699 ++++++++++++------ packages/cli/src/ui/hooks/useCompletion.ts | 121 ++- 5 files changed, 786 insertions(+), 533 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index bad29f10..a1894002 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -121,6 +121,15 @@ describe('InputPrompt', () => { openInExternalEditor: vi.fn(), newline: vi.fn(), backspace: vi.fn(), + preferredCol: null, + selectionAnchor: null, + insert: vi.fn(), + del: vi.fn(), + undo: vi.fn(), + redo: vi.fn(), + replaceRange: vi.fn(), + deleteWordLeft: vi.fn(), + deleteWordRight: vi.fn(), } as unknown as TextBuffer; mockShellHistory = { @@ -137,12 +146,14 @@ describe('InputPrompt', () => { isLoadingSuggestions: false, showSuggestions: false, visibleStartIndex: 0, + isPerfectMatch: false, navigateUp: vi.fn(), navigateDown: vi.fn(), resetCompletionState: vi.fn(), setActiveSuggestionIndex: vi.fn(), setShowSuggestions: vi.fn(), - } as unknown as UseCompletionReturn; + handleAutocomplete: vi.fn(), + }; mockedUseCompletion.mockReturnValue(mockCompletion); mockInputHistory = { @@ -465,7 +476,7 @@ describe('InputPrompt', () => { stdin.write('\t'); // Press Tab await wait(); - expect(props.buffer.setText).toHaveBeenCalledWith('/memory'); + expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0); unmount(); }); @@ -488,7 +499,7 @@ describe('InputPrompt', () => { stdin.write('\t'); // Press Tab await wait(); - expect(props.buffer.setText).toHaveBeenCalledWith('/memory add'); + expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(1); unmount(); }); @@ -513,7 +524,7 @@ describe('InputPrompt', () => { await wait(); // It should NOT become '/show'. It should correctly become '/memory show'. - expect(props.buffer.setText).toHaveBeenCalledWith('/memory show'); + expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0); unmount(); }); @@ -533,7 +544,7 @@ describe('InputPrompt', () => { stdin.write('\t'); // Press Tab await wait(); - expect(props.buffer.setText).toHaveBeenCalledWith('/chat resume fix-foo'); + expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0); unmount(); }); @@ -553,7 +564,7 @@ describe('InputPrompt', () => { await wait(); // The app should autocomplete the text, NOT submit. - expect(props.buffer.setText).toHaveBeenCalledWith('/memory'); + expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0); expect(props.onSubmit).not.toHaveBeenCalled(); unmount(); @@ -583,7 +594,7 @@ describe('InputPrompt', () => { stdin.write('\t'); // Press Tab for autocomplete await wait(); - expect(props.buffer.setText).toHaveBeenCalledWith('/help'); + expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0); unmount(); }); @@ -600,10 +611,29 @@ describe('InputPrompt', () => { unmount(); }); + it('should submit directly on Enter when isPerfectMatch is true', async () => { + mockedUseCompletion.mockReturnValue({ + ...mockCompletion, + showSuggestions: false, + isPerfectMatch: true, + }); + props.buffer.setText('/clear'); + + const { stdin, unmount } = render(); + await wait(); + + stdin.write('\r'); + await wait(); + + expect(props.onSubmit).toHaveBeenCalledWith('/clear'); + unmount(); + }); + it('should submit directly on Enter when a complete leaf command is typed', async () => { mockedUseCompletion.mockReturnValue({ ...mockCompletion, showSuggestions: false, + isPerfectMatch: false, // Added explicit isPerfectMatch false }); props.buffer.setText('/clear'); @@ -632,7 +662,7 @@ describe('InputPrompt', () => { stdin.write('\r'); await wait(); - expect(props.buffer.replaceRangeByOffset).toHaveBeenCalled(); + expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0); expect(props.onSubmit).not.toHaveBeenCalled(); unmount(); }); @@ -697,11 +727,10 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - // Verify useCompletion was called with true (should show completion) + // Verify useCompletion was called with correct signature expect(mockedUseCompletion).toHaveBeenCalledWith( - '@src/components', + mockBuffer, path.join('test', 'project', 'src'), - true, // shouldShowCompletion should be true mockSlashCommands, mockCommandContext, expect.any(Object), @@ -725,9 +754,8 @@ describe('InputPrompt', () => { await wait(); expect(mockedUseCompletion).toHaveBeenCalledWith( - '/memory', + mockBuffer, path.join('test', 'project', 'src'), - true, // shouldShowCompletion should be true mockSlashCommands, mockCommandContext, expect.any(Object), @@ -751,9 +779,8 @@ describe('InputPrompt', () => { await wait(); expect(mockedUseCompletion).toHaveBeenCalledWith( - '@src/file.ts hello', + mockBuffer, path.join('test', 'project', 'src'), - false, // shouldShowCompletion should be false mockSlashCommands, mockCommandContext, expect.any(Object), @@ -777,9 +804,8 @@ describe('InputPrompt', () => { await wait(); expect(mockedUseCompletion).toHaveBeenCalledWith( - '/memory add', + mockBuffer, path.join('test', 'project', 'src'), - false, // shouldShowCompletion should be false mockSlashCommands, mockCommandContext, expect.any(Object), @@ -803,9 +829,8 @@ describe('InputPrompt', () => { await wait(); expect(mockedUseCompletion).toHaveBeenCalledWith( - 'hello world', + mockBuffer, path.join('test', 'project', 'src'), - false, // shouldShowCompletion should be false mockSlashCommands, mockCommandContext, expect.any(Object), @@ -828,10 +853,10 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); + // Verify useCompletion was called with the buffer expect(mockedUseCompletion).toHaveBeenCalledWith( - 'first line\n/memory', + mockBuffer, path.join('test', 'project', 'src'), - false, // shouldShowCompletion should be false (isSlashCommand returns false because text doesn't start with /) mockSlashCommands, mockCommandContext, expect.any(Object), @@ -855,9 +880,8 @@ describe('InputPrompt', () => { await wait(); expect(mockedUseCompletion).toHaveBeenCalledWith( - '/memory', + mockBuffer, path.join('test', 'project', 'src'), - true, // shouldShowCompletion should be true (isSlashCommand returns true AND cursor is after / without space) mockSlashCommands, mockCommandContext, expect.any(Object), @@ -882,9 +906,8 @@ describe('InputPrompt', () => { await wait(); expect(mockedUseCompletion).toHaveBeenCalledWith( - '@src/file๐Ÿ‘.txt', + mockBuffer, path.join('test', 'project', 'src'), - true, // shouldShowCompletion should be true mockSlashCommands, mockCommandContext, expect.any(Object), @@ -909,9 +932,8 @@ describe('InputPrompt', () => { await wait(); expect(mockedUseCompletion).toHaveBeenCalledWith( - '@src/file๐Ÿ‘.txt hello', + mockBuffer, path.join('test', 'project', 'src'), - false, // shouldShowCompletion should be false mockSlashCommands, mockCommandContext, expect.any(Object), @@ -936,9 +958,8 @@ describe('InputPrompt', () => { await wait(); expect(mockedUseCompletion).toHaveBeenCalledWith( - '@src/my\\ file.txt', + mockBuffer, path.join('test', 'project', 'src'), - true, // shouldShowCompletion should be true mockSlashCommands, mockCommandContext, expect.any(Object), @@ -963,9 +984,8 @@ describe('InputPrompt', () => { await wait(); expect(mockedUseCompletion).toHaveBeenCalledWith( - '@path/my\\ file.txt hello', + mockBuffer, path.join('test', 'project', 'src'), - false, // shouldShowCompletion should be false mockSlashCommands, mockCommandContext, expect.any(Object), @@ -992,9 +1012,8 @@ describe('InputPrompt', () => { await wait(); expect(mockedUseCompletion).toHaveBeenCalledWith( - '@docs/my\\ long\\ file\\ name.md', + mockBuffer, path.join('test', 'project', 'src'), - true, // shouldShowCompletion should be true mockSlashCommands, mockCommandContext, expect.any(Object), @@ -1019,9 +1038,8 @@ describe('InputPrompt', () => { await wait(); expect(mockedUseCompletion).toHaveBeenCalledWith( - '/memory\\ test', + mockBuffer, path.join('test', 'project', 'src'), - true, // shouldShowCompletion should be true mockSlashCommands, mockCommandContext, expect.any(Object), @@ -1048,9 +1066,8 @@ describe('InputPrompt', () => { await wait(); expect(mockedUseCompletion).toHaveBeenCalledWith( - '@' + path.join('files', 'emoji\\ ๐Ÿ‘\\ test.txt'), + mockBuffer, path.join('test', 'project', 'src'), - true, // shouldShowCompletion should be true mockSlashCommands, mockCommandContext, expect.any(Object), diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 6192fb8c..9f15b56d 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -10,13 +10,12 @@ import { Colors } from '../colors.js'; import { SuggestionsDisplay } from './SuggestionsDisplay.js'; import { useInputHistory } from '../hooks/useInputHistory.js'; import { TextBuffer } from './shared/text-buffer.js'; -import { cpSlice, cpLen, toCodePoints } from '../utils/textUtils.js'; +import { cpSlice, cpLen } from '../utils/textUtils.js'; import chalk from 'chalk'; import stringWidth from 'string-width'; import { useShellHistory } from '../hooks/useShellHistory.js'; import { useCompletion } from '../hooks/useCompletion.js'; import { useKeypress, Key } from '../hooks/useKeypress.js'; -import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js'; import { CommandContext, SlashCommand } from '../commands/types.js'; import { Config } from '@google/gemini-cli-core'; import { @@ -59,53 +58,9 @@ export const InputPrompt: React.FC = ({ }) => { const [justNavigatedHistory, setJustNavigatedHistory] = useState(false); - // Check if cursor is after @ or / without unescaped spaces - const isCursorAfterCommandWithoutSpace = useCallback(() => { - const [row, col] = buffer.cursor; - const currentLine = buffer.lines[row] || ''; - - // Convert current line to code points for Unicode-aware processing - const codePoints = toCodePoints(currentLine); - - // Search backwards from cursor position within the current line only - for (let i = col - 1; i >= 0; i--) { - const char = codePoints[i]; - - if (char === ' ') { - // Check if this space is escaped by counting backslashes before it - let backslashCount = 0; - for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { - backslashCount++; - } - - // If there's an odd number of backslashes, the space is escaped - const isEscaped = backslashCount % 2 === 1; - - if (!isEscaped) { - // Found unescaped space before @ or /, return false - return false; - } - // If escaped, continue searching backwards - } else if (char === '@' || char === '/') { - // Found @ or / without unescaped space in between - return true; - } - } - - return false; - }, [buffer.cursor, buffer.lines]); - - const shouldShowCompletion = useCallback( - () => - (isAtCommand(buffer.text) || isSlashCommand(buffer.text)) && - isCursorAfterCommandWithoutSpace(), - [buffer.text, isCursorAfterCommandWithoutSpace], - ); - const completion = useCompletion( - buffer.text, + buffer, config.getTargetDir(), - shouldShowCompletion(), slashCommands, commandContext, config, @@ -159,78 +114,6 @@ export const InputPrompt: React.FC = ({ setJustNavigatedHistory, ]); - const completionSuggestions = completion.suggestions; - const handleAutocomplete = useCallback( - (indexToUse: number) => { - if (indexToUse < 0 || indexToUse >= completionSuggestions.length) { - return; - } - const query = buffer.text; - const suggestion = completionSuggestions[indexToUse].value; - - if (query.trimStart().startsWith('/')) { - const hasTrailingSpace = query.endsWith(' '); - const parts = query - .trimStart() - .substring(1) - .split(/\s+/) - .filter(Boolean); - - let isParentPath = false; - // If there's no trailing space, we need to check if the current query - // is already a complete path to a parent command. - if (!hasTrailingSpace) { - let currentLevel: readonly SlashCommand[] | undefined = slashCommands; - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - const found: SlashCommand | undefined = currentLevel?.find( - (cmd) => cmd.name === part || cmd.altNames?.includes(part), - ); - - if (found) { - if (i === parts.length - 1 && found.subCommands) { - isParentPath = true; - } - currentLevel = found.subCommands as - | readonly SlashCommand[] - | undefined; - } else { - // Path is invalid, so it can't be a parent path. - currentLevel = undefined; - break; - } - } - } - - // Determine the base path of the command. - // - If there's a trailing space, the whole command is the base. - // - If it's a known parent path, the whole command is the base. - // - Otherwise, the base is everything EXCEPT the last partial part. - const basePath = - hasTrailingSpace || isParentPath ? parts : parts.slice(0, -1); - const newValue = `/${[...basePath, suggestion].join(' ')}`; - - buffer.setText(newValue); - } else { - const atIndex = query.lastIndexOf('@'); - if (atIndex === -1) return; - const pathPart = query.substring(atIndex + 1); - const lastSlashIndexInPath = pathPart.lastIndexOf('/'); - let autoCompleteStartIndex = atIndex + 1; - if (lastSlashIndexInPath !== -1) { - autoCompleteStartIndex += lastSlashIndexInPath + 1; - } - buffer.replaceRangeByOffset( - autoCompleteStartIndex, - buffer.text.length, - suggestion, - ); - } - resetCompletionState(); - }, - [resetCompletionState, buffer, completionSuggestions, slashCommands], - ); - // Handle clipboard image pasting with Ctrl+V const handleClipboardImage = useCallback(async () => { try { @@ -337,7 +220,7 @@ export const InputPrompt: React.FC = ({ ? 0 // Default to the first if none is active : completion.activeSuggestionIndex; if (targetIndex < completion.suggestions.length) { - handleAutocomplete(targetIndex); + completion.handleAutocomplete(targetIndex); } } return; @@ -459,7 +342,6 @@ export const InputPrompt: React.FC = ({ setShellModeActive, onClearScreen, inputHistory, - handleAutocomplete, handleSubmitAndClear, shellHistory, handleClipboardImage, diff --git a/packages/cli/src/ui/hooks/useCompletion.integration.test.ts b/packages/cli/src/ui/hooks/useCompletion.integration.test.ts index 840d2814..d4c66a15 100644 --- a/packages/cli/src/ui/hooks/useCompletion.integration.test.ts +++ b/packages/cli/src/ui/hooks/useCompletion.integration.test.ts @@ -16,6 +16,7 @@ import { SlashCommand, } from '../commands/types.js'; import { Config, FileDiscoveryService } from '@google/gemini-cli-core'; +import { useTextBuffer } from '../components/shared/text-buffer.js'; interface MockConfig { getFileFilteringOptions: () => { @@ -26,6 +27,19 @@ interface MockConfig { getFileService: () => FileDiscoveryService | null; } +// Helper to create real TextBuffer objects within renderHook +const useTextBufferForTest = (text: string) => { + const cursorOffset = text.length; + + return useTextBuffer({ + initialText: text, + initialCursorOffset: cursorOffset, + viewport: { width: 80, height: 20 }, + isValidPath: () => false, + onChange: () => {}, + }); +}; + // Mock dependencies vi.mock('fs/promises'); vi.mock('@google/gemini-cli-core', async () => { @@ -183,16 +197,16 @@ describe('useCompletion git-aware filtering integration', () => { }, ); - const { result } = renderHook(() => - useCompletion( - '@d', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('@d'); + return useCompletion( + textBuffer, testCwd, - true, slashCommands, mockCommandContext, mockConfig as Config, - ), - ); + ); + }); // Wait for async operations to complete await act(async () => { @@ -241,16 +255,16 @@ describe('useCompletion git-aware filtering integration', () => { }, ); - const { result } = renderHook(() => - useCompletion( - '@', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('@'); + return useCompletion( + textBuffer, testCwd, - true, slashCommands, mockCommandContext, mockConfig as Config, - ), - ); + ); + }); // Wait for async operations to complete await act(async () => { @@ -323,16 +337,16 @@ describe('useCompletion git-aware filtering integration', () => { }, ); - const { result } = renderHook(() => - useCompletion( - '@t', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('@t'); + return useCompletion( + textBuffer, testCwd, - true, slashCommands, mockCommandContext, mockConfig as Config, - ), - ); + ); + }); // Wait for async operations to complete await act(async () => { @@ -362,16 +376,16 @@ describe('useCompletion git-aware filtering integration', () => { { name: 'dist', isDirectory: () => true }, ] as unknown as Awaited>); - renderHook(() => - useCompletion( - '@d', + renderHook(() => { + const textBuffer = useTextBufferForTest('@d'); + return useCompletion( + textBuffer, testCwd, - true, slashCommands, mockCommandContext, mockConfigNoRecursive, - ), - ); + ); + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 150)); @@ -390,22 +404,21 @@ describe('useCompletion git-aware filtering integration', () => { { name: 'README.md', isDirectory: () => false }, ] as unknown as Awaited>); - const { result } = renderHook(() => - useCompletion( - '@', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('@'); + return useCompletion( + textBuffer, testCwd, - true, slashCommands, mockCommandContext, undefined, - ), - ); + ); + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 150)); }); - // Without config, should include all files expect(result.current.suggestions).toHaveLength(3); expect(result.current.suggestions).toEqual( expect.arrayContaining([ @@ -424,16 +437,16 @@ describe('useCompletion git-aware filtering integration', () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const { result } = renderHook(() => - useCompletion( - '@', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('@'); + return useCompletion( + textBuffer, testCwd, - true, slashCommands, mockCommandContext, mockConfig as Config, - ), - ); + ); + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 150)); @@ -470,16 +483,16 @@ describe('useCompletion git-aware filtering integration', () => { }, ); - const { result } = renderHook(() => - useCompletion( - '@src/comp', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('@src/comp'); + return useCompletion( + textBuffer, testCwd, - true, slashCommands, mockCommandContext, mockConfig as Config, - ), - ); + ); + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 150)); @@ -495,16 +508,16 @@ describe('useCompletion git-aware filtering integration', () => { const globResults = [`${testCwd}/src/index.ts`, `${testCwd}/README.md`]; vi.mocked(glob).mockResolvedValue(globResults); - const { result } = renderHook(() => - useCompletion( - '@s', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('@s'); + return useCompletion( + textBuffer, testCwd, - true, slashCommands, mockCommandContext, mockConfig as Config, - ), - ); + ); + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 150)); @@ -530,16 +543,16 @@ describe('useCompletion git-aware filtering integration', () => { ]; vi.mocked(glob).mockResolvedValue(globResults); - const { result } = renderHook(() => - useCompletion( - '@.', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('@.'); + return useCompletion( + textBuffer, testCwd, - true, slashCommands, mockCommandContext, mockConfig as Config, - ), - ); + ); + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 150)); @@ -559,15 +572,15 @@ describe('useCompletion git-aware filtering integration', () => { }); it('should suggest top-level command names based on partial input', async () => { - const { result } = renderHook(() => - useCompletion( - '/mem', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/mem'); + return useCompletion( + textBuffer, '/test/cwd', - true, mockSlashCommands, mockCommandContext, - ), - ); + ); + }); expect(result.current.suggestions).toEqual([ { label: 'memory', value: 'memory', description: 'Manage memory' }, @@ -578,30 +591,30 @@ describe('useCompletion git-aware filtering integration', () => { it.each([['/?'], ['/usage']])( 'should not suggest commands when altNames is fully typed', async (altName) => { - const { result } = renderHook(() => - useCompletion( - altName, + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest(altName); + return useCompletion( + textBuffer, '/test/cwd', - true, mockSlashCommands, mockCommandContext, - ), - ); + ); + }); expect(result.current.suggestions).toHaveLength(0); }, ); it('should suggest commands based on partial altNames matches', async () => { - const { result } = renderHook(() => - useCompletion( - '/usag', // part of the word "usage" + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/usag'); // part of the word "usage" + return useCompletion( + textBuffer, '/test/cwd', - true, mockSlashCommands, mockCommandContext, - ), - ); + ); + }); expect(result.current.suggestions).toEqual([ { @@ -613,15 +626,15 @@ describe('useCompletion git-aware filtering integration', () => { }); it('should suggest sub-command names for a parent command', async () => { - const { result } = renderHook(() => - useCompletion( - '/memory a', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/memory a'); + return useCompletion( + textBuffer, '/test/cwd', - true, mockSlashCommands, mockCommandContext, - ), - ); + ); + }); expect(result.current.suggestions).toEqual([ { label: 'add', value: 'add', description: 'Add to memory' }, @@ -629,15 +642,15 @@ describe('useCompletion git-aware filtering integration', () => { }); it('should suggest all sub-commands when the query ends with the parent command and a space', async () => { - const { result } = renderHook(() => - useCompletion( - '/memory ', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/memory '); + return useCompletion( + textBuffer, '/test/cwd', - true, mockSlashCommands, mockCommandContext, - ), - ); + ); + }); expect(result.current.suggestions).toHaveLength(2); expect(result.current.suggestions).toEqual( @@ -652,8 +665,9 @@ describe('useCompletion git-aware filtering integration', () => { 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)), + .mockImplementation( + async (_context: CommandContext, partialArg: string) => + availableTags.filter((tag) => tag.startsWith(partialArg)), ); const mockCommandsWithFiltering = JSON.parse( @@ -678,15 +692,15 @@ describe('useCompletion git-aware filtering integration', () => { resumeCmd.completion = mockCompletionFn; - const { result } = renderHook(() => - useCompletion( - '/chat resume my-ch', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/chat resume my-ch'); + return useCompletion( + textBuffer, '/test/cwd', - true, mockCommandsWithFiltering, mockCommandContext, - ), - ); + ); + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 150)); @@ -701,45 +715,45 @@ describe('useCompletion git-aware filtering integration', () => { }); it('should not provide suggestions for a fully typed command that has no sub-commands or argument completion', async () => { - const { result } = renderHook(() => - useCompletion( - '/clear ', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/clear '); + return useCompletion( + textBuffer, '/test/cwd', - true, mockSlashCommands, mockCommandContext, - ), - ); + ); + }); expect(result.current.suggestions).toHaveLength(0); expect(result.current.showSuggestions).toBe(false); }); it('should not provide suggestions for an unknown command', async () => { - const { result } = renderHook(() => - useCompletion( - '/unknown-command', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/unknown-command'); + return useCompletion( + textBuffer, '/test/cwd', - true, mockSlashCommands, mockCommandContext, - ), - ); + ); + }); expect(result.current.suggestions).toHaveLength(0); expect(result.current.showSuggestions).toBe(false); }); it('should suggest sub-commands for a fully typed parent command without a trailing space', async () => { - const { result } = renderHook(() => - useCompletion( - '/memory', // Note: no trailing space + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/memory'); // Note: no trailing space + return useCompletion( + textBuffer, '/test/cwd', - true, mockSlashCommands, mockCommandContext, - ), - ); + ); + }); // Assert that suggestions for sub-commands are shown immediately expect(result.current.suggestions).toHaveLength(2); @@ -753,15 +767,15 @@ describe('useCompletion git-aware filtering integration', () => { }); it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => { - const { result } = renderHook(() => - useCompletion( - '/clear', // No trailing space + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/clear'); // No trailing space + return useCompletion( + textBuffer, '/test/cwd', - true, mockSlashCommands, mockCommandContext, - ), - ); + ); + }); expect(result.current.suggestions).toHaveLength(0); expect(result.current.showSuggestions).toBe(false); @@ -787,15 +801,15 @@ describe('useCompletion git-aware filtering integration', () => { } resumeCommand.completion = mockCompletionFn; - const { result } = renderHook(() => - useCompletion( - '/chat resume ', // Trailing space, no partial argument + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/chat resume '); // Trailing space, no partial argument + return useCompletion( + textBuffer, '/test/cwd', - true, isolatedMockCommands, mockCommandContext, - ), - ); + ); + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 150)); @@ -807,15 +821,15 @@ describe('useCompletion git-aware filtering integration', () => { }); it('should suggest all top-level commands for the root slash', async () => { - const { result } = renderHook(() => - useCompletion( - '/', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/'); + return useCompletion( + textBuffer, '/test/cwd', - true, mockSlashCommands, mockCommandContext, - ), - ); + ); + }); expect(result.current.suggestions.length).toBe(mockSlashCommands.length); expect(result.current.suggestions.map((s) => s.label)).toEqual( @@ -824,15 +838,15 @@ describe('useCompletion git-aware filtering integration', () => { }); it('should provide no suggestions for an invalid sub-command', async () => { - const { result } = renderHook(() => - useCompletion( - '/memory dothisnow', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/memory dothisnow'); + return useCompletion( + textBuffer, '/test/cwd', - true, mockSlashCommands, mockCommandContext, - ), - ); + ); + }); expect(result.current.suggestions).toHaveLength(0); expect(result.current.showSuggestions).toBe(false); diff --git a/packages/cli/src/ui/hooks/useCompletion.test.ts b/packages/cli/src/ui/hooks/useCompletion.test.ts index 19671de4..96e8f156 100644 --- a/packages/cli/src/ui/hooks/useCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCompletion.test.ts @@ -18,6 +18,20 @@ import { SlashCommand, } from '../commands/types.js'; import { Config, FileDiscoveryService } from '@google/gemini-cli-core'; +import { useTextBuffer } from '../components/shared/text-buffer.js'; + +// Helper to create real TextBuffer objects within renderHook +const useTextBufferForTest = (text: string) => { + const cursorOffset = text.length; + + return useTextBuffer({ + initialText: text, + initialCursorOffset: cursorOffset, + viewport: { width: 80, height: 20 }, + isValidPath: () => false, + onChange: () => {}, + }); +}; // Mock dependencies vi.mock('fs/promises'); @@ -140,16 +154,16 @@ describe('useCompletion', () => { describe('Hook initialization and state', () => { it('should initialize with default state', () => { - const { result } = renderHook(() => - useCompletion( - '', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest(''); + return useCompletion( + textBuffer, testCwd, - false, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); expect(result.current.suggestions).toEqual([]); expect(result.current.activeSuggestionIndex).toBe(-1); @@ -158,21 +172,23 @@ describe('useCompletion', () => { expect(result.current.isLoadingSuggestions).toBe(false); }); - it('should reset state when isActive becomes false', () => { + it('should reset state when query becomes inactive', () => { const { result, rerender } = renderHook( - ({ isActive }) => - useCompletion( - '/help', + ({ text }) => { + const textBuffer = useTextBufferForTest(text); + return useCompletion( + textBuffer, testCwd, - isActive, mockSlashCommands, mockCommandContext, mockConfig, - ), - { initialProps: { isActive: true } }, + ); + }, + { initialProps: { text: '/help' } }, ); - rerender({ isActive: false }); + // Inactive because of the leading space + rerender({ text: ' /help' }); expect(result.current.suggestions).toEqual([]); expect(result.current.activeSuggestionIndex).toBe(-1); @@ -182,16 +198,16 @@ describe('useCompletion', () => { }); it('should provide required functions', () => { - const { result } = renderHook(() => - useCompletion( - '', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest(''); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); expect(typeof result.current.setActiveSuggestionIndex).toBe('function'); expect(typeof result.current.setShowSuggestions).toBe('function'); @@ -203,16 +219,16 @@ describe('useCompletion', () => { describe('resetCompletionState', () => { it('should reset all state to default values', () => { - const { result } = renderHook(() => - useCompletion( - '/help', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/help'); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); act(() => { result.current.setActiveSuggestionIndex(5); @@ -233,16 +249,16 @@ describe('useCompletion', () => { describe('Navigation functions', () => { it('should handle navigateUp with no suggestions', () => { - const { result } = renderHook(() => - useCompletion( - '', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest(''); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); act(() => { result.current.navigateUp(); @@ -252,16 +268,16 @@ describe('useCompletion', () => { }); it('should handle navigateDown with no suggestions', () => { - const { result } = renderHook(() => - useCompletion( - '', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest(''); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); act(() => { result.current.navigateDown(); @@ -271,16 +287,16 @@ describe('useCompletion', () => { }); it('should navigate up through suggestions with wrap-around', () => { - const { result } = renderHook(() => - useCompletion( - '/h', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/h'); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); expect(result.current.suggestions.length).toBe(1); expect(result.current.activeSuggestionIndex).toBe(0); @@ -293,16 +309,16 @@ describe('useCompletion', () => { }); it('should navigate down through suggestions with wrap-around', () => { - const { result } = renderHook(() => - useCompletion( - '/h', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/h'); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); expect(result.current.suggestions.length).toBe(1); expect(result.current.activeSuggestionIndex).toBe(0); @@ -315,16 +331,16 @@ describe('useCompletion', () => { }); it('should handle navigation with multiple suggestions', () => { - const { result } = renderHook(() => - useCompletion( - '/', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/'); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); expect(result.current.suggestions.length).toBe(5); expect(result.current.activeSuggestionIndex).toBe(0); @@ -363,16 +379,16 @@ describe('useCompletion', () => { action: vi.fn(), })); - const { result } = renderHook(() => - useCompletion( - '/command', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/command'); + return useCompletion( + textBuffer, testCwd, - true, largeMockCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); expect(result.current.suggestions.length).toBe(15); expect(result.current.activeSuggestionIndex).toBe(0); @@ -389,16 +405,16 @@ describe('useCompletion', () => { describe('Slash command completion', () => { it('should show all commands for root slash', () => { - const { result } = renderHook(() => - useCompletion( - '/', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/'); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); expect(result.current.suggestions).toHaveLength(5); expect(result.current.suggestions.map((s) => s.label)).toEqual( @@ -409,16 +425,16 @@ describe('useCompletion', () => { }); it('should filter commands by prefix', () => { - const { result } = renderHook(() => - useCompletion( - '/h', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/h'); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); expect(result.current.suggestions).toHaveLength(1); expect(result.current.suggestions[0].label).toBe('help'); @@ -428,64 +444,64 @@ describe('useCompletion', () => { it.each([['/?'], ['/usage']])( 'should not suggest commands when altNames is fully typed', (altName) => { - const { result } = renderHook(() => - useCompletion( - altName, + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest(altName); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); expect(result.current.suggestions).toHaveLength(0); }, ); it('should suggest commands based on partial altNames matches', () => { - const { result } = renderHook(() => - useCompletion( - '/usag', // part of the word "usage" + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/usag'); // part of the word "usage" + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); expect(result.current.suggestions).toHaveLength(1); expect(result.current.suggestions[0].label).toBe('stats'); }); it('should not show suggestions for exact leaf command match', () => { - const { result } = renderHook(() => - useCompletion( - '/clear', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/clear'); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); expect(result.current.suggestions).toHaveLength(0); expect(result.current.showSuggestions).toBe(false); }); it('should show sub-commands for parent commands', () => { - const { result } = renderHook(() => - useCompletion( - '/memory', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/memory'); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); expect(result.current.suggestions).toHaveLength(2); expect(result.current.suggestions.map((s) => s.label)).toEqual( @@ -494,16 +510,16 @@ describe('useCompletion', () => { }); it('should show all sub-commands after parent command with space', () => { - const { result } = renderHook(() => - useCompletion( - '/memory ', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/memory '); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); expect(result.current.suggestions).toHaveLength(2); expect(result.current.suggestions.map((s) => s.label)).toEqual( @@ -512,32 +528,32 @@ describe('useCompletion', () => { }); it('should filter sub-commands by prefix', () => { - const { result } = renderHook(() => - useCompletion( - '/memory a', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/memory a'); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); expect(result.current.suggestions).toHaveLength(1); expect(result.current.suggestions[0].label).toBe('add'); }); it('should handle unknown command gracefully', () => { - const { result } = renderHook(() => - useCompletion( - '/unknown', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/unknown'); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); expect(result.current.suggestions).toHaveLength(0); expect(result.current.showSuggestions).toBe(false); @@ -558,16 +574,16 @@ describe('useCompletion', () => { resumeCommand.completion = completionFn; } - const { result } = renderHook(() => - useCompletion( - '/chat resume ', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/chat resume '); + return useCompletion( + textBuffer, testCwd, - true, commandsWithCompletion, mockCommandContext, mockConfig, - ), - ); + ); + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 150)); @@ -594,16 +610,16 @@ describe('useCompletion', () => { resumeCommand.completion = completionFn; } - renderHook(() => - useCompletion( - '/chat resume ar', + renderHook(() => { + const textBuffer = useTextBufferForTest('/chat resume ar'); + return useCompletion( + textBuffer, testCwd, - true, commandsWithCompletion, mockCommandContext, mockConfig, - ), - ); + ); + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 150)); @@ -625,16 +641,16 @@ describe('useCompletion', () => { resumeCommand.completion = completionFn; } - const { result } = renderHook(() => - useCompletion( - '/chat resume ', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/chat resume '); + return useCompletion( + textBuffer, testCwd, - true, commandsWithCompletion, mockCommandContext, mockConfig, - ), - ); + ); + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 150)); @@ -673,32 +689,32 @@ describe('useCompletion', () => { }); it('should suggest a namespaced command based on a partial match', () => { - const { result } = renderHook(() => - useCompletion( - '/git:co', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/git:co'); + return useCompletion( + textBuffer, testCwd, - true, commandsWithNamespaces, mockCommandContext, mockConfig, - ), - ); + ); + }); expect(result.current.suggestions).toHaveLength(1); expect(result.current.suggestions[0].label).toBe('git:commit'); }); it('should suggest all commands within a namespace when the namespace prefix is typed', () => { - const { result } = renderHook(() => - useCompletion( - '/git:', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/git:'); + return useCompletion( + textBuffer, testCwd, - true, commandsWithNamespaces, mockCommandContext, mockConfig, - ), - ); + ); + }); expect(result.current.suggestions).toHaveLength(2); expect(result.current.suggestions.map((s) => s.label)).toEqual( @@ -711,16 +727,16 @@ describe('useCompletion', () => { }); it('should not provide suggestions if the namespaced command is a perfect leaf match', () => { - const { result } = renderHook(() => - useCompletion( - '/git:commit', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/git:commit'); + return useCompletion( + textBuffer, testCwd, - true, commandsWithNamespaces, mockCommandContext, mockConfig, - ), - ); + ); + }); expect(result.current.showSuggestions).toBe(false); expect(result.current.suggestions).toHaveLength(0); @@ -738,16 +754,16 @@ describe('useCompletion', () => { }); it('should show file completions for @ prefix', async () => { - const { result } = renderHook(() => - useCompletion( - '@', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('@'); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 150)); @@ -766,16 +782,16 @@ describe('useCompletion', () => { `${testCwd}/file2.js`, ]); - const { result } = renderHook(() => - useCompletion( - '@file', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('@file'); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 150)); @@ -791,16 +807,16 @@ describe('useCompletion', () => { // Mock for recursive search since enableRecursiveFileSearch is true vi.mocked(glob).mockResolvedValue([`${testCwd}/.hidden`]); - const { result } = renderHook(() => - useCompletion( - '@.', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('@.'); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 150)); @@ -815,16 +831,16 @@ describe('useCompletion', () => { (enoentError as Error & { code: string }).code = 'ENOENT'; vi.mocked(fs.readdir).mockRejectedValue(enoentError); - const { result } = renderHook(() => - useCompletion( - '@nonexistent', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('@nonexistent'); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 150)); @@ -840,16 +856,16 @@ describe('useCompletion', () => { .mockImplementation(() => {}); vi.mocked(fs.readdir).mockRejectedValue(new Error('Permission denied')); - const { result } = renderHook(() => - useCompletion( - '@', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('@'); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 150)); @@ -870,21 +886,22 @@ describe('useCompletion', () => { vi.mocked(glob).mockResolvedValue([`${testCwd}/file1.txt`]); const { rerender } = renderHook( - ({ query }) => - useCompletion( - query, + ({ text }) => { + const textBuffer = useTextBufferForTest(text); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - { initialProps: { query: '@f' } }, + ); + }, + { initialProps: { text: '@f' } }, ); - rerender({ query: '@fi' }); - rerender({ query: '@fil' }); - rerender({ query: '@file' }); + rerender({ text: '@fi' }); + rerender({ text: '@fil' }); + rerender({ text: '@file' }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 150)); @@ -896,48 +913,48 @@ describe('useCompletion', () => { describe('Query handling edge cases', () => { it('should handle empty query', () => { - const { result } = renderHook(() => - useCompletion( - '', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest(''); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); expect(result.current.suggestions).toHaveLength(0); expect(result.current.showSuggestions).toBe(false); }); it('should handle query without slash or @', () => { - const { result } = renderHook(() => - useCompletion( - 'regular text', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('regular text'); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); expect(result.current.suggestions).toHaveLength(0); expect(result.current.showSuggestions).toBe(false); }); it('should handle query with whitespace', () => { - const { result } = renderHook(() => - useCompletion( - ' /hel', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest(' /hel'); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); expect(result.current.suggestions).toHaveLength(1); expect(result.current.suggestions[0].label).toBe('help'); @@ -947,16 +964,16 @@ describe('useCompletion', () => { // Mock for recursive search since enableRecursiveFileSearch is true vi.mocked(glob).mockResolvedValue([`${testCwd}/file1.txt`]); - const { result } = renderHook(() => - useCompletion( - 'some text @', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('some text @'); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); // Wait for completion await act(async () => { @@ -983,16 +1000,16 @@ describe('useCompletion', () => { mockFileDiscoveryService.shouldIgnoreFile.mockReturnValue(false); - const { result } = renderHook(() => - useCompletion( - '@comp', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('@comp'); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 150)); @@ -1015,22 +1032,238 @@ describe('useCompletion', () => { }); }); + describe('handleAutocomplete', () => { + it('should complete a partial command', () => { + // Create a mock buffer that we can spy on directly + const mockBuffer = { + text: '/mem', + lines: ['/mem'], + cursor: [0, 4], + preferredCol: null, + selectionAnchor: null, + allVisualLines: ['/mem'], + viewportVisualLines: ['/mem'], + visualCursor: [0, 4], + visualScrollRow: 0, + setText: vi.fn(), + insert: vi.fn(), + newline: vi.fn(), + backspace: vi.fn(), + del: vi.fn(), + move: vi.fn(), + undo: vi.fn(), + redo: vi.fn(), + replaceRange: vi.fn(), + replaceRangeByOffset: vi.fn(), + moveToOffset: vi.fn(), + deleteWordLeft: vi.fn(), + deleteWordRight: vi.fn(), + killLineRight: vi.fn(), + killLineLeft: vi.fn(), + handleInput: vi.fn(), + openInExternalEditor: vi.fn(), + }; + + const { result } = renderHook(() => + useCompletion( + mockBuffer, + testCwd, + mockSlashCommands, + mockCommandContext, + mockConfig, + ), + ); + + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'memory', + ]); + + act(() => { + result.current.handleAutocomplete(0); + }); + + expect(mockBuffer.setText).toHaveBeenCalledWith('/memory'); + }); + + it('should append a sub-command when the parent is complete', () => { + const mockBuffer = { + text: '/memory ', + lines: ['/memory '], + cursor: [0, 8], + preferredCol: null, + selectionAnchor: null, + allVisualLines: ['/memory '], + viewportVisualLines: ['/memory '], + visualCursor: [0, 8], + visualScrollRow: 0, + setText: vi.fn(), + insert: vi.fn(), + newline: vi.fn(), + backspace: vi.fn(), + del: vi.fn(), + move: vi.fn(), + undo: vi.fn(), + redo: vi.fn(), + replaceRange: vi.fn(), + replaceRangeByOffset: vi.fn(), + moveToOffset: vi.fn(), + deleteWordLeft: vi.fn(), + deleteWordRight: vi.fn(), + killLineRight: vi.fn(), + killLineLeft: vi.fn(), + handleInput: vi.fn(), + openInExternalEditor: vi.fn(), + }; + + const { result } = renderHook(() => + useCompletion( + mockBuffer, + testCwd, + mockSlashCommands, + mockCommandContext, + mockConfig, + ), + ); + + // 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(mockBuffer.setText).toHaveBeenCalledWith('/memory add'); + }); + + it('should complete a command with an alternative name', () => { + const mockBuffer = { + text: '/?', + lines: ['/?'], + cursor: [0, 2], + preferredCol: null, + selectionAnchor: null, + allVisualLines: ['/?'], + viewportVisualLines: ['/?'], + visualCursor: [0, 2], + visualScrollRow: 0, + setText: vi.fn(), + insert: vi.fn(), + newline: vi.fn(), + backspace: vi.fn(), + del: vi.fn(), + move: vi.fn(), + undo: vi.fn(), + redo: vi.fn(), + replaceRange: vi.fn(), + replaceRangeByOffset: vi.fn(), + moveToOffset: vi.fn(), + deleteWordLeft: vi.fn(), + deleteWordRight: vi.fn(), + killLineRight: vi.fn(), + killLineLeft: vi.fn(), + handleInput: vi.fn(), + openInExternalEditor: vi.fn(), + }; + + const { result } = renderHook(() => + useCompletion( + mockBuffer, + testCwd, + mockSlashCommands, + mockCommandContext, + mockConfig, + ), + ); + + result.current.suggestions.push({ + label: 'help', + value: 'help', + description: 'Show help', + }); + + act(() => { + result.current.handleAutocomplete(0); + }); + + expect(mockBuffer.setText).toHaveBeenCalledWith('/help'); + }); + + it('should complete a file path', async () => { + const mockBuffer = { + text: '@src/fi', + lines: ['@src/fi'], + cursor: [0, 7], + preferredCol: null, + selectionAnchor: null, + allVisualLines: ['@src/fi'], + viewportVisualLines: ['@src/fi'], + visualCursor: [0, 7], + visualScrollRow: 0, + setText: vi.fn(), + insert: vi.fn(), + newline: vi.fn(), + backspace: vi.fn(), + del: vi.fn(), + move: vi.fn(), + undo: vi.fn(), + redo: vi.fn(), + replaceRange: vi.fn(), + replaceRangeByOffset: vi.fn(), + moveToOffset: vi.fn(), + deleteWordLeft: vi.fn(), + deleteWordRight: vi.fn(), + killLineRight: vi.fn(), + killLineLeft: vi.fn(), + handleInput: vi.fn(), + openInExternalEditor: vi.fn(), + }; + + const { result } = renderHook(() => + useCompletion( + mockBuffer, + testCwd, + mockSlashCommands, + mockCommandContext, + mockConfig, + ), + ); + + result.current.suggestions.push({ + label: 'file1.txt', + value: 'file1.txt', + }); + + act(() => { + result.current.handleAutocomplete(0); + }); + + expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalledWith( + 5, // after '@src/' + mockBuffer.text.length, + 'file1.txt', + ); + }); + }); + describe('Config and FileDiscoveryService integration', () => { it('should work without config', async () => { vi.mocked(fs.readdir).mockResolvedValue([ { name: 'file1.txt', isDirectory: () => false }, ] as unknown as Awaited>); - const { result } = renderHook(() => - useCompletion( - '@', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('@'); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, undefined, - ), - ); + ); + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 150)); @@ -1050,16 +1283,16 @@ describe('useCompletion', () => { (path: string) => path.includes('.log'), ); - const { result } = renderHook(() => - useCompletion( - '@', + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('@'); + return useCompletion( + textBuffer, testCwd, - true, mockSlashCommands, mockCommandContext, mockConfig, - ), - ); + ); + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 150)); diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts index aacc111d..f4ebfac3 100644 --- a/packages/cli/src/ui/hooks/useCompletion.ts +++ b/packages/cli/src/ui/hooks/useCompletion.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import * as fs from 'fs/promises'; import * as path from 'path'; import { glob } from 'glob'; @@ -22,6 +22,9 @@ import { Suggestion, } from '../components/SuggestionsDisplay.js'; import { CommandContext, SlashCommand } from '../commands/types.js'; +import { TextBuffer } from '../components/shared/text-buffer.js'; +import { isSlashCommand } from '../utils/commandUtils.js'; +import { toCodePoints } from '../utils/textUtils.js'; export interface UseCompletionReturn { suggestions: Suggestion[]; @@ -35,12 +38,12 @@ export interface UseCompletionReturn { resetCompletionState: () => void; navigateUp: () => void; navigateDown: () => void; + handleAutocomplete: (indexToUse: number) => void; } export function useCompletion( - query: string, + buffer: TextBuffer, cwd: string, - isActive: boolean, slashCommands: readonly SlashCommand[], commandContext: CommandContext, config?: Config, @@ -122,13 +125,45 @@ export function useCompletion( }); }, [suggestions.length]); + // Check if cursor is after @ or / without unescaped spaces + const isActive = useMemo(() => { + if (isSlashCommand(buffer.text.trim())) { + return true; + } + + // For other completions like '@', we search backwards from the cursor. + const [row, col] = buffer.cursor; + const currentLine = buffer.lines[row] || ''; + const codePoints = toCodePoints(currentLine); + + for (let i = col - 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 false; // Inactive on unescaped space. + } + } else if (char === '@') { + // Active if we find an '@' before any unescaped space. + return true; + } + } + + return false; + }, [buffer.text, buffer.cursor, buffer.lines]); + useEffect(() => { if (!isActive) { resetCompletionState(); return; } - const trimmedQuery = query.trimStart(); + const trimmedQuery = buffer.text.trimStart(); if (trimmedQuery.startsWith('/')) { // Always reset perfect match at the beginning of processing. @@ -275,13 +310,13 @@ export function useCompletion( } // Handle At Command Completion - const atIndex = query.lastIndexOf('@'); + const atIndex = buffer.text.lastIndexOf('@'); if (atIndex === -1) { resetCompletionState(); return; } - const partialPath = query.substring(atIndex + 1); + const partialPath = buffer.text.substring(atIndex + 1); const lastSlashIndex = partialPath.lastIndexOf('/'); const baseDirRelative = lastSlashIndex === -1 @@ -545,7 +580,7 @@ export function useCompletion( clearTimeout(debounceTimeout); }; }, [ - query, + buffer.text, cwd, isActive, resetCompletionState, @@ -554,6 +589,77 @@ export function useCompletion( config, ]); + const handleAutocomplete = useCallback( + (indexToUse: number) => { + if (indexToUse < 0 || indexToUse >= suggestions.length) { + return; + } + const query = buffer.text; + const suggestion = suggestions[indexToUse].value; + + if (query.trimStart().startsWith('/')) { + const hasTrailingSpace = query.endsWith(' '); + const parts = query + .trimStart() + .substring(1) + .split(/\s+/) + .filter(Boolean); + + let isParentPath = false; + // If there's no trailing space, we need to check if the current query + // is already a complete path to a parent command. + if (!hasTrailingSpace) { + let currentLevel: readonly SlashCommand[] | undefined = slashCommands; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const found: SlashCommand | undefined = currentLevel?.find( + (cmd) => cmd.name === part || cmd.altNames?.includes(part), + ); + + if (found) { + if (i === parts.length - 1 && found.subCommands) { + isParentPath = true; + } + currentLevel = found.subCommands as + | readonly SlashCommand[] + | undefined; + } else { + // Path is invalid, so it can't be a parent path. + currentLevel = undefined; + break; + } + } + } + + // Determine the base path of the command. + // - If there's a trailing space, the whole command is the base. + // - If it's a known parent path, the whole command is the base. + // - Otherwise, the base is everything EXCEPT the last partial part. + const basePath = + hasTrailingSpace || isParentPath ? parts : parts.slice(0, -1); + const newValue = `/${[...basePath, suggestion].join(' ')}`; + + buffer.setText(newValue); + } else { + const atIndex = query.lastIndexOf('@'); + if (atIndex === -1) return; + const pathPart = query.substring(atIndex + 1); + const lastSlashIndexInPath = pathPart.lastIndexOf('/'); + let autoCompleteStartIndex = atIndex + 1; + if (lastSlashIndexInPath !== -1) { + autoCompleteStartIndex += lastSlashIndexInPath + 1; + } + buffer.replaceRangeByOffset( + autoCompleteStartIndex, + buffer.text.length, + suggestion, + ); + } + resetCompletionState(); + }, + [resetCompletionState, buffer, suggestions, slashCommands], + ); + return { suggestions, activeSuggestionIndex, @@ -566,5 +672,6 @@ export function useCompletion( resetCompletionState, navigateUp, navigateDown, + handleAutocomplete, }; }