fix(ui): prevent over-eager slash subcommand completion

This commit is contained in:
Keith Guerin
2026-02-23 23:06:24 -08:00
parent a1367e9cdd
commit e944e71c87
4 changed files with 120 additions and 47 deletions

View File

@@ -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';
@@ -56,7 +56,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, query: '' },
}: {
atSuggestions?: Suggestion[];
@@ -64,7 +68,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;
@@ -427,10 +437,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 } = renderCommandCompletionHook('/mem');
@@ -443,12 +458,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 } = 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 } = renderCommandCompletionHook('@src/fi');
@@ -470,6 +513,11 @@ describe('useCommandCompletion', () => {
setupMocks({
atSuggestions: [{ label: 'src/file1.txt', value: 'src/file1.txt' }],
slashCompletionRange: {
completionStart: 0,
completionEnd: 0,
getCommandFromSuggestion: () => undefined,
},
});
const { result } = renderCommandCompletionHook(text, cursorOffset);
@@ -490,6 +538,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 } = renderCommandCompletionHook('@src/comp');
@@ -510,6 +563,11 @@ describe('useCommandCompletion', () => {
atSuggestions: [
{ label: 'src\\components\\', value: 'src\\components\\' },
],
slashCompletionRange: {
completionStart: 0,
completionEnd: 0,
getCommandFromSuggestion: () => undefined,
},
});
const { result } = renderCommandCompletionHook('@src\\comp');
@@ -713,6 +771,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 } = renderCommandCompletionHook('/cmd @src/fi');

View File

@@ -361,10 +361,19 @@ export function useCommandCompletion({
const lineCodePoints = toCodePoints(buffer.lines[cursorRow] || '');
const charAfterCompletion = lineCodePoints[end];
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);
const shouldAddSpace = !isExecutableCommand || requiresArguments;
if (
charAfterCompletion !== ' ' &&
!suggestionText.endsWith('/') &&
!suggestionText.endsWith('\\')
!suggestionText.endsWith('\\') &&
shouldAddSpace
) {
suggestionText += ' ';
}

View File

@@ -492,13 +492,45 @@ describe('useSlashCompletion', () => {
);
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);
// 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 } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/stats',
slashCommands,
mockCommandContext,
),
);
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();
});

View File

@@ -54,7 +54,6 @@ interface CommandParserResult {
partial: string;
currentLevel: readonly SlashCommand[] | undefined;
leafCommand: SlashCommand | null;
exactMatchAsParent: SlashCommand | undefined;
isArgumentCompletion: boolean;
}
@@ -70,7 +69,6 @@ function useCommandParser(
partial: '',
currentLevel: slashCommands,
leafCommand: null,
exactMatchAsParent: undefined,
isArgumentCompletion: false,
};
}
@@ -112,34 +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 = '';
}
}
}
const depth = commandPathParts.length;
const isArgumentCompletion = !!(
leafCommand?.completion &&
@@ -153,7 +123,6 @@ function useCommandParser(
partial,
currentLevel,
leafCommand,
exactMatchAsParent,
isArgumentCompletion,
};
}, [query, slashCommands]);
@@ -356,10 +325,10 @@ function useCompletionPositions(
return { start: -1, end: -1 };
}
const { hasTrailingSpace, partial, exactMatchAsParent } = parserResult;
const { hasTrailingSpace, partial } = parserResult;
// Set completion start/end positions
if (hasTrailingSpace || exactMatchAsParent) {
if (hasTrailingSpace) {
return { start: query.length, end: query.length };
} else if (partial) {
if (parserResult.isArgumentCompletion) {