diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 1a100c36..6b201901 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -570,4 +570,383 @@ describe('InputPrompt', () => { expect(props.buffer.setText).not.toHaveBeenCalled(); unmount(); }); + + describe('cursor-based completion trigger', () => { + it('should trigger completion when cursor is after @ without spaces', async () => { + // Set up buffer state + mockBuffer.text = '@src/components'; + mockBuffer.lines = ['@src/components']; + mockBuffer.cursor = [0, 15]; + + mockedUseCompletion.mockReturnValue({ + ...mockCompletion, + showSuggestions: true, + suggestions: [{ label: 'Button.tsx', value: 'Button.tsx' }], + }); + + const { unmount } = render(); + await wait(); + + // Verify useCompletion was called with true (should show completion) + expect(mockedUseCompletion).toHaveBeenCalledWith( + '@src/components', + '/test/project/src', + true, // shouldShowCompletion should be true + mockSlashCommands, + mockCommandContext, + expect.any(Object), + ); + + unmount(); + }); + + it('should trigger completion when cursor is after / without spaces', async () => { + mockBuffer.text = '/memory'; + mockBuffer.lines = ['/memory']; + mockBuffer.cursor = [0, 7]; + + mockedUseCompletion.mockReturnValue({ + ...mockCompletion, + showSuggestions: true, + suggestions: [{ label: 'show', value: 'show' }], + }); + + const { unmount } = render(); + await wait(); + + expect(mockedUseCompletion).toHaveBeenCalledWith( + '/memory', + '/test/project/src', + true, // shouldShowCompletion should be true + mockSlashCommands, + mockCommandContext, + expect.any(Object), + ); + + unmount(); + }); + + it('should NOT trigger completion when cursor is after space following @', async () => { + mockBuffer.text = '@src/file.ts hello'; + mockBuffer.lines = ['@src/file.ts hello']; + mockBuffer.cursor = [0, 18]; + + mockedUseCompletion.mockReturnValue({ + ...mockCompletion, + showSuggestions: false, + suggestions: [], + }); + + const { unmount } = render(); + await wait(); + + expect(mockedUseCompletion).toHaveBeenCalledWith( + '@src/file.ts hello', + '/test/project/src', + false, // shouldShowCompletion should be false + mockSlashCommands, + mockCommandContext, + expect.any(Object), + ); + + unmount(); + }); + + it('should NOT trigger completion when cursor is after space following /', async () => { + mockBuffer.text = '/memory add'; + mockBuffer.lines = ['/memory add']; + mockBuffer.cursor = [0, 11]; + + mockedUseCompletion.mockReturnValue({ + ...mockCompletion, + showSuggestions: false, + suggestions: [], + }); + + const { unmount } = render(); + await wait(); + + expect(mockedUseCompletion).toHaveBeenCalledWith( + '/memory add', + '/test/project/src', + false, // shouldShowCompletion should be false + mockSlashCommands, + mockCommandContext, + expect.any(Object), + ); + + unmount(); + }); + + it('should NOT trigger completion when cursor is not after @ or /', async () => { + mockBuffer.text = 'hello world'; + mockBuffer.lines = ['hello world']; + mockBuffer.cursor = [0, 5]; + + mockedUseCompletion.mockReturnValue({ + ...mockCompletion, + showSuggestions: false, + suggestions: [], + }); + + const { unmount } = render(); + await wait(); + + expect(mockedUseCompletion).toHaveBeenCalledWith( + 'hello world', + '/test/project/src', + false, // shouldShowCompletion should be false + mockSlashCommands, + mockCommandContext, + expect.any(Object), + ); + + unmount(); + }); + + it('should handle multiline text correctly', async () => { + mockBuffer.text = 'first line\n/memory'; + mockBuffer.lines = ['first line', '/memory']; + mockBuffer.cursor = [1, 7]; + + mockedUseCompletion.mockReturnValue({ + ...mockCompletion, + showSuggestions: false, + suggestions: [], + }); + + const { unmount } = render(); + await wait(); + + expect(mockedUseCompletion).toHaveBeenCalledWith( + 'first line\n/memory', + '/test/project/src', + false, // shouldShowCompletion should be false (isSlashCommand returns false because text doesn't start with /) + mockSlashCommands, + mockCommandContext, + expect.any(Object), + ); + + unmount(); + }); + + it('should handle single line slash command correctly', async () => { + mockBuffer.text = '/memory'; + mockBuffer.lines = ['/memory']; + mockBuffer.cursor = [0, 7]; + + mockedUseCompletion.mockReturnValue({ + ...mockCompletion, + showSuggestions: true, + suggestions: [{ label: 'show', value: 'show' }], + }); + + const { unmount } = render(); + await wait(); + + expect(mockedUseCompletion).toHaveBeenCalledWith( + '/memory', + '/test/project/src', + true, // shouldShowCompletion should be true (isSlashCommand returns true AND cursor is after / without space) + mockSlashCommands, + mockCommandContext, + expect.any(Object), + ); + + unmount(); + }); + + it('should handle Unicode characters (emojis) correctly in paths', async () => { + // Test with emoji in path after @ + mockBuffer.text = '@src/file๐Ÿ‘.txt'; + mockBuffer.lines = ['@src/file๐Ÿ‘.txt']; + mockBuffer.cursor = [0, 14]; // After the emoji character + + mockedUseCompletion.mockReturnValue({ + ...mockCompletion, + showSuggestions: true, + suggestions: [{ label: 'file๐Ÿ‘.txt', value: 'file๐Ÿ‘.txt' }], + }); + + const { unmount } = render(); + await wait(); + + expect(mockedUseCompletion).toHaveBeenCalledWith( + '@src/file๐Ÿ‘.txt', + '/test/project/src', + true, // shouldShowCompletion should be true + mockSlashCommands, + mockCommandContext, + expect.any(Object), + ); + + unmount(); + }); + + it('should handle Unicode characters with spaces after them', async () => { + // Test with emoji followed by space - should NOT trigger completion + mockBuffer.text = '@src/file๐Ÿ‘.txt hello'; + mockBuffer.lines = ['@src/file๐Ÿ‘.txt hello']; + mockBuffer.cursor = [0, 20]; // After the space + + mockedUseCompletion.mockReturnValue({ + ...mockCompletion, + showSuggestions: false, + suggestions: [], + }); + + const { unmount } = render(); + await wait(); + + expect(mockedUseCompletion).toHaveBeenCalledWith( + '@src/file๐Ÿ‘.txt hello', + '/test/project/src', + false, // shouldShowCompletion should be false + mockSlashCommands, + mockCommandContext, + expect.any(Object), + ); + + unmount(); + }); + + it('should handle escaped spaces in paths correctly', async () => { + // Test with escaped space in path - should trigger completion + mockBuffer.text = '@src/my\\ file.txt'; + mockBuffer.lines = ['@src/my\\ file.txt']; + mockBuffer.cursor = [0, 16]; // After the escaped space and filename + + mockedUseCompletion.mockReturnValue({ + ...mockCompletion, + showSuggestions: true, + suggestions: [{ label: 'my file.txt', value: 'my file.txt' }], + }); + + const { unmount } = render(); + await wait(); + + expect(mockedUseCompletion).toHaveBeenCalledWith( + '@src/my\\ file.txt', + '/test/project/src', + true, // shouldShowCompletion should be true + mockSlashCommands, + mockCommandContext, + expect.any(Object), + ); + + unmount(); + }); + + it('should NOT trigger completion after unescaped space following escaped space', async () => { + // Test: @path/my\ file.txt hello (unescaped space after escaped space) + mockBuffer.text = '@path/my\\ file.txt hello'; + mockBuffer.lines = ['@path/my\\ file.txt hello']; + mockBuffer.cursor = [0, 24]; // After "hello" + + mockedUseCompletion.mockReturnValue({ + ...mockCompletion, + showSuggestions: false, + suggestions: [], + }); + + const { unmount } = render(); + await wait(); + + expect(mockedUseCompletion).toHaveBeenCalledWith( + '@path/my\\ file.txt hello', + '/test/project/src', + false, // shouldShowCompletion should be false + mockSlashCommands, + mockCommandContext, + expect.any(Object), + ); + + unmount(); + }); + + it('should handle multiple escaped spaces in paths', async () => { + // Test with multiple escaped spaces + mockBuffer.text = '@docs/my\\ long\\ file\\ name.md'; + mockBuffer.lines = ['@docs/my\\ long\\ file\\ name.md']; + mockBuffer.cursor = [0, 29]; // At the end + + mockedUseCompletion.mockReturnValue({ + ...mockCompletion, + showSuggestions: true, + suggestions: [ + { label: 'my long file name.md', value: 'my long file name.md' }, + ], + }); + + const { unmount } = render(); + await wait(); + + expect(mockedUseCompletion).toHaveBeenCalledWith( + '@docs/my\\ long\\ file\\ name.md', + '/test/project/src', + true, // shouldShowCompletion should be true + mockSlashCommands, + mockCommandContext, + expect.any(Object), + ); + + unmount(); + }); + + it('should handle escaped spaces in slash commands', async () => { + // Test escaped spaces with slash commands (though less common) + mockBuffer.text = '/memory\\ test'; + mockBuffer.lines = ['/memory\\ test']; + mockBuffer.cursor = [0, 13]; // At the end + + mockedUseCompletion.mockReturnValue({ + ...mockCompletion, + showSuggestions: true, + suggestions: [{ label: 'test-command', value: 'test-command' }], + }); + + const { unmount } = render(); + await wait(); + + expect(mockedUseCompletion).toHaveBeenCalledWith( + '/memory\\ test', + '/test/project/src', + true, // shouldShowCompletion should be true + mockSlashCommands, + mockCommandContext, + expect.any(Object), + ); + + unmount(); + }); + + it('should handle Unicode characters with escaped spaces', async () => { + // Test combining Unicode and escaped spaces + mockBuffer.text = '@files/emoji\\ ๐Ÿ‘\\ test.txt'; + mockBuffer.lines = ['@files/emoji\\ ๐Ÿ‘\\ test.txt']; + mockBuffer.cursor = [0, 25]; // After the escaped space and emoji + + mockedUseCompletion.mockReturnValue({ + ...mockCompletion, + showSuggestions: true, + suggestions: [ + { label: 'emoji ๐Ÿ‘ test.txt', value: 'emoji ๐Ÿ‘ test.txt' }, + ], + }); + + const { unmount } = render(); + await wait(); + + expect(mockedUseCompletion).toHaveBeenCalledWith( + '@files/emoji\\ ๐Ÿ‘\\ test.txt', + '/test/project/src', + true, // shouldShowCompletion should be true + mockSlashCommands, + mockCommandContext, + expect.any(Object), + ); + + unmount(); + }); + }); }); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 4d66b10c..46326431 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -10,7 +10,7 @@ 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 } from '../utils/textUtils.js'; +import { cpSlice, cpLen, toCodePoints } from '../utils/textUtils.js'; import chalk from 'chalk'; import stringWidth from 'string-width'; import { useShellHistory } from '../hooks/useShellHistory.js'; @@ -58,10 +58,54 @@ export const InputPrompt: React.FC = ({ setShellModeActive, }) => { 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, config.getTargetDir(), - isAtCommand(buffer.text) || isSlashCommand(buffer.text), + shouldShowCompletion(), slashCommands, commandContext, config,