mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
feat: auto-execute on slash command completion functions (#14584)
This commit is contained in:
@@ -157,7 +157,7 @@ const resumeCommand: SlashCommand = {
|
|||||||
description:
|
description:
|
||||||
'Resume a conversation from a checkpoint. Usage: /chat resume <tag>',
|
'Resume a conversation from a checkpoint. Usage: /chat resume <tag>',
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
autoExecute: false,
|
autoExecute: true,
|
||||||
action: async (context, args) => {
|
action: async (context, args) => {
|
||||||
const tag = args.trim();
|
const tag = args.trim();
|
||||||
if (!tag) {
|
if (!tag) {
|
||||||
@@ -241,7 +241,7 @@ const deleteCommand: SlashCommand = {
|
|||||||
name: 'delete',
|
name: 'delete',
|
||||||
description: 'Delete a conversation checkpoint. Usage: /chat delete <tag>',
|
description: 'Delete a conversation checkpoint. Usage: /chat delete <tag>',
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
autoExecute: false,
|
autoExecute: true,
|
||||||
action: async (context, args): Promise<MessageActionReturn> => {
|
action: async (context, args): Promise<MessageActionReturn> => {
|
||||||
const tag = args.trim();
|
const tag = args.trim();
|
||||||
if (!tag) {
|
if (!tag) {
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ const enableCommand: SlashCommand = {
|
|||||||
name: 'enable',
|
name: 'enable',
|
||||||
description: 'Enable a hook by name',
|
description: 'Enable a hook by name',
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
|
autoExecute: true,
|
||||||
action: enableAction,
|
action: enableAction,
|
||||||
completion: completeHookNames,
|
completion: completeHookNames,
|
||||||
};
|
};
|
||||||
@@ -236,6 +237,7 @@ const disableCommand: SlashCommand = {
|
|||||||
name: 'disable',
|
name: 'disable',
|
||||||
description: 'Disable a hook by name',
|
description: 'Disable a hook by name',
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
|
autoExecute: true,
|
||||||
action: disableAction,
|
action: disableAction,
|
||||||
completion: completeHookNames,
|
completion: completeHookNames,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const authCommand: SlashCommand = {
|
|||||||
name: 'auth',
|
name: 'auth',
|
||||||
description: 'Authenticate with an OAuth-enabled MCP server',
|
description: 'Authenticate with an OAuth-enabled MCP server',
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
autoExecute: false,
|
autoExecute: true,
|
||||||
action: async (
|
action: async (
|
||||||
context: CommandContext,
|
context: CommandContext,
|
||||||
args: string,
|
args: string,
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ export const restoreCommand = (config: Config | null): SlashCommand | null => {
|
|||||||
description:
|
description:
|
||||||
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested',
|
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested',
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
autoExecute: false,
|
autoExecute: true,
|
||||||
action: restoreAction,
|
action: restoreAction,
|
||||||
completion,
|
completion,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -196,6 +196,8 @@ describe('InputPrompt', () => {
|
|||||||
completionStart: -1,
|
completionStart: -1,
|
||||||
completionEnd: -1,
|
completionEnd: -1,
|
||||||
getCommandFromSuggestion: vi.fn().mockReturnValue(undefined),
|
getCommandFromSuggestion: vi.fn().mockReturnValue(undefined),
|
||||||
|
isArgumentCompletion: false,
|
||||||
|
leafCommand: null,
|
||||||
},
|
},
|
||||||
getCompletedText: vi.fn().mockReturnValue(null),
|
getCompletedText: vi.fn().mockReturnValue(null),
|
||||||
};
|
};
|
||||||
@@ -807,6 +809,8 @@ describe('InputPrompt', () => {
|
|||||||
completionStart: 1,
|
completionStart: 1,
|
||||||
completionEnd: 3, // "/ab" -> start at 1, end at 3
|
completionEnd: 3, // "/ab" -> start at 1, end at 3
|
||||||
getCommandFromSuggestion: vi.fn(),
|
getCommandFromSuggestion: vi.fn(),
|
||||||
|
isArgumentCompletion: false,
|
||||||
|
leafCommand: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -952,6 +956,158 @@ describe('InputPrompt', () => {
|
|||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should auto-execute argument completion when command has autoExecute: true', async () => {
|
||||||
|
// Simulates: /mcp auth <server> where user selects a server from completions
|
||||||
|
const authCommand: SlashCommand = {
|
||||||
|
name: 'auth',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
description: 'Authenticate with MCP server',
|
||||||
|
action: vi.fn(),
|
||||||
|
autoExecute: true,
|
||||||
|
completion: vi.fn().mockResolvedValue(['server1', 'server2']),
|
||||||
|
};
|
||||||
|
|
||||||
|
const suggestion = { label: 'server1', value: 'server1' };
|
||||||
|
|
||||||
|
mockedUseCommandCompletion.mockReturnValue({
|
||||||
|
...mockCommandCompletion,
|
||||||
|
showSuggestions: true,
|
||||||
|
suggestions: [suggestion],
|
||||||
|
activeSuggestionIndex: 0,
|
||||||
|
getCommandFromSuggestion: vi.fn().mockReturnValue(authCommand),
|
||||||
|
getCompletedText: vi.fn().mockReturnValue('/mcp auth server1'),
|
||||||
|
slashCompletionRange: {
|
||||||
|
completionStart: 10,
|
||||||
|
completionEnd: 10,
|
||||||
|
getCommandFromSuggestion: vi.fn(),
|
||||||
|
isArgumentCompletion: true,
|
||||||
|
leafCommand: authCommand,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
props.buffer.setText('/mcp auth ');
|
||||||
|
props.buffer.lines = ['/mcp auth '];
|
||||||
|
props.buffer.cursor = [0, 10];
|
||||||
|
|
||||||
|
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
|
||||||
|
uiActions,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
stdin.write('\r'); // Enter
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Should auto-execute with the completed command
|
||||||
|
expect(props.onSubmit).toHaveBeenCalledWith('/mcp auth server1');
|
||||||
|
expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should autocomplete argument completion when command has autoExecute: false', async () => {
|
||||||
|
// Simulates: /extensions enable <ext> where multi-arg completions should NOT auto-execute
|
||||||
|
const enableCommand: SlashCommand = {
|
||||||
|
name: 'enable',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
description: 'Enable an extension',
|
||||||
|
action: vi.fn(),
|
||||||
|
autoExecute: false,
|
||||||
|
completion: vi.fn().mockResolvedValue(['ext1 --scope user']),
|
||||||
|
};
|
||||||
|
|
||||||
|
const suggestion = {
|
||||||
|
label: 'ext1 --scope user',
|
||||||
|
value: 'ext1 --scope user',
|
||||||
|
};
|
||||||
|
|
||||||
|
mockedUseCommandCompletion.mockReturnValue({
|
||||||
|
...mockCommandCompletion,
|
||||||
|
showSuggestions: true,
|
||||||
|
suggestions: [suggestion],
|
||||||
|
activeSuggestionIndex: 0,
|
||||||
|
getCommandFromSuggestion: vi.fn().mockReturnValue(enableCommand),
|
||||||
|
getCompletedText: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue('/extensions enable ext1 --scope user'),
|
||||||
|
slashCompletionRange: {
|
||||||
|
completionStart: 19,
|
||||||
|
completionEnd: 19,
|
||||||
|
getCommandFromSuggestion: vi.fn(),
|
||||||
|
isArgumentCompletion: true,
|
||||||
|
leafCommand: enableCommand,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
props.buffer.setText('/extensions enable ');
|
||||||
|
props.buffer.lines = ['/extensions enable '];
|
||||||
|
props.buffer.cursor = [0, 19];
|
||||||
|
|
||||||
|
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
|
||||||
|
uiActions,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
stdin.write('\r'); // Enter
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Should autocomplete (not execute) to allow user to modify
|
||||||
|
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||||
|
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should autocomplete command name even with autoExecute: true if command has completion function', async () => {
|
||||||
|
// Simulates: /chat resu -> should NOT auto-execute, should autocomplete to show arg completions
|
||||||
|
const resumeCommand: SlashCommand = {
|
||||||
|
name: 'resume',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
description: 'Resume a conversation',
|
||||||
|
action: vi.fn(),
|
||||||
|
autoExecute: true,
|
||||||
|
completion: vi.fn().mockResolvedValue(['chat1', 'chat2']),
|
||||||
|
};
|
||||||
|
|
||||||
|
const suggestion = { label: 'resume', value: 'resume' };
|
||||||
|
|
||||||
|
mockedUseCommandCompletion.mockReturnValue({
|
||||||
|
...mockCommandCompletion,
|
||||||
|
showSuggestions: true,
|
||||||
|
suggestions: [suggestion],
|
||||||
|
activeSuggestionIndex: 0,
|
||||||
|
getCommandFromSuggestion: vi.fn().mockReturnValue(resumeCommand),
|
||||||
|
getCompletedText: vi.fn().mockReturnValue('/chat resume'),
|
||||||
|
slashCompletionRange: {
|
||||||
|
completionStart: 6,
|
||||||
|
completionEnd: 10,
|
||||||
|
getCommandFromSuggestion: vi.fn(),
|
||||||
|
isArgumentCompletion: false,
|
||||||
|
leafCommand: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
props.buffer.setText('/chat resu');
|
||||||
|
props.buffer.lines = ['/chat resu'];
|
||||||
|
props.buffer.cursor = [0, 10];
|
||||||
|
|
||||||
|
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
|
||||||
|
uiActions,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
stdin.write('\r'); // Enter
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Should autocomplete to allow selecting an argument, NOT auto-execute
|
||||||
|
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||||
|
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
it('should autocomplete an @-path on Enter without submitting', async () => {
|
it('should autocomplete an @-path on Enter without submitting', async () => {
|
||||||
mockedUseCommandCompletion.mockReturnValue({
|
mockedUseCommandCompletion.mockReturnValue({
|
||||||
...mockCommandCompletion,
|
...mockCommandCompletion,
|
||||||
|
|||||||
@@ -621,10 +621,34 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
const isEnterKey = key.name === 'return' && !key.ctrl;
|
const isEnterKey = key.name === 'return' && !key.ctrl;
|
||||||
|
|
||||||
if (isEnterKey && buffer.text.startsWith('/')) {
|
if (isEnterKey && buffer.text.startsWith('/')) {
|
||||||
const command = completion.getCommandFromSuggestion(suggestion);
|
const { isArgumentCompletion, leafCommand } =
|
||||||
|
completion.slashCompletionRange;
|
||||||
|
|
||||||
if (command && isAutoExecutableCommand(command)) {
|
if (
|
||||||
|
isArgumentCompletion &&
|
||||||
|
isAutoExecutableCommand(leafCommand)
|
||||||
|
) {
|
||||||
|
// isArgumentCompletion guarantees leafCommand exists
|
||||||
const completedText = completion.getCompletedText(suggestion);
|
const completedText = completion.getCompletedText(suggestion);
|
||||||
|
if (completedText) {
|
||||||
|
setExpandedSuggestionIndex(-1);
|
||||||
|
handleSubmit(completedText.trim());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (!isArgumentCompletion) {
|
||||||
|
// Existing logic for command name completion
|
||||||
|
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);
|
||||||
|
|
||||||
if (completedText) {
|
if (completedText) {
|
||||||
setExpandedSuggestionIndex(-1);
|
setExpandedSuggestionIndex(-1);
|
||||||
@@ -633,6 +657,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Default behavior: auto-complete to prompt box
|
// Default behavior: auto-complete to prompt box
|
||||||
completion.handleAutocomplete(targetIndex);
|
completion.handleAutocomplete(targetIndex);
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export interface UseCommandCompletionReturn {
|
|||||||
getCommandFromSuggestion: (
|
getCommandFromSuggestion: (
|
||||||
suggestion: Suggestion,
|
suggestion: Suggestion,
|
||||||
) => SlashCommand | undefined;
|
) => SlashCommand | undefined;
|
||||||
|
isArgumentCompletion: boolean;
|
||||||
|
leafCommand: SlashCommand | null;
|
||||||
};
|
};
|
||||||
getCompletedText: (suggestion: Suggestion) => string | null;
|
getCompletedText: (suggestion: Suggestion) => string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -419,6 +419,8 @@ export function useSlashCompletion(props: UseSlashCompletionProps): {
|
|||||||
getCommandFromSuggestion: (
|
getCommandFromSuggestion: (
|
||||||
suggestion: Suggestion,
|
suggestion: Suggestion,
|
||||||
) => SlashCommand | undefined;
|
) => SlashCommand | undefined;
|
||||||
|
isArgumentCompletion: boolean;
|
||||||
|
leafCommand: SlashCommand | null;
|
||||||
} {
|
} {
|
||||||
const {
|
const {
|
||||||
enabled,
|
enabled,
|
||||||
@@ -568,5 +570,7 @@ export function useSlashCompletion(props: UseSlashCompletionProps): {
|
|||||||
completionEnd,
|
completionEnd,
|
||||||
getCommandFromSuggestion: (suggestion: Suggestion) =>
|
getCommandFromSuggestion: (suggestion: Suggestion) =>
|
||||||
getCommandFromSuggestion(suggestion, parserResult),
|
getCommandFromSuggestion(suggestion, parserResult),
|
||||||
|
isArgumentCompletion: parserResult.isArgumentCompletion,
|
||||||
|
leafCommand: parserResult.leafCommand,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ export const getUrlOpenCommand = (): string => {
|
|||||||
* @returns true if the command should auto-execute on Enter
|
* @returns true if the command should auto-execute on Enter
|
||||||
*/
|
*/
|
||||||
export function isAutoExecutableCommand(
|
export function isAutoExecutableCommand(
|
||||||
command: SlashCommand | undefined,
|
command: SlashCommand | undefined | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!command) {
|
if (!command) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
Reference in New Issue
Block a user