improve command completion trigger logic based on cursor position (#4462)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
parent
b5f5ea2c31
commit
4915050ad4
|
@ -570,4 +570,383 @@ describe('InputPrompt', () => {
|
||||||
expect(props.buffer.setText).not.toHaveBeenCalled();
|
expect(props.buffer.setText).not.toHaveBeenCalled();
|
||||||
unmount();
|
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(<InputPrompt {...props} />);
|
||||||
|
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(<InputPrompt {...props} />);
|
||||||
|
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(<InputPrompt {...props} />);
|
||||||
|
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(<InputPrompt {...props} />);
|
||||||
|
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(<InputPrompt {...props} />);
|
||||||
|
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(<InputPrompt {...props} />);
|
||||||
|
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(<InputPrompt {...props} />);
|
||||||
|
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(<InputPrompt {...props} />);
|
||||||
|
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(<InputPrompt {...props} />);
|
||||||
|
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(<InputPrompt {...props} />);
|
||||||
|
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(<InputPrompt {...props} />);
|
||||||
|
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(<InputPrompt {...props} />);
|
||||||
|
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(<InputPrompt {...props} />);
|
||||||
|
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(<InputPrompt {...props} />);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||||
|
'@files/emoji\\ 👍\\ test.txt',
|
||||||
|
'/test/project/src',
|
||||||
|
true, // shouldShowCompletion should be true
|
||||||
|
mockSlashCommands,
|
||||||
|
mockCommandContext,
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { Colors } from '../colors.js';
|
||||||
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
|
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
|
||||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||||
import { TextBuffer } from './shared/text-buffer.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 chalk from 'chalk';
|
||||||
import stringWidth from 'string-width';
|
import stringWidth from 'string-width';
|
||||||
import { useShellHistory } from '../hooks/useShellHistory.js';
|
import { useShellHistory } from '../hooks/useShellHistory.js';
|
||||||
|
@ -58,10 +58,54 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
setShellModeActive,
|
setShellModeActive,
|
||||||
}) => {
|
}) => {
|
||||||
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
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(
|
const completion = useCompletion(
|
||||||
buffer.text,
|
buffer.text,
|
||||||
config.getTargetDir(),
|
config.getTargetDir(),
|
||||||
isAtCommand(buffer.text) || isSlashCommand(buffer.text),
|
shouldShowCompletion(),
|
||||||
slashCommands,
|
slashCommands,
|
||||||
commandContext,
|
commandContext,
|
||||||
config,
|
config,
|
||||||
|
|
Loading…
Reference in New Issue