mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-15 06:12:50 -07:00
fix(ui): ensure autocomplete allows immediate execution and subcommand disclosure (Team Consensus)
This commit is contained in:
@@ -1445,9 +1445,101 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Should autocomplete to allow adding file argument
|
||||
// Should submit the full command constructed from buffer + suggestion
|
||||
// even if autoExecute is false, because the user explicitly hit Enter on the suggestion.
|
||||
expect(props.onSubmit).toHaveBeenCalledWith('/share');
|
||||
expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should add a space on Tab when command is already complete in the buffer', async () => {
|
||||
const executableCommand: SlashCommand = {
|
||||
name: 'about',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'About info',
|
||||
action: vi.fn(),
|
||||
autoExecute: true,
|
||||
};
|
||||
|
||||
const suggestion = { label: 'about', value: 'about' };
|
||||
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [suggestion],
|
||||
activeSuggestionIndex: 0,
|
||||
getCommandFromSuggestion: vi.fn().mockReturnValue(executableCommand),
|
||||
getCompletedText: vi.fn().mockReturnValue('/about'),
|
||||
});
|
||||
|
||||
// Buffer is already complete
|
||||
props.buffer.setText('/about');
|
||||
props.buffer.lines = ['/about'];
|
||||
props.buffer.cursor = [0, 6];
|
||||
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
uiActions,
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\t'); // Tab
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Should call handleAutocomplete which will add the space
|
||||
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should submit the base command when Enter is pressed on its suggestion even with a trailing space', async () => {
|
||||
const statsCommand: SlashCommand = {
|
||||
name: 'stats',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'Stats info',
|
||||
action: vi.fn(),
|
||||
autoExecute: false,
|
||||
};
|
||||
|
||||
const suggestion = {
|
||||
label: 'stats',
|
||||
value: 'stats',
|
||||
sectionTitle: 'command',
|
||||
insertValue: 'stats',
|
||||
};
|
||||
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [suggestion],
|
||||
activeSuggestionIndex: 0,
|
||||
getCommandFromSuggestion: vi.fn().mockReturnValue(statsCommand),
|
||||
getCompletedText: vi.fn().mockReturnValue('/stats'),
|
||||
});
|
||||
|
||||
// Buffer has trailing space: "/stats "
|
||||
props.buffer.setText('/stats ');
|
||||
props.buffer.lines = ['/stats '];
|
||||
props.buffer.cursor = [0, 7];
|
||||
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
uiActions,
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r'); // Enter
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Should submit "/stats"
|
||||
expect(props.onSubmit).toHaveBeenCalledWith('/stats');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
@@ -1564,9 +1656,9 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Should autocomplete (not execute) since autoExecute is undefined
|
||||
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
// Should submit the command
|
||||
expect(props.onSubmit).toHaveBeenCalledWith('/find-capital');
|
||||
expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -1082,16 +1082,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const command =
|
||||
completion.getCommandFromSuggestion(suggestion);
|
||||
|
||||
// Only auto-execute if the command has no completion function
|
||||
// (i.e., it doesn't require an argument to be selected)
|
||||
if (
|
||||
command &&
|
||||
isAutoExecutableCommand(command) &&
|
||||
!command.completion
|
||||
) {
|
||||
const completedText =
|
||||
completion.getCompletedText(suggestion);
|
||||
const completedText = completion.getCompletedText(suggestion);
|
||||
|
||||
// If the user hits Enter on a suggestion, we should submit it
|
||||
// IF it has an action and doesn't require further arguments (no completion function).
|
||||
// This is separate from autoExecute, which handles automatic execution while typing.
|
||||
if (command && command.action && !command.completion) {
|
||||
if (completedText) {
|
||||
setExpandedSuggestionIndex(-1);
|
||||
handleSubmit(completedText.trim());
|
||||
|
||||
@@ -481,7 +481,7 @@ describe('useCommandCompletion', () => {
|
||||
});
|
||||
|
||||
describe('handleAutocomplete', () => {
|
||||
it('should complete a partial command and NOT add a space if it has an action', async () => {
|
||||
it('should complete a partial command and ALWAYS add a space if it is a slash command', async () => {
|
||||
setupMocks({
|
||||
slashSuggestions: [{ label: 'memory', value: 'memory' }],
|
||||
slashCompletionRange: {
|
||||
@@ -502,7 +502,31 @@ describe('useCommandCompletion', () => {
|
||||
result.current.handleAutocomplete(0);
|
||||
});
|
||||
|
||||
expect(result.current.textBuffer.text).toBe('/memory');
|
||||
expect(result.current.textBuffer.text).toBe('/memory ');
|
||||
});
|
||||
|
||||
it('should ADD a space even even if it is ALREADY complete', async () => {
|
||||
setupMocks({
|
||||
slashSuggestions: [{ label: 'stats', value: 'stats' }],
|
||||
slashCompletionRange: {
|
||||
completionStart: 1,
|
||||
completionEnd: 6, // "/stats"
|
||||
getCommandFromSuggestion: () =>
|
||||
({ action: vi.fn() }) as unknown as SlashCommand,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = await renderCommandCompletionHook('/stats');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions.length).toBe(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleAutocomplete(0);
|
||||
});
|
||||
|
||||
expect(result.current.textBuffer.text).toBe('/stats ');
|
||||
});
|
||||
|
||||
it('should complete a partial command and ADD a space if it has NO action (e.g. just a parent)', async () => {
|
||||
|
||||
@@ -440,13 +440,10 @@ export function useCommandCompletion({
|
||||
|
||||
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;
|
||||
// ALWAYS add a space when autocompleting a slash command via Tab.
|
||||
// This ensures subcommands are immediately disclosed.
|
||||
// The base command will still be accessible at the top of the suggestions list.
|
||||
shouldAddSpace = true;
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -501,13 +501,14 @@ describe('useSlashCompletion', () => {
|
||||
});
|
||||
|
||||
const chatCheckpointLabels = chatResult.current.suggestions
|
||||
.slice(1)
|
||||
.slice(0)
|
||||
.map((s) => s.label);
|
||||
const resumeCheckpointLabels = resumeResult.current.suggestions
|
||||
.slice(1)
|
||||
.slice(0)
|
||||
.map((s) => s.label);
|
||||
|
||||
expect(chatCheckpointLabels).toEqual(resumeCheckpointLabels);
|
||||
expect(chatCheckpointLabels).toEqual(['list', 'chat', 'save']);
|
||||
expect(resumeCheckpointLabels).toEqual(['list', 'resume', 'save']);
|
||||
|
||||
unmountChat();
|
||||
unmountResume();
|
||||
@@ -773,6 +774,46 @@ describe('useSlashCompletion', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should suggest the parent command itself at the top when a trailing space is present (if it has an action)', async () => {
|
||||
const slashCommands = [
|
||||
createTestCommand({
|
||||
name: 'stats',
|
||||
description: 'Check session stats',
|
||||
action: vi.fn(),
|
||||
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 first suggestion, followed by its subcommands
|
||||
expect(result.current.suggestions.length).toBe(2);
|
||||
expect(result.current.suggestions[0]).toMatchObject({
|
||||
label: 'stats',
|
||||
sectionTitle: 'command',
|
||||
});
|
||||
expect(result.current.suggestions[1]).toMatchObject({
|
||||
label: 'session',
|
||||
});
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should suggest parent command (and siblings) instead of sub-commands when no trailing space', async () => {
|
||||
const slashCommands = [
|
||||
createTestCommand({
|
||||
|
||||
@@ -313,7 +313,56 @@ function useCommandSuggestions(
|
||||
return 0; // Maintain FZF score order for other matches
|
||||
});
|
||||
|
||||
const finalSuggestions = sortedSuggestions.map((cmd) => {
|
||||
const isTopLevelChatOrResumeContext = !!(
|
||||
leafCommand &&
|
||||
(leafCommand.name === 'chat' || leafCommand.name === 'resume') &&
|
||||
(commandPathParts.length === 0 ||
|
||||
(commandPathParts.length === 1 &&
|
||||
matchesCommand(leafCommand, commandPathParts[0])))
|
||||
);
|
||||
|
||||
const resultSuggestions: Suggestion[] = [];
|
||||
|
||||
if (isTopLevelChatOrResumeContext && leafCommand) {
|
||||
const canonicalParentName = leafCommand.name;
|
||||
const autoSectionSuggestion: Suggestion = {
|
||||
label: 'list',
|
||||
value: 'list',
|
||||
insertValue: canonicalParentName,
|
||||
description: 'Browse auto-saved chats',
|
||||
commandKind: CommandKind.BUILT_IN,
|
||||
sectionTitle: 'auto',
|
||||
submitValue: `/${canonicalParentName}`,
|
||||
};
|
||||
resultSuggestions.push(autoSectionSuggestion);
|
||||
}
|
||||
|
||||
// If we have a leaf command with an action and a trailing space,
|
||||
// add it to the suggestions list so it is still accessible.
|
||||
if (
|
||||
parserResult.hasTrailingSpace &&
|
||||
leafCommand &&
|
||||
leafCommand.action &&
|
||||
!isArgumentCompletion
|
||||
) {
|
||||
const selfSuggestion: Suggestion = {
|
||||
label: leafCommand.name,
|
||||
value: leafCommand.name,
|
||||
description: leafCommand.description,
|
||||
commandKind: leafCommand.kind,
|
||||
sectionTitle: 'command',
|
||||
// Use insertValue to ensure that if selected, we don't add another space
|
||||
insertValue: leafCommand.name,
|
||||
};
|
||||
resultSuggestions.push(selfSuggestion);
|
||||
}
|
||||
|
||||
sortedSuggestions.forEach((cmd) => {
|
||||
// Avoid duplicate 'list' for chat/resume context
|
||||
if (isTopLevelChatOrResumeContext && cmd.name === 'list') {
|
||||
return;
|
||||
}
|
||||
|
||||
const suggestion: Suggestion = {
|
||||
label: cmd.name,
|
||||
value: cmd.name,
|
||||
@@ -325,33 +374,10 @@ function useCommandSuggestions(
|
||||
suggestion.sectionTitle = cmd.suggestionGroup;
|
||||
}
|
||||
|
||||
return suggestion;
|
||||
resultSuggestions.push(suggestion);
|
||||
});
|
||||
|
||||
const isTopLevelChatOrResumeContext = !!(
|
||||
leafCommand &&
|
||||
(leafCommand.name === 'chat' || leafCommand.name === 'resume') &&
|
||||
(commandPathParts.length === 0 ||
|
||||
(commandPathParts.length === 1 &&
|
||||
matchesCommand(leafCommand, commandPathParts[0])))
|
||||
);
|
||||
|
||||
if (isTopLevelChatOrResumeContext) {
|
||||
const canonicalParentName = leafCommand.name;
|
||||
const autoSectionSuggestion: Suggestion = {
|
||||
label: 'list',
|
||||
value: 'list',
|
||||
insertValue: canonicalParentName,
|
||||
description: 'Browse auto-saved chats',
|
||||
commandKind: CommandKind.BUILT_IN,
|
||||
sectionTitle: 'auto',
|
||||
submitValue: `/${canonicalParentName}`,
|
||||
};
|
||||
setSuggestions([autoSectionSuggestion, ...finalSuggestions]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSuggestions(finalSuggestions);
|
||||
setSuggestions(resultSuggestions);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -448,7 +474,15 @@ function getCommandFromSuggestion(
|
||||
suggestion: Suggestion,
|
||||
parserResult: CommandParserResult,
|
||||
): SlashCommand | undefined {
|
||||
const { currentLevel } = parserResult;
|
||||
const { currentLevel, leafCommand, hasTrailingSpace } = parserResult;
|
||||
|
||||
if (
|
||||
hasTrailingSpace &&
|
||||
leafCommand &&
|
||||
suggestion.value === leafCommand.name
|
||||
) {
|
||||
return leafCommand;
|
||||
}
|
||||
|
||||
if (!currentLevel) {
|
||||
return undefined;
|
||||
@@ -456,7 +490,8 @@ function getCommandFromSuggestion(
|
||||
|
||||
// suggestion.value is just the command name at the current level (e.g., "list")
|
||||
// Find it in the current level's commands
|
||||
const command = currentLevel.find((cmd) =>
|
||||
const command = currentLevel.find(
|
||||
(cmd) => matchesCommand(cmd, suggestion.value),
|
||||
matchesCommand(cmd, suggestion.value),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user