diff options
Diffstat (limited to 'packages/cli/src/ui/components')
| -rw-r--r-- | packages/cli/src/ui/components/InputPrompt.test.tsx | 91 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/InputPrompt.tsx | 124 |
2 files changed, 57 insertions, 158 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(<InputPrompt {...props} />); + 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(<InputPrompt {...props} />); 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(<InputPrompt {...props} />); 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<InputPromptProps> = ({ }) => { 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<InputPromptProps> = ({ 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<InputPromptProps> = ({ ? 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<InputPromptProps> = ({ setShellModeActive, onClearScreen, inputHistory, - handleAutocomplete, handleSubmitAndClear, shellHistory, handleClipboardImage, |
