mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-15 16:41:11 -07:00
fix(ui): show command suggestions even on perfect match and sort them (#15287)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -55,3 +55,4 @@ gha-creds-*.json
|
||||
patch_output.log
|
||||
|
||||
.genkit
|
||||
.gemini-clipboard/
|
||||
|
||||
@@ -768,6 +768,63 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should execute perfect match on Enter even if suggestions are showing, if at first suggestion', async () => {
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [
|
||||
{ label: 'review', value: 'review' }, // Match is now at index 0
|
||||
{ label: 'review-frontend', value: 'review-frontend' },
|
||||
],
|
||||
activeSuggestionIndex: 0,
|
||||
isPerfectMatch: true,
|
||||
});
|
||||
props.buffer.text = '/review';
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
|
||||
uiActions,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSubmit).toHaveBeenCalledWith('/review');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should autocomplete and NOT execute on Enter if a DIFFERENT suggestion is selected even if perfect match', async () => {
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [
|
||||
{ label: 'review', value: 'review' },
|
||||
{ label: 'review-frontend', value: 'review-frontend' },
|
||||
],
|
||||
activeSuggestionIndex: 1, // review-frontend selected (not the perfect match at 0)
|
||||
isPerfectMatch: true, // /review is a perfect match
|
||||
});
|
||||
props.buffer.text = '/review';
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
|
||||
uiActions,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Should handle autocomplete for index 1
|
||||
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(1);
|
||||
// Should NOT submit
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should submit directly on Enter when a complete leaf command is typed', async () => {
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
|
||||
@@ -589,7 +589,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
|
||||
// If the command is a perfect match, pressing enter should execute it.
|
||||
if (completion.isPerfectMatch && keyMatchers[Command.RETURN](key)) {
|
||||
// We prioritize execution unless the user is explicitly selecting a different suggestion.
|
||||
if (
|
||||
completion.isPerfectMatch &&
|
||||
keyMatchers[Command.RETURN](key) &&
|
||||
(!completion.showSuggestions || completion.activeSuggestionIndex <= 0)
|
||||
) {
|
||||
handleSubmit(buffer.text);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -319,7 +319,7 @@ describe('useSlashCompletion', () => {
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => {
|
||||
it('should provide suggestions even for a perfectly typed command that is a leaf node', async () => {
|
||||
const slashCommands = [
|
||||
createTestCommand({
|
||||
name: 'clear',
|
||||
@@ -344,14 +344,15 @@ describe('useSlashCompletion', () => {
|
||||
unmount = hook.unmount;
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions).toHaveLength(0);
|
||||
expect(result.current.suggestions).toHaveLength(1);
|
||||
expect(result.current.suggestions[0].label).toBe('clear');
|
||||
expect(result.current.completionStart).toBe(1);
|
||||
});
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it.each([['/?'], ['/usage']])(
|
||||
'should not suggest commands when altNames is fully typed',
|
||||
'should suggest commands even when altNames is fully typed',
|
||||
async (query) => {
|
||||
const mockSlashCommands = [
|
||||
createTestCommand({
|
||||
@@ -387,13 +388,121 @@ describe('useSlashCompletion', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions).toHaveLength(0);
|
||||
expect(result.current.suggestions).toHaveLength(1);
|
||||
expect(result.current.completionStart).toBe(1);
|
||||
});
|
||||
unmount!();
|
||||
},
|
||||
);
|
||||
|
||||
it('should show all matching suggestions even when one is a perfect match', async () => {
|
||||
const slashCommands = [
|
||||
createTestCommand({
|
||||
name: 'review',
|
||||
description: 'Review code',
|
||||
action: vi.fn(),
|
||||
}),
|
||||
createTestCommand({
|
||||
name: 'review-frontend',
|
||||
description: 'Review frontend code',
|
||||
action: vi.fn(),
|
||||
}),
|
||||
createTestCommand({
|
||||
name: 'oncall:pr-review',
|
||||
description: 'Review PR as oncall',
|
||||
action: vi.fn(),
|
||||
}),
|
||||
];
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/review',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// All three should match 'review' in our fuzzy mock or as prefix/exact
|
||||
expect(result.current.suggestions.length).toBe(3);
|
||||
// 'review' should be first because it is an exact match
|
||||
expect(result.current.suggestions[0].label).toBe('review');
|
||||
|
||||
const labels = result.current.suggestions.map((s) => s.label);
|
||||
expect(labels).toContain('review');
|
||||
expect(labels).toContain('review-frontend');
|
||||
expect(labels).toContain('oncall:pr-review');
|
||||
expect(result.current.isPerfectMatch).toBe(true);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should sort exact altName matches to the top', async () => {
|
||||
const slashCommands = [
|
||||
createTestCommand({
|
||||
name: 'help',
|
||||
altNames: ['?'],
|
||||
description: 'Show help',
|
||||
action: vi.fn(),
|
||||
}),
|
||||
createTestCommand({
|
||||
name: 'question-mark',
|
||||
description: 'Alternative name for help',
|
||||
action: vi.fn(),
|
||||
}),
|
||||
];
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/?',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// 'help' should be first because '?' is an exact altName match
|
||||
expect(result.current.suggestions[0].label).toBe('help');
|
||||
expect(result.current.isPerfectMatch).toBe(true);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should suggest subcommands when a parent command is fully typed without a trailing space', async () => {
|
||||
const slashCommands = [
|
||||
createTestCommand({
|
||||
name: 'chat',
|
||||
description: 'Manage chat history',
|
||||
subCommands: [
|
||||
createTestCommand({ name: 'list', description: 'List chats' }),
|
||||
createTestCommand({ name: 'save', description: 'Save chat' }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/chat',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show subcommands of 'chat'
|
||||
expect(result.current.suggestions).toHaveLength(2);
|
||||
expect(result.current.suggestions.map((s) => s.label)).toEqual(
|
||||
expect.arrayContaining(['list', 'save']),
|
||||
);
|
||||
// completionStart should be at the end of '/chat' to append subcommands
|
||||
expect(result.current.completionStart).toBe(5);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not provide suggestions for a fully typed command that has no sub-commands or argument completion', async () => {
|
||||
const slashCommands = [
|
||||
createTestCommand({ name: 'clear', description: 'Clear the screen' }),
|
||||
|
||||
@@ -117,6 +117,27 @@ function useCommandParser(
|
||||
exactMatchAsParent = currentLevel.find(
|
||||
(cmd) => matchesCommand(cmd, partial) && cmd.subCommands,
|
||||
);
|
||||
|
||||
if (exactMatchAsParent) {
|
||||
// Only descend if there are NO other matches for the partial at this level.
|
||||
// This ensures that typing "/memory" still shows "/memory-leak" if it exists.
|
||||
const otherMatches = currentLevel.filter(
|
||||
(cmd) =>
|
||||
cmd !== exactMatchAsParent &&
|
||||
(cmd.name.toLowerCase().startsWith(partial.toLowerCase()) ||
|
||||
cmd.altNames?.some((alt) =>
|
||||
alt.toLowerCase().startsWith(partial.toLowerCase()),
|
||||
)),
|
||||
);
|
||||
|
||||
if (otherMatches.length === 0) {
|
||||
leafCommand = exactMatchAsParent;
|
||||
currentLevel = exactMatchAsParent.subCommands as
|
||||
| readonly SlashCommand[]
|
||||
| undefined;
|
||||
partial = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const depth = commandPathParts.length;
|
||||
@@ -278,7 +299,16 @@ function useCommandSuggestions(
|
||||
}
|
||||
|
||||
if (!signal.aborted) {
|
||||
const finalSuggestions = potentialSuggestions.map((cmd) => ({
|
||||
// Sort potentialSuggestions so that exact match (by name or altName) comes first
|
||||
const sortedSuggestions = [...potentialSuggestions].sort((a, b) => {
|
||||
const aIsExact = matchesCommand(a, partial);
|
||||
const bIsExact = matchesCommand(b, partial);
|
||||
if (aIsExact && !bIsExact) return -1;
|
||||
if (!aIsExact && bIsExact) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const finalSuggestions = sortedSuggestions.map((cmd) => ({
|
||||
label: cmd.name,
|
||||
value: cmd.name,
|
||||
description: cmd.description,
|
||||
@@ -537,11 +567,7 @@ export function useSlashCompletion(props: UseSlashCompletionProps): {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPerfectMatch) {
|
||||
setSuggestions([]);
|
||||
} else {
|
||||
setSuggestions(hookSuggestions);
|
||||
}
|
||||
setSuggestions(hookSuggestions);
|
||||
setIsLoadingSuggestions(isLoading);
|
||||
setIsPerfectMatch(isPerfectMatch);
|
||||
setCompletionStart(calculatedStart);
|
||||
|
||||
Reference in New Issue
Block a user