[Refactor] Centralizes autocompletion logic within useCompletion (#4740)
This commit is contained in:
parent
273e74c09d
commit
1d7eb0d250
|
@ -121,6 +121,15 @@ describe('InputPrompt', () => {
|
||||||
openInExternalEditor: vi.fn(),
|
openInExternalEditor: vi.fn(),
|
||||||
newline: vi.fn(),
|
newline: vi.fn(),
|
||||||
backspace: 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;
|
} as unknown as TextBuffer;
|
||||||
|
|
||||||
mockShellHistory = {
|
mockShellHistory = {
|
||||||
|
@ -137,12 +146,14 @@ describe('InputPrompt', () => {
|
||||||
isLoadingSuggestions: false,
|
isLoadingSuggestions: false,
|
||||||
showSuggestions: false,
|
showSuggestions: false,
|
||||||
visibleStartIndex: 0,
|
visibleStartIndex: 0,
|
||||||
|
isPerfectMatch: false,
|
||||||
navigateUp: vi.fn(),
|
navigateUp: vi.fn(),
|
||||||
navigateDown: vi.fn(),
|
navigateDown: vi.fn(),
|
||||||
resetCompletionState: vi.fn(),
|
resetCompletionState: vi.fn(),
|
||||||
setActiveSuggestionIndex: vi.fn(),
|
setActiveSuggestionIndex: vi.fn(),
|
||||||
setShowSuggestions: vi.fn(),
|
setShowSuggestions: vi.fn(),
|
||||||
} as unknown as UseCompletionReturn;
|
handleAutocomplete: vi.fn(),
|
||||||
|
};
|
||||||
mockedUseCompletion.mockReturnValue(mockCompletion);
|
mockedUseCompletion.mockReturnValue(mockCompletion);
|
||||||
|
|
||||||
mockInputHistory = {
|
mockInputHistory = {
|
||||||
|
@ -465,7 +476,7 @@ describe('InputPrompt', () => {
|
||||||
stdin.write('\t'); // Press Tab
|
stdin.write('\t'); // Press Tab
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory');
|
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -488,7 +499,7 @@ describe('InputPrompt', () => {
|
||||||
stdin.write('\t'); // Press Tab
|
stdin.write('\t'); // Press Tab
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory add');
|
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(1);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -513,7 +524,7 @@ describe('InputPrompt', () => {
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
// It should NOT become '/show'. It should correctly become '/memory show'.
|
// It should NOT become '/show'. It should correctly become '/memory show'.
|
||||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory show');
|
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -533,7 +544,7 @@ describe('InputPrompt', () => {
|
||||||
stdin.write('\t'); // Press Tab
|
stdin.write('\t'); // Press Tab
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
expect(props.buffer.setText).toHaveBeenCalledWith('/chat resume fix-foo');
|
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -553,7 +564,7 @@ describe('InputPrompt', () => {
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
// The app should autocomplete the text, NOT submit.
|
// The app should autocomplete the text, NOT submit.
|
||||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory');
|
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||||
|
|
||||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||||
unmount();
|
unmount();
|
||||||
|
@ -583,7 +594,7 @@ describe('InputPrompt', () => {
|
||||||
stdin.write('\t'); // Press Tab for autocomplete
|
stdin.write('\t'); // Press Tab for autocomplete
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
expect(props.buffer.setText).toHaveBeenCalledWith('/help');
|
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -600,10 +611,29 @@ describe('InputPrompt', () => {
|
||||||
unmount();
|
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 () => {
|
it('should submit directly on Enter when a complete leaf command is typed', async () => {
|
||||||
mockedUseCompletion.mockReturnValue({
|
mockedUseCompletion.mockReturnValue({
|
||||||
...mockCompletion,
|
...mockCompletion,
|
||||||
showSuggestions: false,
|
showSuggestions: false,
|
||||||
|
isPerfectMatch: false, // Added explicit isPerfectMatch false
|
||||||
});
|
});
|
||||||
props.buffer.setText('/clear');
|
props.buffer.setText('/clear');
|
||||||
|
|
||||||
|
@ -632,7 +662,7 @@ describe('InputPrompt', () => {
|
||||||
stdin.write('\r');
|
stdin.write('\r');
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
expect(props.buffer.replaceRangeByOffset).toHaveBeenCalled();
|
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
@ -697,11 +727,10 @@ describe('InputPrompt', () => {
|
||||||
const { unmount } = render(<InputPrompt {...props} />);
|
const { unmount } = render(<InputPrompt {...props} />);
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
// Verify useCompletion was called with true (should show completion)
|
// Verify useCompletion was called with correct signature
|
||||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||||
'@src/components',
|
mockBuffer,
|
||||||
path.join('test', 'project', 'src'),
|
path.join('test', 'project', 'src'),
|
||||||
true, // shouldShowCompletion should be true
|
|
||||||
mockSlashCommands,
|
mockSlashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
|
@ -725,9 +754,8 @@ describe('InputPrompt', () => {
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||||
'/memory',
|
mockBuffer,
|
||||||
path.join('test', 'project', 'src'),
|
path.join('test', 'project', 'src'),
|
||||||
true, // shouldShowCompletion should be true
|
|
||||||
mockSlashCommands,
|
mockSlashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
|
@ -751,9 +779,8 @@ describe('InputPrompt', () => {
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||||
'@src/file.ts hello',
|
mockBuffer,
|
||||||
path.join('test', 'project', 'src'),
|
path.join('test', 'project', 'src'),
|
||||||
false, // shouldShowCompletion should be false
|
|
||||||
mockSlashCommands,
|
mockSlashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
|
@ -777,9 +804,8 @@ describe('InputPrompt', () => {
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||||
'/memory add',
|
mockBuffer,
|
||||||
path.join('test', 'project', 'src'),
|
path.join('test', 'project', 'src'),
|
||||||
false, // shouldShowCompletion should be false
|
|
||||||
mockSlashCommands,
|
mockSlashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
|
@ -803,9 +829,8 @@ describe('InputPrompt', () => {
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||||
'hello world',
|
mockBuffer,
|
||||||
path.join('test', 'project', 'src'),
|
path.join('test', 'project', 'src'),
|
||||||
false, // shouldShowCompletion should be false
|
|
||||||
mockSlashCommands,
|
mockSlashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
|
@ -828,10 +853,10 @@ describe('InputPrompt', () => {
|
||||||
const { unmount } = render(<InputPrompt {...props} />);
|
const { unmount } = render(<InputPrompt {...props} />);
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
|
// Verify useCompletion was called with the buffer
|
||||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||||
'first line\n/memory',
|
mockBuffer,
|
||||||
path.join('test', 'project', 'src'),
|
path.join('test', 'project', 'src'),
|
||||||
false, // shouldShowCompletion should be false (isSlashCommand returns false because text doesn't start with /)
|
|
||||||
mockSlashCommands,
|
mockSlashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
|
@ -855,9 +880,8 @@ describe('InputPrompt', () => {
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||||
'/memory',
|
mockBuffer,
|
||||||
path.join('test', 'project', 'src'),
|
path.join('test', 'project', 'src'),
|
||||||
true, // shouldShowCompletion should be true (isSlashCommand returns true AND cursor is after / without space)
|
|
||||||
mockSlashCommands,
|
mockSlashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
|
@ -882,9 +906,8 @@ describe('InputPrompt', () => {
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||||
'@src/file👍.txt',
|
mockBuffer,
|
||||||
path.join('test', 'project', 'src'),
|
path.join('test', 'project', 'src'),
|
||||||
true, // shouldShowCompletion should be true
|
|
||||||
mockSlashCommands,
|
mockSlashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
|
@ -909,9 +932,8 @@ describe('InputPrompt', () => {
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||||
'@src/file👍.txt hello',
|
mockBuffer,
|
||||||
path.join('test', 'project', 'src'),
|
path.join('test', 'project', 'src'),
|
||||||
false, // shouldShowCompletion should be false
|
|
||||||
mockSlashCommands,
|
mockSlashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
|
@ -936,9 +958,8 @@ describe('InputPrompt', () => {
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||||
'@src/my\\ file.txt',
|
mockBuffer,
|
||||||
path.join('test', 'project', 'src'),
|
path.join('test', 'project', 'src'),
|
||||||
true, // shouldShowCompletion should be true
|
|
||||||
mockSlashCommands,
|
mockSlashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
|
@ -963,9 +984,8 @@ describe('InputPrompt', () => {
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||||
'@path/my\\ file.txt hello',
|
mockBuffer,
|
||||||
path.join('test', 'project', 'src'),
|
path.join('test', 'project', 'src'),
|
||||||
false, // shouldShowCompletion should be false
|
|
||||||
mockSlashCommands,
|
mockSlashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
|
@ -992,9 +1012,8 @@ describe('InputPrompt', () => {
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||||
'@docs/my\\ long\\ file\\ name.md',
|
mockBuffer,
|
||||||
path.join('test', 'project', 'src'),
|
path.join('test', 'project', 'src'),
|
||||||
true, // shouldShowCompletion should be true
|
|
||||||
mockSlashCommands,
|
mockSlashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
|
@ -1019,9 +1038,8 @@ describe('InputPrompt', () => {
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||||
'/memory\\ test',
|
mockBuffer,
|
||||||
path.join('test', 'project', 'src'),
|
path.join('test', 'project', 'src'),
|
||||||
true, // shouldShowCompletion should be true
|
|
||||||
mockSlashCommands,
|
mockSlashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
|
@ -1048,9 +1066,8 @@ describe('InputPrompt', () => {
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||||
'@' + path.join('files', 'emoji\\ 👍\\ test.txt'),
|
mockBuffer,
|
||||||
path.join('test', 'project', 'src'),
|
path.join('test', 'project', 'src'),
|
||||||
true, // shouldShowCompletion should be true
|
|
||||||
mockSlashCommands,
|
mockSlashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
|
|
|
@ -10,13 +10,12 @@ 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, toCodePoints } from '../utils/textUtils.js';
|
import { cpSlice, cpLen } 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';
|
||||||
import { useCompletion } from '../hooks/useCompletion.js';
|
import { useCompletion } from '../hooks/useCompletion.js';
|
||||||
import { useKeypress, Key } from '../hooks/useKeypress.js';
|
import { useKeypress, Key } from '../hooks/useKeypress.js';
|
||||||
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
|
|
||||||
import { CommandContext, SlashCommand } from '../commands/types.js';
|
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||||
import { Config } from '@google/gemini-cli-core';
|
import { Config } from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
|
@ -59,53 +58,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
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,
|
||||||
config.getTargetDir(),
|
config.getTargetDir(),
|
||||||
shouldShowCompletion(),
|
|
||||||
slashCommands,
|
slashCommands,
|
||||||
commandContext,
|
commandContext,
|
||||||
config,
|
config,
|
||||||
|
@ -159,78 +114,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
setJustNavigatedHistory,
|
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
|
// Handle clipboard image pasting with Ctrl+V
|
||||||
const handleClipboardImage = useCallback(async () => {
|
const handleClipboardImage = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
@ -337,7 +220,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
? 0 // Default to the first if none is active
|
? 0 // Default to the first if none is active
|
||||||
: completion.activeSuggestionIndex;
|
: completion.activeSuggestionIndex;
|
||||||
if (targetIndex < completion.suggestions.length) {
|
if (targetIndex < completion.suggestions.length) {
|
||||||
handleAutocomplete(targetIndex);
|
completion.handleAutocomplete(targetIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
@ -459,7 +342,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
setShellModeActive,
|
setShellModeActive,
|
||||||
onClearScreen,
|
onClearScreen,
|
||||||
inputHistory,
|
inputHistory,
|
||||||
handleAutocomplete,
|
|
||||||
handleSubmitAndClear,
|
handleSubmitAndClear,
|
||||||
shellHistory,
|
shellHistory,
|
||||||
handleClipboardImage,
|
handleClipboardImage,
|
||||||
|
|
|
@ -16,6 +16,7 @@ import {
|
||||||
SlashCommand,
|
SlashCommand,
|
||||||
} from '../commands/types.js';
|
} from '../commands/types.js';
|
||||||
import { Config, FileDiscoveryService } from '@google/gemini-cli-core';
|
import { Config, FileDiscoveryService } from '@google/gemini-cli-core';
|
||||||
|
import { useTextBuffer } from '../components/shared/text-buffer.js';
|
||||||
|
|
||||||
interface MockConfig {
|
interface MockConfig {
|
||||||
getFileFilteringOptions: () => {
|
getFileFilteringOptions: () => {
|
||||||
|
@ -26,6 +27,19 @@ interface MockConfig {
|
||||||
getFileService: () => FileDiscoveryService | null;
|
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
|
// Mock dependencies
|
||||||
vi.mock('fs/promises');
|
vi.mock('fs/promises');
|
||||||
vi.mock('@google/gemini-cli-core', async () => {
|
vi.mock('@google/gemini-cli-core', async () => {
|
||||||
|
@ -183,16 +197,16 @@ describe('useCompletion git-aware filtering integration', () => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() => {
|
||||||
useCompletion(
|
const textBuffer = useTextBufferForTest('@d');
|
||||||
'@d',
|
return useCompletion(
|
||||||
|
textBuffer,
|
||||||
testCwd,
|
testCwd,
|
||||||
true,
|
|
||||||
slashCommands,
|
slashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
mockConfig as Config,
|
mockConfig as Config,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Wait for async operations to complete
|
// Wait for async operations to complete
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
@ -241,16 +255,16 @@ describe('useCompletion git-aware filtering integration', () => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() => {
|
||||||
useCompletion(
|
const textBuffer = useTextBufferForTest('@');
|
||||||
'@',
|
return useCompletion(
|
||||||
|
textBuffer,
|
||||||
testCwd,
|
testCwd,
|
||||||
true,
|
|
||||||
slashCommands,
|
slashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
mockConfig as Config,
|
mockConfig as Config,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Wait for async operations to complete
|
// Wait for async operations to complete
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
@ -323,16 +337,16 @@ describe('useCompletion git-aware filtering integration', () => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() => {
|
||||||
useCompletion(
|
const textBuffer = useTextBufferForTest('@t');
|
||||||
'@t',
|
return useCompletion(
|
||||||
|
textBuffer,
|
||||||
testCwd,
|
testCwd,
|
||||||
true,
|
|
||||||
slashCommands,
|
slashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
mockConfig as Config,
|
mockConfig as Config,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Wait for async operations to complete
|
// Wait for async operations to complete
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
@ -362,16 +376,16 @@ describe('useCompletion git-aware filtering integration', () => {
|
||||||
{ name: 'dist', isDirectory: () => true },
|
{ name: 'dist', isDirectory: () => true },
|
||||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||||
|
|
||||||
renderHook(() =>
|
renderHook(() => {
|
||||||
useCompletion(
|
const textBuffer = useTextBufferForTest('@d');
|
||||||
'@d',
|
return useCompletion(
|
||||||
|
textBuffer,
|
||||||
testCwd,
|
testCwd,
|
||||||
true,
|
|
||||||
slashCommands,
|
slashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
mockConfigNoRecursive,
|
mockConfigNoRecursive,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
@ -390,22 +404,21 @@ describe('useCompletion git-aware filtering integration', () => {
|
||||||
{ name: 'README.md', isDirectory: () => false },
|
{ name: 'README.md', isDirectory: () => false },
|
||||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() => {
|
||||||
useCompletion(
|
const textBuffer = useTextBufferForTest('@');
|
||||||
'@',
|
return useCompletion(
|
||||||
|
textBuffer,
|
||||||
testCwd,
|
testCwd,
|
||||||
true,
|
|
||||||
slashCommands,
|
slashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
undefined,
|
undefined,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Without config, should include all files
|
|
||||||
expect(result.current.suggestions).toHaveLength(3);
|
expect(result.current.suggestions).toHaveLength(3);
|
||||||
expect(result.current.suggestions).toEqual(
|
expect(result.current.suggestions).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
|
@ -424,16 +437,16 @@ describe('useCompletion git-aware filtering integration', () => {
|
||||||
|
|
||||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() => {
|
||||||
useCompletion(
|
const textBuffer = useTextBufferForTest('@');
|
||||||
'@',
|
return useCompletion(
|
||||||
|
textBuffer,
|
||||||
testCwd,
|
testCwd,
|
||||||
true,
|
|
||||||
slashCommands,
|
slashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
mockConfig as Config,
|
mockConfig as Config,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
@ -470,16 +483,16 @@ describe('useCompletion git-aware filtering integration', () => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() => {
|
||||||
useCompletion(
|
const textBuffer = useTextBufferForTest('@src/comp');
|
||||||
'@src/comp',
|
return useCompletion(
|
||||||
|
textBuffer,
|
||||||
testCwd,
|
testCwd,
|
||||||
true,
|
|
||||||
slashCommands,
|
slashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
mockConfig as Config,
|
mockConfig as Config,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
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`];
|
const globResults = [`${testCwd}/src/index.ts`, `${testCwd}/README.md`];
|
||||||
vi.mocked(glob).mockResolvedValue(globResults);
|
vi.mocked(glob).mockResolvedValue(globResults);
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() => {
|
||||||
useCompletion(
|
const textBuffer = useTextBufferForTest('@s');
|
||||||
'@s',
|
return useCompletion(
|
||||||
|
textBuffer,
|
||||||
testCwd,
|
testCwd,
|
||||||
true,
|
|
||||||
slashCommands,
|
slashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
mockConfig as Config,
|
mockConfig as Config,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
@ -530,16 +543,16 @@ describe('useCompletion git-aware filtering integration', () => {
|
||||||
];
|
];
|
||||||
vi.mocked(glob).mockResolvedValue(globResults);
|
vi.mocked(glob).mockResolvedValue(globResults);
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() => {
|
||||||
useCompletion(
|
const textBuffer = useTextBufferForTest('@.');
|
||||||
'@.',
|
return useCompletion(
|
||||||
|
textBuffer,
|
||||||
testCwd,
|
testCwd,
|
||||||
true,
|
|
||||||
slashCommands,
|
slashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
mockConfig as Config,
|
mockConfig as Config,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
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 () => {
|
it('should suggest top-level command names based on partial input', async () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() => {
|
||||||
useCompletion(
|
const textBuffer = useTextBufferForTest('/mem');
|
||||||
'/mem',
|
return useCompletion(
|
||||||
|
textBuffer,
|
||||||
'/test/cwd',
|
'/test/cwd',
|
||||||
true,
|
|
||||||
mockSlashCommands,
|
mockSlashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
expect(result.current.suggestions).toEqual([
|
expect(result.current.suggestions).toEqual([
|
||||||
{ label: 'memory', value: 'memory', description: 'Manage memory' },
|
{ label: 'memory', value: 'memory', description: 'Manage memory' },
|
||||||
|
@ -578,30 +591,30 @@ describe('useCompletion git-aware filtering integration', () => {
|
||||||
it.each([['/?'], ['/usage']])(
|
it.each([['/?'], ['/usage']])(
|
||||||
'should not suggest commands when altNames is fully typed',
|
'should not suggest commands when altNames is fully typed',
|
||||||
async (altName) => {
|
async (altName) => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() => {
|
||||||
useCompletion(
|
const textBuffer = useTextBufferForTest(altName);
|
||||||
altName,
|
return useCompletion(
|
||||||
|
textBuffer,
|
||||||
'/test/cwd',
|
'/test/cwd',
|
||||||
true,
|
|
||||||
mockSlashCommands,
|
mockSlashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
expect(result.current.suggestions).toHaveLength(0);
|
expect(result.current.suggestions).toHaveLength(0);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it('should suggest commands based on partial altNames matches', async () => {
|
it('should suggest commands based on partial altNames matches', async () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() => {
|
||||||
useCompletion(
|
const textBuffer = useTextBufferForTest('/usag'); // part of the word "usage"
|
||||||
'/usag', // part of the word "usage"
|
return useCompletion(
|
||||||
|
textBuffer,
|
||||||
'/test/cwd',
|
'/test/cwd',
|
||||||
true,
|
|
||||||
mockSlashCommands,
|
mockSlashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
expect(result.current.suggestions).toEqual([
|
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 () => {
|
it('should suggest sub-command names for a parent command', async () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() => {
|
||||||
useCompletion(
|
const textBuffer = useTextBufferForTest('/memory a');
|
||||||
'/memory a',
|
return useCompletion(
|
||||||
|
textBuffer,
|
||||||
'/test/cwd',
|
'/test/cwd',
|
||||||
true,
|
|
||||||
mockSlashCommands,
|
mockSlashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
expect(result.current.suggestions).toEqual([
|
expect(result.current.suggestions).toEqual([
|
||||||
{ label: 'add', value: 'add', description: 'Add to memory' },
|
{ 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 () => {
|
it('should suggest all sub-commands when the query ends with the parent command and a space', async () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() => {
|
||||||
useCompletion(
|
const textBuffer = useTextBufferForTest('/memory ');
|
||||||
'/memory ',
|
return useCompletion(
|
||||||
|
textBuffer,
|
||||||
'/test/cwd',
|
'/test/cwd',
|
||||||
true,
|
|
||||||
mockSlashCommands,
|
mockSlashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
expect(result.current.suggestions).toHaveLength(2);
|
expect(result.current.suggestions).toHaveLength(2);
|
||||||
expect(result.current.suggestions).toEqual(
|
expect(result.current.suggestions).toEqual(
|
||||||
|
@ -652,7 +665,8 @@ describe('useCompletion git-aware filtering integration', () => {
|
||||||
const availableTags = ['my-chat-tag-1', 'my-chat-tag-2', 'another-channel'];
|
const availableTags = ['my-chat-tag-1', 'my-chat-tag-2', 'another-channel'];
|
||||||
const mockCompletionFn = vi
|
const mockCompletionFn = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockImplementation(async (context: CommandContext, partialArg: string) =>
|
.mockImplementation(
|
||||||
|
async (_context: CommandContext, partialArg: string) =>
|
||||||
availableTags.filter((tag) => tag.startsWith(partialArg)),
|
availableTags.filter((tag) => tag.startsWith(partialArg)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -678,15 +692,15 @@ describe('useCompletion git-aware filtering integration', () => {
|
||||||
|
|
||||||
resumeCmd.completion = mockCompletionFn;
|
resumeCmd.completion = mockCompletionFn;
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() => {
|
||||||
useCompletion(
|
const textBuffer = useTextBufferForTest('/chat resume my-ch');
|
||||||
'/chat resume my-ch',
|
return useCompletion(
|
||||||
|
textBuffer,
|
||||||
'/test/cwd',
|
'/test/cwd',
|
||||||
true,
|
|
||||||
mockCommandsWithFiltering,
|
mockCommandsWithFiltering,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
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 () => {
|
it('should not provide suggestions for a fully typed command that has no sub-commands or argument completion', async () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() => {
|
||||||
useCompletion(
|
const textBuffer = useTextBufferForTest('/clear ');
|
||||||
'/clear ',
|
return useCompletion(
|
||||||
|
textBuffer,
|
||||||
'/test/cwd',
|
'/test/cwd',
|
||||||
true,
|
|
||||||
mockSlashCommands,
|
mockSlashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
expect(result.current.suggestions).toHaveLength(0);
|
expect(result.current.suggestions).toHaveLength(0);
|
||||||
expect(result.current.showSuggestions).toBe(false);
|
expect(result.current.showSuggestions).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not provide suggestions for an unknown command', async () => {
|
it('should not provide suggestions for an unknown command', async () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() => {
|
||||||
useCompletion(
|
const textBuffer = useTextBufferForTest('/unknown-command');
|
||||||
'/unknown-command',
|
return useCompletion(
|
||||||
|
textBuffer,
|
||||||
'/test/cwd',
|
'/test/cwd',
|
||||||
true,
|
|
||||||
mockSlashCommands,
|
mockSlashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
expect(result.current.suggestions).toHaveLength(0);
|
expect(result.current.suggestions).toHaveLength(0);
|
||||||
expect(result.current.showSuggestions).toBe(false);
|
expect(result.current.showSuggestions).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should suggest sub-commands for a fully typed parent command without a trailing space', async () => {
|
it('should suggest sub-commands for a fully typed parent command without a trailing space', async () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() => {
|
||||||
useCompletion(
|
const textBuffer = useTextBufferForTest('/memory'); // Note: no trailing space
|
||||||
'/memory', // Note: no trailing space
|
return useCompletion(
|
||||||
|
textBuffer,
|
||||||
'/test/cwd',
|
'/test/cwd',
|
||||||
true,
|
|
||||||
mockSlashCommands,
|
mockSlashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Assert that suggestions for sub-commands are shown immediately
|
// Assert that suggestions for sub-commands are shown immediately
|
||||||
expect(result.current.suggestions).toHaveLength(2);
|
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 () => {
|
it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() => {
|
||||||
useCompletion(
|
const textBuffer = useTextBufferForTest('/clear'); // No trailing space
|
||||||
'/clear', // No trailing space
|
return useCompletion(
|
||||||
|
textBuffer,
|
||||||
'/test/cwd',
|
'/test/cwd',
|
||||||
true,
|
|
||||||
mockSlashCommands,
|
mockSlashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
expect(result.current.suggestions).toHaveLength(0);
|
expect(result.current.suggestions).toHaveLength(0);
|
||||||
expect(result.current.showSuggestions).toBe(false);
|
expect(result.current.showSuggestions).toBe(false);
|
||||||
|
@ -787,15 +801,15 @@ describe('useCompletion git-aware filtering integration', () => {
|
||||||
}
|
}
|
||||||
resumeCommand.completion = mockCompletionFn;
|
resumeCommand.completion = mockCompletionFn;
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() => {
|
||||||
useCompletion(
|
const textBuffer = useTextBufferForTest('/chat resume '); // Trailing space, no partial argument
|
||||||
'/chat resume ', // Trailing space, no partial argument
|
return useCompletion(
|
||||||
|
textBuffer,
|
||||||
'/test/cwd',
|
'/test/cwd',
|
||||||
true,
|
|
||||||
isolatedMockCommands,
|
isolatedMockCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
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 () => {
|
it('should suggest all top-level commands for the root slash', async () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() => {
|
||||||
useCompletion(
|
const textBuffer = useTextBufferForTest('/');
|
||||||
'/',
|
return useCompletion(
|
||||||
|
textBuffer,
|
||||||
'/test/cwd',
|
'/test/cwd',
|
||||||
true,
|
|
||||||
mockSlashCommands,
|
mockSlashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
expect(result.current.suggestions.length).toBe(mockSlashCommands.length);
|
expect(result.current.suggestions.length).toBe(mockSlashCommands.length);
|
||||||
expect(result.current.suggestions.map((s) => s.label)).toEqual(
|
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 () => {
|
it('should provide no suggestions for an invalid sub-command', async () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() => {
|
||||||
useCompletion(
|
const textBuffer = useTextBufferForTest('/memory dothisnow');
|
||||||
'/memory dothisnow',
|
return useCompletion(
|
||||||
|
textBuffer,
|
||||||
'/test/cwd',
|
'/test/cwd',
|
||||||
true,
|
|
||||||
mockSlashCommands,
|
mockSlashCommands,
|
||||||
mockCommandContext,
|
mockCommandContext,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
expect(result.current.suggestions).toHaveLength(0);
|
expect(result.current.suggestions).toHaveLength(0);
|
||||||
expect(result.current.showSuggestions).toBe(false);
|
expect(result.current.showSuggestions).toBe(false);
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -4,7 +4,7 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* 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 fs from 'fs/promises';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { glob } from 'glob';
|
import { glob } from 'glob';
|
||||||
|
@ -22,6 +22,9 @@ import {
|
||||||
Suggestion,
|
Suggestion,
|
||||||
} from '../components/SuggestionsDisplay.js';
|
} from '../components/SuggestionsDisplay.js';
|
||||||
import { CommandContext, SlashCommand } from '../commands/types.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 {
|
export interface UseCompletionReturn {
|
||||||
suggestions: Suggestion[];
|
suggestions: Suggestion[];
|
||||||
|
@ -35,12 +38,12 @@ export interface UseCompletionReturn {
|
||||||
resetCompletionState: () => void;
|
resetCompletionState: () => void;
|
||||||
navigateUp: () => void;
|
navigateUp: () => void;
|
||||||
navigateDown: () => void;
|
navigateDown: () => void;
|
||||||
|
handleAutocomplete: (indexToUse: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCompletion(
|
export function useCompletion(
|
||||||
query: string,
|
buffer: TextBuffer,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
isActive: boolean,
|
|
||||||
slashCommands: readonly SlashCommand[],
|
slashCommands: readonly SlashCommand[],
|
||||||
commandContext: CommandContext,
|
commandContext: CommandContext,
|
||||||
config?: Config,
|
config?: Config,
|
||||||
|
@ -122,13 +125,45 @@ export function useCompletion(
|
||||||
});
|
});
|
||||||
}, [suggestions.length]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
resetCompletionState();
|
resetCompletionState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmedQuery = query.trimStart();
|
const trimmedQuery = buffer.text.trimStart();
|
||||||
|
|
||||||
if (trimmedQuery.startsWith('/')) {
|
if (trimmedQuery.startsWith('/')) {
|
||||||
// Always reset perfect match at the beginning of processing.
|
// Always reset perfect match at the beginning of processing.
|
||||||
|
@ -275,13 +310,13 @@ export function useCompletion(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle At Command Completion
|
// Handle At Command Completion
|
||||||
const atIndex = query.lastIndexOf('@');
|
const atIndex = buffer.text.lastIndexOf('@');
|
||||||
if (atIndex === -1) {
|
if (atIndex === -1) {
|
||||||
resetCompletionState();
|
resetCompletionState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const partialPath = query.substring(atIndex + 1);
|
const partialPath = buffer.text.substring(atIndex + 1);
|
||||||
const lastSlashIndex = partialPath.lastIndexOf('/');
|
const lastSlashIndex = partialPath.lastIndexOf('/');
|
||||||
const baseDirRelative =
|
const baseDirRelative =
|
||||||
lastSlashIndex === -1
|
lastSlashIndex === -1
|
||||||
|
@ -545,7 +580,7 @@ export function useCompletion(
|
||||||
clearTimeout(debounceTimeout);
|
clearTimeout(debounceTimeout);
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
query,
|
buffer.text,
|
||||||
cwd,
|
cwd,
|
||||||
isActive,
|
isActive,
|
||||||
resetCompletionState,
|
resetCompletionState,
|
||||||
|
@ -554,6 +589,77 @@ export function useCompletion(
|
||||||
config,
|
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 {
|
return {
|
||||||
suggestions,
|
suggestions,
|
||||||
activeSuggestionIndex,
|
activeSuggestionIndex,
|
||||||
|
@ -566,5 +672,6 @@ export function useCompletion(
|
||||||
resetCompletionState,
|
resetCompletionState,
|
||||||
navigateUp,
|
navigateUp,
|
||||||
navigateDown,
|
navigateDown,
|
||||||
|
handleAutocomplete,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue