diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx index 51aeb0bf43..982991bf9a 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -20,7 +20,7 @@ import { useCommandCompletion, CompletionMode, } from './useCommandCompletion.js'; -import type { CommandContext } from '../commands/types.js'; +import type { CommandContext, SlashCommand } from '../commands/types.js'; import type { Config } from '@google/gemini-cli-core'; import { useTextBuffer } from '../components/shared/text-buffer.js'; import type { Suggestion } from '../components/SuggestionsDisplay.js'; @@ -72,7 +72,11 @@ const setupMocks = ({ shellSuggestions = [], isLoading = false, isPerfectMatch = false, - slashCompletionRange = { completionStart: 0, completionEnd: 0 }, + slashCompletionRange = { + completionStart: 0, + completionEnd: 0, + getCommandFromSuggestion: () => undefined, + }, shellCompletionRange = { completionStart: 0, completionEnd: 0, @@ -85,7 +89,13 @@ const setupMocks = ({ shellSuggestions?: Suggestion[]; isLoading?: boolean; isPerfectMatch?: boolean; - slashCompletionRange?: { completionStart: number; completionEnd: number }; + slashCompletionRange?: { + completionStart: number; + completionEnd: number; + getCommandFromSuggestion: ( + suggestion: Suggestion, + ) => SlashCommand | undefined; + }; shellCompletionRange?: { completionStart: number; completionEnd: number; @@ -471,10 +481,15 @@ describe('useCommandCompletion', () => { }); describe('handleAutocomplete', () => { - it('should complete a partial command', async () => { + it('should complete a partial command and NOT add a space if it has an action', async () => { setupMocks({ slashSuggestions: [{ label: 'memory', value: 'memory' }], - slashCompletionRange: { completionStart: 1, completionEnd: 4 }, + slashCompletionRange: { + completionStart: 1, + completionEnd: 4, + getCommandFromSuggestion: () => + ({ action: vi.fn() }) as unknown as SlashCommand, + }, }); const { result } = await renderCommandCompletionHook('/mem'); @@ -487,12 +502,40 @@ describe('useCommandCompletion', () => { result.current.handleAutocomplete(0); }); - expect(result.current.textBuffer.text).toBe('/memory '); + expect(result.current.textBuffer.text).toBe('/memory'); + }); + + it('should complete a partial command and ADD a space if it has NO action (e.g. just a parent)', async () => { + setupMocks({ + slashSuggestions: [{ label: 'chat', value: 'chat' }], + slashCompletionRange: { + completionStart: 1, + completionEnd: 5, + getCommandFromSuggestion: () => ({}) as unknown as SlashCommand, // No action + }, + }); + + const { result } = await renderCommandCompletionHook('/chat'); + + await waitFor(() => { + expect(result.current.suggestions.length).toBe(1); + }); + + act(() => { + result.current.handleAutocomplete(0); + }); + + expect(result.current.textBuffer.text).toBe('/chat '); }); it('should complete a file path', async () => { setupMocks({ atSuggestions: [{ label: 'src/file1.txt', value: 'src/file1.txt' }], + slashCompletionRange: { + completionStart: 0, + completionEnd: 0, + getCommandFromSuggestion: () => undefined, + }, }); const { result } = await renderCommandCompletionHook('@src/fi'); @@ -517,7 +560,11 @@ describe('useCommandCompletion', () => { insertValue: 'resume list', }, ], - slashCompletionRange: { completionStart: 1, completionEnd: 5 }, + slashCompletionRange: { + completionStart: 1, + completionEnd: 5, + getCommandFromSuggestion: () => undefined, + }, }); const { result } = await renderCommandCompletionHook('/resu'); @@ -539,6 +586,11 @@ describe('useCommandCompletion', () => { setupMocks({ atSuggestions: [{ label: 'src/file1.txt', value: 'src/file1.txt' }], + slashCompletionRange: { + completionStart: 0, + completionEnd: 0, + getCommandFromSuggestion: () => undefined, + }, }); const { result } = await renderCommandCompletionHook(text, cursorOffset); @@ -559,6 +611,11 @@ describe('useCommandCompletion', () => { it('should complete a directory path ending with / without a trailing space', async () => { setupMocks({ atSuggestions: [{ label: 'src/components/', value: 'src/components/' }], + slashCompletionRange: { + completionStart: 0, + completionEnd: 0, + getCommandFromSuggestion: () => undefined, + }, }); const { result } = await renderCommandCompletionHook('@src/comp'); @@ -579,6 +636,11 @@ describe('useCommandCompletion', () => { atSuggestions: [ { label: 'src\\components\\', value: 'src\\components\\' }, ], + slashCompletionRange: { + completionStart: 0, + completionEnd: 0, + getCommandFromSuggestion: () => undefined, + }, }); const { result } = await renderCommandCompletionHook('@src\\comp'); @@ -594,6 +656,33 @@ describe('useCommandCompletion', () => { expect(result.current.textBuffer.text).toBe('@src\\components\\'); }); + it('should ADD a space for AT completion even if name matches a command with an action', async () => { + // Setup a mock where getCommandFromSuggestion WOULD return a command with an action + // if it were in SLASH mode. + setupMocks({ + atSuggestions: [{ label: 'memory', value: 'memory' }], + slashCompletionRange: { + completionStart: 0, + completionEnd: 0, + getCommandFromSuggestion: () => + ({ action: vi.fn() }) as unknown as SlashCommand, + }, + }); + + const { result } = await renderCommandCompletionHook('@mem'); + + await waitFor(() => { + expect(result.current.suggestions.length).toBe(1); + }); + + act(() => { + result.current.handleAutocomplete(0); + }); + + // Should have a space because it's AT mode, not SLASH mode + expect(result.current.textBuffer.text).toBe('@memory '); + }); + it('should show ghost text for a single shell completion', async () => { const text = 'l'; setupMocks({ @@ -905,6 +994,11 @@ describe('useCommandCompletion', () => { it('should complete file path and add trailing space', async () => { setupMocks({ atSuggestions: [{ label: 'src/file.txt', value: 'src/file.txt' }], + slashCompletionRange: { + completionStart: 0, + completionEnd: 0, + getCommandFromSuggestion: () => undefined, + }, }); const { result } = await renderCommandCompletionHook('/cmd @src/fi'); diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx index 2f964306f4..4f89d69ff1 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -1,16 +1,17 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ +import type React from 'react'; import { useCallback, useMemo, useEffect, useState } from 'react'; import type { Suggestion } from '../components/SuggestionsDisplay.js'; import type { CommandContext, SlashCommand } from '../commands/types.js'; import type { TextBuffer } from '../components/shared/text-buffer.js'; import { logicalPosToOffset } from '../components/shared/text-buffer.js'; -import { isSlashCommand } from '../utils/commandUtils.js'; import { toCodePoints } from '../utils/textUtils.js'; +import { isSlashCommand } from '../utils/commandUtils.js'; import { useAtCompletion } from './useAtCompletion.js'; import { useSlashCompletion } from './useSlashCompletion.js'; import { useShellCompletion } from './useShellCompletion.js'; @@ -436,10 +437,23 @@ export function useCommandCompletion({ const lineCodePoints = toCodePoints(buffer.lines[cursorRow] || ''); const charAfterCompletion = lineCodePoints[end]; + + let shouldAddSpace = true; + if (completionMode === CompletionMode.SLASH) { + const command = + slashCompletionRange.getCommandFromSuggestion(suggestion); + // Don't add a space if the command has an action (can be executed) + // and doesn't have a completion function (doesn't REQUIRE more arguments) + const isExecutableCommand = !!(command && command.action); + const requiresArguments = !!(command && command.completion); + shouldAddSpace = !isExecutableCommand || requiresArguments; + } + if ( charAfterCompletion !== ' ' && !suggestionText.endsWith('/') && - !suggestionText.endsWith('\\') + !suggestionText.endsWith('\\') && + shouldAddSpace ) { suggestionText += ' '; } diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts index 47935c8c6a..575202ce98 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -464,7 +464,7 @@ describe('useSlashCompletion', () => { () => useTestHarnessForSlashCompletion( true, - '/chat', + '/chat ', slashCommands, mockCommandContext, ), @@ -484,7 +484,7 @@ describe('useSlashCompletion', () => { () => useTestHarnessForSlashCompletion( true, - '/resume', + '/resume ', slashCommands, mockCommandContext, ), @@ -513,53 +513,6 @@ describe('useSlashCompletion', () => { unmountResume(); }); - it('should show the grouped /resume menu for unique /resum prefix input', async () => { - const slashCommands = [ - createTestCommand({ - name: 'resume', - description: 'Resume command', - action: vi.fn(), - subCommands: [ - createTestCommand({ - name: 'list', - description: 'List checkpoints', - suggestionGroup: 'checkpoints', - }), - createTestCommand({ - name: 'save', - description: 'Save checkpoint', - suggestionGroup: 'checkpoints', - }), - ], - }), - ]; - - const { result, unmount } = await renderHook(() => - useTestHarnessForSlashCompletion( - true, - '/resum', - slashCommands, - mockCommandContext, - ), - ); - - await resolveMatch(); - - await waitFor(() => { - expect(result.current.suggestions[0]).toMatchObject({ - label: 'list', - sectionTitle: 'auto', - submitValue: '/resume', - }); - expect(result.current.isPerfectMatch).toBe(false); - expect(result.current.suggestions.slice(1).map((s) => s.label)).toEqual( - expect.arrayContaining(['list', 'save']), - ); - }); - - unmount(); - }); - it('should sort exact altName matches to the top', async () => { const slashCommands = [ createTestCommand({ @@ -594,7 +547,7 @@ describe('useSlashCompletion', () => { unmount(); }); - it('should suggest subcommands when a parent command is fully typed without a trailing space', async () => { + it('should suggest the command itself instead of subcommands when a parent command is fully typed without a trailing space', async () => { const slashCommands = [ createTestCommand({ name: 'chat', @@ -618,18 +571,47 @@ describe('useSlashCompletion', () => { await resolveMatch(); await waitFor(() => { - // Should show the auto-session entry plus subcommands of 'chat' - expect(result.current.suggestions).toHaveLength(3); - expect(result.current.suggestions[0]).toMatchObject({ - label: 'list', - sectionTitle: 'auto', - submitValue: '/chat', - }); - 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); + // Should show 'chat' as the suggestion, NOT its subcommands + expect(result.current.suggestions).toHaveLength(1); + expect(result.current.suggestions[0].label).toBe('chat'); + // completionStart should be at 1 (to replace 'chat') + expect(result.current.completionStart).toBe(1); + }); + unmount(); + }); + + it('should NOT suggest subcommands when a parent command is fully typed without a trailing space (fix for over-eager completion)', async () => { + const slashCommands = [ + createTestCommand({ + name: 'stats', + description: 'Check session stats', + action: vi.fn(), // Has action + subCommands: [ + createTestCommand({ + name: 'session', + description: 'Show session-specific usage statistics', + }), + ], + }), + ]; + + const { result, unmount } = await renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/stats', + slashCommands, + mockCommandContext, + ), + ); + + await resolveMatch(); + + await waitFor(() => { + // Should show 'stats' as the suggestion, NOT 'session' + expect(result.current.suggestions).toHaveLength(1); + expect(result.current.suggestions[0].label).toBe('stats'); + // isPerfectMatch should be true because it has an action + expect(result.current.isPerfectMatch).toBe(true); }); unmount(); }); diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index 0548451615..4afa8e2241 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -54,8 +54,6 @@ interface CommandParserResult { partial: string; currentLevel: readonly SlashCommand[] | undefined; leafCommand: SlashCommand | null; - exactMatchAsParent: SlashCommand | undefined; - usedPrefixParentDescent: boolean; isArgumentCompletion: boolean; } @@ -71,8 +69,6 @@ function useCommandParser( partial: '', currentLevel: slashCommands, leafCommand: null, - exactMatchAsParent: undefined, - usedPrefixParentDescent: false, isArgumentCompletion: false, }; } @@ -90,7 +86,6 @@ function useCommandParser( let currentLevel: readonly SlashCommand[] | undefined = slashCommands; let leafCommand: SlashCommand | null = null; - let usedPrefixParentDescent = false; for (const part of commandPathParts) { if (!currentLevel) { @@ -115,60 +110,6 @@ function useCommandParser( } } - let exactMatchAsParent: SlashCommand | undefined; - if (!hasTrailingSpace && currentLevel) { - 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 = ''; - } - } - - // Phase-one alias UX: allow unique prefix descent for /chat and /resume - // so `/cha` and `/resum` expose the same grouped menu immediately. - if (!exactMatchAsParent && partial && currentLevel) { - const prefixParentMatches = currentLevel.filter( - (cmd) => - !!cmd.subCommands && - (cmd.name.toLowerCase().startsWith(partial.toLowerCase()) || - cmd.altNames?.some((alt) => - alt.toLowerCase().startsWith(partial.toLowerCase()), - )), - ); - - if (prefixParentMatches.length === 1) { - const candidate = prefixParentMatches[0]; - if (candidate.name === 'chat' || candidate.name === 'resume') { - exactMatchAsParent = candidate; - leafCommand = candidate; - usedPrefixParentDescent = true; - currentLevel = candidate.subCommands as - | readonly SlashCommand[] - | undefined; - partial = ''; - } - } - } - } - const depth = commandPathParts.length; const isArgumentCompletion = !!( leafCommand?.completion && @@ -182,8 +123,6 @@ function useCommandParser( partial, currentLevel, leafCommand, - exactMatchAsParent, - usedPrefixParentDescent, isArgumentCompletion, }; }, [query, slashCommands]); @@ -343,19 +282,9 @@ function useCommandSuggestions( }); const finalSuggestions = sortedSuggestions.map((cmd) => { - const canonicalParentName = - parserResult.usedPrefixParentDescent && - leafCommand && - (leafCommand.name === 'chat' || leafCommand.name === 'resume') - ? leafCommand.name - : undefined; - const suggestion: Suggestion = { label: cmd.name, value: cmd.name, - insertValue: canonicalParentName - ? `${canonicalParentName} ${cmd.name}` - : undefined, description: cmd.description, commandKind: cmd.kind, }; @@ -384,7 +313,7 @@ function useCommandSuggestions( description: 'Browse auto-saved chats', commandKind: CommandKind.BUILT_IN, sectionTitle: 'auto', - submitValue: `/${leafCommand.name}`, + submitValue: `/${canonicalParentName}`, }; setSuggestions([autoSectionSuggestion, ...finalSuggestions]); return; @@ -427,12 +356,10 @@ function useCompletionPositions( return { start: -1, end: -1 }; } - const { hasTrailingSpace, partial, exactMatchAsParent } = parserResult; + const { hasTrailingSpace, partial } = parserResult; // Set completion start/end positions - if (parserResult.usedPrefixParentDescent) { - return { start: 1, end: query.length }; - } else if (hasTrailingSpace || exactMatchAsParent) { + if (hasTrailingSpace) { return { start: query.length, end: query.length }; } else if (partial) { if (parserResult.isArgumentCompletion) { @@ -461,12 +388,7 @@ function usePerfectMatch( return { isPerfectMatch: false }; } - if ( - leafCommand && - partial === '' && - leafCommand.action && - !parserResult.usedPrefixParentDescent - ) { + if (leafCommand && partial === '' && leafCommand.action) { return { isPerfectMatch: true }; }