feat: auto-execute simple slash commands on Enter (#13985)

This commit is contained in:
Jack Wotherspoon
2025-12-01 12:29:03 -05:00
committed by GitHub
parent 844d3a4dfa
commit f918af82fe
38 changed files with 393 additions and 9 deletions
@@ -19,6 +19,7 @@ export const aboutCommand: SlashCommand = {
name: 'about',
description: 'Show version info',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
const osVersion = process.platform;
let sandboxEnv = 'no sandbox';
@@ -11,6 +11,7 @@ export const authCommand: SlashCommand = {
name: 'auth',
description: 'Change the auth method',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'auth',
@@ -21,6 +21,7 @@ export const bugCommand: SlashCommand = {
name: 'bug',
description: 'Submit a bug report',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: async (context: CommandContext, args?: string): Promise<void> => {
const bugDescription = (args || '').trim();
const { config } = context.services;
@@ -68,6 +68,7 @@ const listCommand: SlashCommand = {
name: 'list',
description: 'List saved conversation checkpoints',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context): Promise<void> => {
const chatDetails = await getSavedChatTags(context, false);
@@ -85,6 +86,7 @@ const saveCommand: SlashCommand = {
description:
'Save the current conversation as a checkpoint. Usage: /chat save <tag>',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: async (context, args): Promise<SlashCommandActionReturn | void> => {
const tag = args.trim();
if (!tag) {
@@ -153,6 +155,7 @@ const resumeCommand: SlashCommand = {
description:
'Resume a conversation from a checkpoint. Usage: /chat resume <tag>',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: async (context, args) => {
const tag = args.trim();
if (!tag) {
@@ -236,6 +239,7 @@ const deleteCommand: SlashCommand = {
name: 'delete',
description: 'Delete a conversation checkpoint. Usage: /chat delete <tag>',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: async (context, args): Promise<MessageActionReturn> => {
const tag = args.trim();
if (!tag) {
@@ -309,6 +313,7 @@ const shareCommand: SlashCommand = {
description:
'Share the current conversation to a markdown or json file. Usage: /chat share <file>',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: async (context, args): Promise<MessageActionReturn> => {
let filePathArg = args.trim();
if (!filePathArg) {
@@ -376,6 +381,7 @@ export const chatCommand: SlashCommand = {
name: 'chat',
description: 'Manage conversation history',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands: [
listCommand,
saveCommand,
@@ -13,6 +13,7 @@ export const clearCommand: SlashCommand = {
name: 'clear',
description: 'Clear the screen and conversation history',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context, _args) => {
const geminiClient = context.services.config?.getGeminiClient();
const config = context.services.config;
@@ -14,6 +14,7 @@ export const compressCommand: SlashCommand = {
altNames: ['summarize'],
description: 'Compresses the context by replacing it with a summary',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
const { ui } = context;
if (ui.pendingItem) {
@@ -13,6 +13,7 @@ export const copyCommand: SlashCommand = {
name: 'copy',
description: 'Copy the last result or code snippet to clipboard',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context, _args): Promise<SlashCommandActionReturn | void> => {
const chat = await context.services.config?.getGeminiClient()?.getChat();
const history = chat?.getHistory();
@@ -11,6 +11,7 @@ export const corgiCommand: SlashCommand = {
description: 'Toggles corgi mode',
hidden: true,
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context, _args) => {
context.ui.toggleCorgiMode();
},
@@ -79,6 +79,7 @@ export const directoryCommand: SlashCommand = {
description:
'Add directories to the workspace. Use comma to separate multiple paths',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: async (context: CommandContext, args: string) => {
const {
ui: { addItem },
@@ -17,6 +17,7 @@ export const docsCommand: SlashCommand = {
name: 'docs',
description: 'Open full Gemini CLI documentation in your browser',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context: CommandContext): Promise<void> => {
const docsUrl = 'https://goo.gle/gemini-cli-docs';
@@ -14,6 +14,7 @@ export const editorCommand: SlashCommand = {
name: 'editor',
description: 'Set external editor preference',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'editor',
@@ -473,6 +473,7 @@ const listExtensionsCommand: SlashCommand = {
name: 'list',
description: 'List active extensions',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: listAction,
};
@@ -480,6 +481,7 @@ const updateExtensionsCommand: SlashCommand = {
name: 'update',
description: 'Update extensions. Usage: update <extension-names>|--all',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: updateAction,
completion: completeExtensions,
};
@@ -488,6 +490,7 @@ const disableCommand: SlashCommand = {
name: 'disable',
description: 'Disable an extension',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: disableAction,
completion: completeExtensionsAndScopes,
};
@@ -496,6 +499,7 @@ const enableCommand: SlashCommand = {
name: 'enable',
description: 'Enable an extension',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: enableAction,
completion: completeExtensionsAndScopes,
};
@@ -504,6 +508,7 @@ const exploreExtensionsCommand: SlashCommand = {
name: 'explore',
description: 'Open extensions page in your browser',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: exploreAction,
};
@@ -511,6 +516,7 @@ const restartCommand: SlashCommand = {
name: 'restart',
description: 'Restart all extensions',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: restartAction,
completion: completeExtensions,
};
@@ -525,6 +531,7 @@ export function extensionsCommand(
name: 'extensions',
description: 'Manage extensions',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands: [
listExtensionsCommand,
updateExtensionsCommand,
@@ -13,6 +13,7 @@ export const helpCommand: SlashCommand = {
altNames: ['?'],
kind: CommandKind.BUILT_IN,
description: 'For help on gemini-cli',
autoExecute: true,
action: async (context) => {
const helpItem: Omit<HistoryItemHelp, 'id'> = {
type: MessageType.HELP,
@@ -141,6 +141,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
name: 'ide',
description: 'Manage IDE integration',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: (): SlashCommandActionReturn =>
({
type: 'message',
@@ -154,6 +155,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
name: 'ide',
description: 'Manage IDE integration',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands: [],
};
@@ -161,6 +163,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
name: 'status',
description: 'Check status of IDE integration',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (): Promise<SlashCommandActionReturn> => {
const { messageType, content } =
await getIdeStatusMessageWithFiles(ideClient);
@@ -176,6 +179,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
name: 'install',
description: `Install required IDE companion for ${ideClient.getDetectedIdeDisplayName()}`,
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
const installer = getIdeInstaller(currentIDE);
if (!installer) {
@@ -251,6 +255,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
name: 'enable',
description: 'Enable IDE integration',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context: CommandContext) => {
context.services.settings.setValue(
SettingScope.User,
@@ -273,6 +278,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
name: 'disable',
description: 'Disable IDE integration',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context: CommandContext) => {
context.services.settings.setValue(
SettingScope.User,
@@ -17,6 +17,7 @@ export const initCommand: SlashCommand = {
name: 'init',
description: 'Analyzes the project and creates a tailored GEMINI.md file',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (
context: CommandContext,
_args: string,
@@ -28,6 +28,7 @@ const authCommand: SlashCommand = {
name: 'auth',
description: 'Authenticate with an OAuth-enabled MCP server',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: async (
context: CommandContext,
args: string,
@@ -265,6 +266,7 @@ const listCommand: SlashCommand = {
altNames: ['ls', 'nodesc', 'nodescription'],
description: 'List configured MCP servers and tools',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context) => listAction(context),
};
@@ -273,6 +275,7 @@ const descCommand: SlashCommand = {
altNames: ['description'],
description: 'List configured MCP servers and tools with descriptions',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context) => listAction(context, true),
};
@@ -281,6 +284,7 @@ const schemaCommand: SlashCommand = {
description:
'List configured MCP servers and tools with descriptions and schemas',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context) => listAction(context, true, true),
};
@@ -288,6 +292,7 @@ const refreshCommand: SlashCommand = {
name: 'refresh',
description: 'Restarts MCP servers',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (
context: CommandContext,
): Promise<void | SlashCommandActionReturn> => {
@@ -336,6 +341,7 @@ export const mcpCommand: SlashCommand = {
name: 'mcp',
description: 'Manage configured Model Context Protocol (MCP) servers',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands: [
listCommand,
descCommand,
@@ -16,11 +16,13 @@ export const memoryCommand: SlashCommand = {
name: 'memory',
description: 'Commands for interacting with memory',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands: [
{
name: 'show',
description: 'Show the current memory contents',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
const memoryContent = context.services.config?.getUserMemory() || '';
const fileCount = context.services.config?.getGeminiMdFileCount() || 0;
@@ -43,6 +45,7 @@ export const memoryCommand: SlashCommand = {
name: 'add',
description: 'Add content to the memory',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: (context, args): SlashCommandActionReturn | void => {
if (!args || args.trim() === '') {
return {
@@ -71,6 +74,7 @@ export const memoryCommand: SlashCommand = {
name: 'refresh',
description: 'Refresh the memory from the source',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
context.ui.addItem(
{
@@ -117,6 +121,7 @@ export const memoryCommand: SlashCommand = {
name: 'list',
description: 'Lists the paths of the GEMINI.md files in use',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
const filePaths = context.services.config?.getGeminiMdFilePaths() || [];
const fileCount = filePaths.length;
@@ -10,6 +10,7 @@ export const modelCommand: SlashCommand = {
name: 'model',
description: 'Opens a dialog to configure the model',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async () => ({
type: 'dialog',
dialog: 'model',
@@ -19,12 +19,14 @@ export const permissionsCommand: SlashCommand = {
name: 'permissions',
description: 'Manage folder trust settings and other permissions',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands: [
{
name: 'trust',
description:
'Manage folder trust settings. Usage: /permissions trust [<directory-path>]',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: (context, input): SlashCommandActionReturn => {
const dirPath = input.trim();
let targetDirectory: string;
@@ -11,6 +11,7 @@ const listPoliciesCommand: SlashCommand = {
name: 'list',
description: 'List all active policies',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
const { config } = context.services;
if (!config) {
@@ -69,5 +70,6 @@ export const policiesCommand: SlashCommand = {
name: 'policies',
description: 'Manage policies',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands: [listPoliciesCommand],
};
@@ -11,6 +11,7 @@ export const privacyCommand: SlashCommand = {
name: 'privacy',
description: 'Display the privacy notice',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'privacy',
@@ -12,6 +12,7 @@ export const profileCommand: SlashCommand | null = isDevelopment
name: 'profile',
kind: CommandKind.BUILT_IN,
description: 'Toggle the debug profile display',
autoExecute: true,
action: async (context) => {
context.ui.toggleDebugProfiler();
return {
@@ -12,6 +12,7 @@ export const quitCommand: SlashCommand = {
altNames: ['exit'],
description: 'Exit the cli',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context) => {
const now = Date.now();
const { sessionStartTime } = context.session.stats;
@@ -147,6 +147,7 @@ export const restoreCommand = (config: Config | null): SlashCommand | null => {
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',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: restoreAction,
completion,
};
@@ -15,6 +15,7 @@ export const resumeCommand: SlashCommand = {
name: 'resume',
description: 'Browse and resume auto-saved conversations',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (
_context: CommandContext,
_args: string,
@@ -11,6 +11,7 @@ export const settingsCommand: SlashCommand = {
name: 'settings',
description: 'View and edit Gemini CLI settings',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'settings',
@@ -203,6 +203,7 @@ export const setupGithubCommand: SlashCommand = {
name: 'setup-github',
description: 'Set up GitHub Actions',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (
context: CommandContext,
): Promise<SlashCommandActionReturn> => {
@@ -52,6 +52,7 @@ export const statsCommand: SlashCommand = {
altNames: ['usage'],
description: 'Check session stats. Usage: /stats [session|model|tools]',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: async (context: CommandContext) => {
await defaultSessionView(context);
},
@@ -60,6 +61,7 @@ export const statsCommand: SlashCommand = {
name: 'session',
description: 'Show session-specific usage statistics',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context: CommandContext) => {
await defaultSessionView(context);
},
@@ -68,6 +70,7 @@ export const statsCommand: SlashCommand = {
name: 'model',
description: 'Show model-specific usage statistics',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context: CommandContext) => {
context.ui.addItem(
{
@@ -81,6 +84,7 @@ export const statsCommand: SlashCommand = {
name: 'tools',
description: 'Show tool-specific usage statistics',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context: CommandContext) => {
context.ui.addItem(
{
@@ -19,7 +19,7 @@ export const terminalSetupCommand: SlashCommand = {
description:
'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf)',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (): Promise<MessageActionReturn> => {
try {
const result = await terminalSetup();
@@ -11,6 +11,7 @@ export const themeCommand: SlashCommand = {
name: 'theme',
description: 'Change the theme',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'theme',
@@ -15,6 +15,7 @@ export const toolsCommand: SlashCommand = {
name: 'tools',
description: 'List available Gemini CLI tools. Usage: /tools [desc]',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: async (context: CommandContext, args?: string): Promise<void> => {
const subCommand = args?.trim();
+8
View File
@@ -202,6 +202,14 @@ export interface SlashCommand {
kind: CommandKind;
/**
* Controls whether the command auto-executes when selected with Enter.
*
* If true, pressing Enter on the suggestion will execute the command immediately.
* If false or undefined, pressing Enter will autocomplete the command into the prompt window.
*/
autoExecute?: boolean;
// Optional metadata for extension commands
extensionName?: string;
extensionId?: string;
@@ -11,6 +11,7 @@ export const vimCommand: SlashCommand = {
name: 'vim',
description: 'Toggle vim mode on/off',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context, _args) => {
const newVimState = await context.ui.toggleVimEnabled();
@@ -191,6 +191,13 @@ describe('InputPrompt', () => {
isActive: false,
markSelected: vi.fn(),
},
getCommandFromSuggestion: vi.fn().mockReturnValue(undefined),
slashCompletionRange: {
completionStart: -1,
completionEnd: -1,
getCommandFromSuggestion: vi.fn().mockReturnValue(undefined),
},
getCompletedText: vi.fn().mockReturnValue(null),
};
mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion);
@@ -778,6 +785,173 @@ describe('InputPrompt', () => {
unmount();
});
it('should auto-execute commands with autoExecute: true on Enter', async () => {
const aboutCommand: SlashCommand = {
name: 'about',
kind: CommandKind.BUILT_IN,
description: 'About command',
action: vi.fn(),
autoExecute: true,
};
const suggestion = { label: 'about', value: 'about' };
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [suggestion],
activeSuggestionIndex: 0,
getCommandFromSuggestion: vi.fn().mockReturnValue(aboutCommand),
getCompletedText: vi.fn().mockReturnValue('/about'),
slashCompletionRange: {
completionStart: 1,
completionEnd: 3, // "/ab" -> start at 1, end at 3
getCommandFromSuggestion: vi.fn(),
},
});
// User typed partial command
props.buffer.setText('/ab');
props.buffer.lines = ['/ab'];
props.buffer.cursor = [0, 3];
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
await act(async () => {
stdin.write('\r'); // Enter
});
await waitFor(() => {
// Should submit the full command constructed from buffer + suggestion
expect(props.onSubmit).toHaveBeenCalledWith('/about');
// Should NOT handle autocomplete (which just fills text)
expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled();
});
unmount();
});
it('should autocomplete commands with autoExecute: false on Enter', async () => {
const shareCommand: SlashCommand = {
name: 'share',
kind: CommandKind.BUILT_IN,
description: 'Share conversation to file',
action: vi.fn(),
autoExecute: false, // Explicitly set to false
};
const suggestion = { label: 'share', value: 'share' };
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [suggestion],
activeSuggestionIndex: 0,
getCommandFromSuggestion: vi.fn().mockReturnValue(shareCommand),
getCompletedText: vi.fn().mockReturnValue('/share'),
});
props.buffer.setText('/sh');
props.buffer.lines = ['/sh'];
props.buffer.cursor = [0, 3];
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
await act(async () => {
stdin.write('\r'); // Enter
});
await waitFor(() => {
// Should autocomplete to allow adding file argument
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(props.onSubmit).not.toHaveBeenCalled();
});
unmount();
});
it('should autocomplete on Tab, even for executable commands', 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'),
});
props.buffer.setText('/ab');
props.buffer.lines = ['/ab'];
props.buffer.cursor = [0, 3];
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
await act(async () => {
stdin.write('\t'); // Tab
});
await waitFor(() => {
// Tab always autocompletes, never executes
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(props.onSubmit).not.toHaveBeenCalled();
});
unmount();
});
it('should autocomplete custom commands from .toml files on Enter', async () => {
const customCommand: SlashCommand = {
name: 'find-capital',
kind: CommandKind.FILE,
description: 'Find capital of a country',
action: vi.fn(),
// No autoExecute flag - custom commands default to undefined
};
const suggestion = { label: 'find-capital', value: 'find-capital' };
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [suggestion],
activeSuggestionIndex: 0,
getCommandFromSuggestion: vi.fn().mockReturnValue(customCommand),
getCompletedText: vi.fn().mockReturnValue('/find-capital'),
});
props.buffer.setText('/find');
props.buffer.lines = ['/find'];
props.buffer.cursor = [0, 5];
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
await act(async () => {
stdin.write('\r'); // Enter
});
await waitFor(() => {
// Should autocomplete (not execute) since autoExecute is undefined
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(props.onSubmit).not.toHaveBeenCalled();
});
unmount();
});
it('should autocomplete an @-path on Enter without submitting', async () => {
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
+24 -1
View File
@@ -35,12 +35,15 @@ import {
saveClipboardImage,
cleanupOldClipboardImages,
} from '../utils/clipboardUtils.js';
import {
isAutoExecutableCommand,
isSlashCommand,
} from '../utils/commandUtils.js';
import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { StreamingState } from '../types.js';
import { isSlashCommand } from '../utils/commandUtils.js';
import { useMouseClick } from '../hooks/useMouseClick.js';
import { useMouse, type MouseEvent } from '../contexts/MouseContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
@@ -621,7 +624,27 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
completion.activeSuggestionIndex === -1
? 0 // Default to the first if none is active
: completion.activeSuggestionIndex;
if (targetIndex < completion.suggestions.length) {
const suggestion = completion.suggestions[targetIndex];
const isEnterKey = key.name === 'return' && !key.ctrl;
if (isEnterKey && buffer.text.startsWith('/')) {
const command = completion.getCommandFromSuggestion(suggestion);
if (command && isAutoExecutableCommand(command)) {
const completedText = completion.getCompletedText(suggestion);
if (completedText) {
setExpandedSuggestionIndex(-1);
handleSubmit(completedText.trim());
return;
}
}
}
// Default behavior: auto-complete to prompt box
completion.handleAutocomplete(targetIndex);
setExpandedSuggestionIndex(-1); // Reset expansion after selection
}
@@ -42,6 +42,17 @@ export interface UseCommandCompletionReturn {
navigateDown: () => void;
handleAutocomplete: (indexToUse: number) => void;
promptCompletion: PromptCompletion;
getCommandFromSuggestion: (
suggestion: Suggestion,
) => SlashCommand | undefined;
slashCompletionRange: {
completionStart: number;
completionEnd: number;
getCommandFromSuggestion: (
suggestion: Suggestion,
) => SlashCommand | undefined;
};
getCompletedText: (suggestion: Suggestion) => string | null;
}
export function useCommandCompletion(
@@ -200,12 +211,16 @@ export function useCommandCompletion(
setShowSuggestions,
]);
const handleAutocomplete = useCallback(
(indexToUse: number) => {
if (indexToUse < 0 || indexToUse >= suggestions.length) {
return;
}
const suggestion = suggestions[indexToUse].value;
/**
* Gets the completed text by replacing the completion range with the suggestion value.
* This is the core string replacement logic used by both autocomplete and auto-execute.
*
* @param suggestion The suggestion to apply
* @returns The completed text with the suggestion applied, or null if invalid
*/
const getCompletedText = useCallback(
(suggestion: Suggestion): string | null => {
const currentLine = buffer.lines[cursorRow] || '';
let start = completionStart;
let end = completionEnd;
@@ -215,10 +230,56 @@ export function useCommandCompletion(
}
if (start === -1 || end === -1) {
return null;
}
// Apply space padding for slash commands (needed for subcommands like "/chat list")
let suggestionText = suggestion.value;
if (completionMode === CompletionMode.SLASH) {
// Add leading space if completing a subcommand (cursor is after parent command with no space)
if (start === end && start > 1 && currentLine[start - 1] !== ' ') {
suggestionText = ' ' + suggestionText;
}
}
// Build the completed text with proper spacing
return (
currentLine.substring(0, start) +
suggestionText +
currentLine.substring(end)
);
},
[
cursorRow,
buffer.lines,
completionMode,
completionStart,
completionEnd,
slashCompletionRange,
],
);
const handleAutocomplete = useCallback(
(indexToUse: number) => {
if (indexToUse < 0 || indexToUse >= suggestions.length) {
return;
}
const suggestion = suggestions[indexToUse];
const completedText = getCompletedText(suggestion);
if (completedText === null) {
return;
}
let suggestionText = suggestion;
let start = completionStart;
let end = completionEnd;
if (completionMode === CompletionMode.SLASH) {
start = slashCompletionRange.completionStart;
end = slashCompletionRange.completionEnd;
}
// Add space padding for Tab completion (auto-execute gets padding from getCompletedText)
let suggestionText = suggestion.value;
if (completionMode === CompletionMode.SLASH) {
if (
start === end &&
@@ -253,6 +314,7 @@ export function useCommandCompletion(
completionStart,
completionEnd,
slashCompletionRange,
getCompletedText,
],
);
@@ -270,5 +332,8 @@ export function useCommandCompletion(
navigateDown,
handleAutocomplete,
promptCompletion,
getCommandFromSuggestion: slashCompletionRange.getCommandFromSuggestion,
slashCompletionRange,
getCompletedText,
};
}
@@ -376,6 +376,32 @@ function usePerfectMatch(
}, [parserResult]);
}
/**
* Gets the SlashCommand object for a given suggestion by navigating the command hierarchy
* based on the current parser state.
* @param suggestion The suggestion object
* @param parserResult The current parser result with hierarchy information
* @returns The matching SlashCommand or undefined
*/
function getCommandFromSuggestion(
suggestion: Suggestion,
parserResult: CommandParserResult,
): SlashCommand | undefined {
const { currentLevel } = parserResult;
if (!currentLevel) {
return undefined;
}
// 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) =>
matchesCommand(cmd, suggestion.value),
);
return command;
}
export interface UseSlashCompletionProps {
enabled: boolean;
query: string | null;
@@ -389,6 +415,9 @@ export interface UseSlashCompletionProps {
export function useSlashCompletion(props: UseSlashCompletionProps): {
completionStart: number;
completionEnd: number;
getCommandFromSuggestion: (
suggestion: Suggestion,
) => SlashCommand | undefined;
} {
const {
enabled,
@@ -536,5 +565,7 @@ export function useSlashCompletion(props: UseSlashCompletionProps): {
return {
completionStart,
completionEnd,
getCommandFromSuggestion: (suggestion: Suggestion) =>
getCommandFromSuggestion(suggestion, parserResult),
};
}
+22
View File
@@ -6,6 +6,7 @@
import { debugLogger } from '@google/gemini-cli-core';
import clipboardy from 'clipboardy';
import type { SlashCommand } from '../commands/types.js';
/**
* Checks if a query string potentially represents an '@' command.
@@ -72,3 +73,24 @@ export const getUrlOpenCommand = (): string => {
}
return openCmd;
};
/**
* Determines if a slash command should auto-execute when selected.
*
* All built-in commands have autoExecute explicitly set to true or false.
* Custom commands (.toml files) and extension commands without this flag
* will default to false (safe default - won't auto-execute).
*
* @param command The slash command to check
* @returns true if the command should auto-execute on Enter
*/
export function isAutoExecutableCommand(
command: SlashCommand | undefined,
): boolean {
if (!command) {
return false;
}
// Simply return the autoExecute flag value, defaulting to false if undefined
return command.autoExecute ?? false;
}