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);