From 70696e364bdaee869564d6652352431d7dba241a Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Thu, 18 Dec 2025 14:05:36 -1000 Subject: [PATCH] fix(ui): show command suggestions even on perfect match and sort them (#15287) --- .gitignore | 1 + .../src/ui/components/InputPrompt.test.tsx | 57 +++++++++ .../cli/src/ui/components/InputPrompt.tsx | 7 +- .../src/ui/hooks/useSlashCompletion.test.ts | 117 +++++++++++++++++- .../cli/src/ui/hooks/useSlashCompletion.ts | 38 +++++- 5 files changed, 209 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 4852eb2ea8..1222895148 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ gha-creds-*.json patch_output.log .genkit +.gemini-clipboard/ diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 19f86253cc..421b8b3312 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -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(, { + 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(, { + 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, diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 31c34f3a28..9ad465f8e6 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -589,7 +589,12 @@ export const InputPrompt: React.FC = ({ } // 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; } diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts index 41af410f35..ea320b80a1 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -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' }), diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index 0a6f49e597..5e6631f453 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -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);