From f918af82fe13eae28b324843d03e00f02937b521 Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Mon, 1 Dec 2025 12:29:03 -0500 Subject: [PATCH] feat: auto-execute simple slash commands on Enter (#13985) --- packages/cli/src/ui/commands/aboutCommand.ts | 1 + packages/cli/src/ui/commands/authCommand.ts | 1 + packages/cli/src/ui/commands/bugCommand.ts | 1 + packages/cli/src/ui/commands/chatCommand.ts | 6 + packages/cli/src/ui/commands/clearCommand.ts | 1 + .../cli/src/ui/commands/compressCommand.ts | 1 + packages/cli/src/ui/commands/copyCommand.ts | 1 + packages/cli/src/ui/commands/corgiCommand.ts | 1 + .../cli/src/ui/commands/directoryCommand.tsx | 1 + packages/cli/src/ui/commands/docsCommand.ts | 1 + packages/cli/src/ui/commands/editorCommand.ts | 1 + .../cli/src/ui/commands/extensionsCommand.ts | 7 + packages/cli/src/ui/commands/helpCommand.ts | 1 + packages/cli/src/ui/commands/ideCommand.ts | 6 + packages/cli/src/ui/commands/initCommand.ts | 1 + packages/cli/src/ui/commands/mcpCommand.ts | 6 + packages/cli/src/ui/commands/memoryCommand.ts | 5 + packages/cli/src/ui/commands/modelCommand.ts | 1 + .../cli/src/ui/commands/permissionsCommand.ts | 2 + .../cli/src/ui/commands/policiesCommand.ts | 2 + .../cli/src/ui/commands/privacyCommand.ts | 1 + .../cli/src/ui/commands/profileCommand.ts | 1 + packages/cli/src/ui/commands/quitCommand.ts | 1 + .../cli/src/ui/commands/restoreCommand.ts | 1 + packages/cli/src/ui/commands/resumeCommand.ts | 1 + .../cli/src/ui/commands/settingsCommand.ts | 1 + .../cli/src/ui/commands/setupGithubCommand.ts | 1 + packages/cli/src/ui/commands/statsCommand.ts | 4 + .../src/ui/commands/terminalSetupCommand.ts | 2 +- packages/cli/src/ui/commands/themeCommand.ts | 1 + packages/cli/src/ui/commands/toolsCommand.ts | 1 + packages/cli/src/ui/commands/types.ts | 8 + packages/cli/src/ui/commands/vimCommand.ts | 1 + .../src/ui/components/InputPrompt.test.tsx | 174 ++++++++++++++++++ .../cli/src/ui/components/InputPrompt.tsx | 25 ++- .../cli/src/ui/hooks/useCommandCompletion.tsx | 79 +++++++- .../cli/src/ui/hooks/useSlashCompletion.ts | 31 ++++ packages/cli/src/ui/utils/commandUtils.ts | 22 +++ 38 files changed, 393 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/ui/commands/aboutCommand.ts b/packages/cli/src/ui/commands/aboutCommand.ts index fad66a119e..7870d2558a 100644 --- a/packages/cli/src/ui/commands/aboutCommand.ts +++ b/packages/cli/src/ui/commands/aboutCommand.ts @@ -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'; diff --git a/packages/cli/src/ui/commands/authCommand.ts b/packages/cli/src/ui/commands/authCommand.ts index 1d2df73f2c..dfb113e504 100644 --- a/packages/cli/src/ui/commands/authCommand.ts +++ b/packages/cli/src/ui/commands/authCommand.ts @@ -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', diff --git a/packages/cli/src/ui/commands/bugCommand.ts b/packages/cli/src/ui/commands/bugCommand.ts index 05cb07f58e..7e69ec113f 100644 --- a/packages/cli/src/ui/commands/bugCommand.ts +++ b/packages/cli/src/ui/commands/bugCommand.ts @@ -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 => { const bugDescription = (args || '').trim(); const { config } = context.services; diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index 149351976a..342cf73068 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -68,6 +68,7 @@ const listCommand: SlashCommand = { name: 'list', description: 'List saved conversation checkpoints', kind: CommandKind.BUILT_IN, + autoExecute: true, action: async (context): Promise => { const chatDetails = await getSavedChatTags(context, false); @@ -85,6 +86,7 @@ const saveCommand: SlashCommand = { description: 'Save the current conversation as a checkpoint. Usage: /chat save ', kind: CommandKind.BUILT_IN, + autoExecute: false, action: async (context, args): Promise => { const tag = args.trim(); if (!tag) { @@ -153,6 +155,7 @@ const resumeCommand: SlashCommand = { description: 'Resume a conversation from a checkpoint. Usage: /chat resume ', 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 ', kind: CommandKind.BUILT_IN, + autoExecute: false, action: async (context, args): Promise => { 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 ', kind: CommandKind.BUILT_IN, + autoExecute: false, action: async (context, args): Promise => { 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, diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index eca35a58fd..f9a84522ce 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -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; diff --git a/packages/cli/src/ui/commands/compressCommand.ts b/packages/cli/src/ui/commands/compressCommand.ts index a36bf47b82..3bb5b34383 100644 --- a/packages/cli/src/ui/commands/compressCommand.ts +++ b/packages/cli/src/ui/commands/compressCommand.ts @@ -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) { diff --git a/packages/cli/src/ui/commands/copyCommand.ts b/packages/cli/src/ui/commands/copyCommand.ts index b9ed967ad9..68f8dee312 100644 --- a/packages/cli/src/ui/commands/copyCommand.ts +++ b/packages/cli/src/ui/commands/copyCommand.ts @@ -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 => { const chat = await context.services.config?.getGeminiClient()?.getChat(); const history = chat?.getHistory(); diff --git a/packages/cli/src/ui/commands/corgiCommand.ts b/packages/cli/src/ui/commands/corgiCommand.ts index f1e120fcbe..87f1dd9443 100644 --- a/packages/cli/src/ui/commands/corgiCommand.ts +++ b/packages/cli/src/ui/commands/corgiCommand.ts @@ -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(); }, diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index ccf131d1ca..f1aa62b800 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -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 }, diff --git a/packages/cli/src/ui/commands/docsCommand.ts b/packages/cli/src/ui/commands/docsCommand.ts index 5225ce9c62..6286557cd1 100644 --- a/packages/cli/src/ui/commands/docsCommand.ts +++ b/packages/cli/src/ui/commands/docsCommand.ts @@ -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 => { const docsUrl = 'https://goo.gle/gemini-cli-docs'; diff --git a/packages/cli/src/ui/commands/editorCommand.ts b/packages/cli/src/ui/commands/editorCommand.ts index 69a361a3e2..e4e668ae53 100644 --- a/packages/cli/src/ui/commands/editorCommand.ts +++ b/packages/cli/src/ui/commands/editorCommand.ts @@ -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', diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index c523c536e0..d762b495d3 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -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 |--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, diff --git a/packages/cli/src/ui/commands/helpCommand.ts b/packages/cli/src/ui/commands/helpCommand.ts index 40a984488d..f7d469a7e7 100644 --- a/packages/cli/src/ui/commands/helpCommand.ts +++ b/packages/cli/src/ui/commands/helpCommand.ts @@ -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 = { type: MessageType.HELP, diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index 3cb82a201b..1f726f90e5 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -141,6 +141,7 @@ export const ideCommand = async (): Promise => { 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 => { name: 'ide', description: 'Manage IDE integration', kind: CommandKind.BUILT_IN, + autoExecute: false, subCommands: [], }; @@ -161,6 +163,7 @@ export const ideCommand = async (): Promise => { name: 'status', description: 'Check status of IDE integration', kind: CommandKind.BUILT_IN, + autoExecute: true, action: async (): Promise => { const { messageType, content } = await getIdeStatusMessageWithFiles(ideClient); @@ -176,6 +179,7 @@ export const ideCommand = async (): Promise => { 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 => { 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 => { name: 'disable', description: 'Disable IDE integration', kind: CommandKind.BUILT_IN, + autoExecute: true, action: async (context: CommandContext) => { context.services.settings.setValue( SettingScope.User, diff --git a/packages/cli/src/ui/commands/initCommand.ts b/packages/cli/src/ui/commands/initCommand.ts index acf96c4420..f978fccdf8 100644 --- a/packages/cli/src/ui/commands/initCommand.ts +++ b/packages/cli/src/ui/commands/initCommand.ts @@ -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, diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index 8663965c22..65abb50741 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -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 => { @@ -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, diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 953a9e7633..844298f170 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -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; diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts index bfabbb4831..d355d0521a 100644 --- a/packages/cli/src/ui/commands/modelCommand.ts +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -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', diff --git a/packages/cli/src/ui/commands/permissionsCommand.ts b/packages/cli/src/ui/commands/permissionsCommand.ts index 5480ca0a67..7aecbcf2a2 100644 --- a/packages/cli/src/ui/commands/permissionsCommand.ts +++ b/packages/cli/src/ui/commands/permissionsCommand.ts @@ -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 []', kind: CommandKind.BUILT_IN, + autoExecute: false, action: (context, input): SlashCommandActionReturn => { const dirPath = input.trim(); let targetDirectory: string; diff --git a/packages/cli/src/ui/commands/policiesCommand.ts b/packages/cli/src/ui/commands/policiesCommand.ts index c449990df1..cc6136c3d5 100644 --- a/packages/cli/src/ui/commands/policiesCommand.ts +++ b/packages/cli/src/ui/commands/policiesCommand.ts @@ -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], }; diff --git a/packages/cli/src/ui/commands/privacyCommand.ts b/packages/cli/src/ui/commands/privacyCommand.ts index 8385900409..4526de500e 100644 --- a/packages/cli/src/ui/commands/privacyCommand.ts +++ b/packages/cli/src/ui/commands/privacyCommand.ts @@ -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', diff --git a/packages/cli/src/ui/commands/profileCommand.ts b/packages/cli/src/ui/commands/profileCommand.ts index e31fc82be2..22ef6f37ba 100644 --- a/packages/cli/src/ui/commands/profileCommand.ts +++ b/packages/cli/src/ui/commands/profileCommand.ts @@ -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 { diff --git a/packages/cli/src/ui/commands/quitCommand.ts b/packages/cli/src/ui/commands/quitCommand.ts index e8634d7f34..ab879f22ca 100644 --- a/packages/cli/src/ui/commands/quitCommand.ts +++ b/packages/cli/src/ui/commands/quitCommand.ts @@ -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; diff --git a/packages/cli/src/ui/commands/restoreCommand.ts b/packages/cli/src/ui/commands/restoreCommand.ts index e2eb11590f..cbee8ec0ea 100644 --- a/packages/cli/src/ui/commands/restoreCommand.ts +++ b/packages/cli/src/ui/commands/restoreCommand.ts @@ -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, }; diff --git a/packages/cli/src/ui/commands/resumeCommand.ts b/packages/cli/src/ui/commands/resumeCommand.ts index 6b89daf38a..636dfef1b6 100644 --- a/packages/cli/src/ui/commands/resumeCommand.ts +++ b/packages/cli/src/ui/commands/resumeCommand.ts @@ -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, diff --git a/packages/cli/src/ui/commands/settingsCommand.ts b/packages/cli/src/ui/commands/settingsCommand.ts index b9baf2f735..91b2c50cc6 100644 --- a/packages/cli/src/ui/commands/settingsCommand.ts +++ b/packages/cli/src/ui/commands/settingsCommand.ts @@ -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', diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index 0d2d5dd99f..0ebc9b5056 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -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 => { diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index fbc8e9b6c1..5657d826b1 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -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( { diff --git a/packages/cli/src/ui/commands/terminalSetupCommand.ts b/packages/cli/src/ui/commands/terminalSetupCommand.ts index 09e5240c54..c5772ae5a7 100644 --- a/packages/cli/src/ui/commands/terminalSetupCommand.ts +++ b/packages/cli/src/ui/commands/terminalSetupCommand.ts @@ -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 => { try { const result = await terminalSetup(); diff --git a/packages/cli/src/ui/commands/themeCommand.ts b/packages/cli/src/ui/commands/themeCommand.ts index e702efa25a..4b72625d55 100644 --- a/packages/cli/src/ui/commands/themeCommand.ts +++ b/packages/cli/src/ui/commands/themeCommand.ts @@ -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', diff --git a/packages/cli/src/ui/commands/toolsCommand.ts b/packages/cli/src/ui/commands/toolsCommand.ts index 0fa40636b3..bbb86082f1 100644 --- a/packages/cli/src/ui/commands/toolsCommand.ts +++ b/packages/cli/src/ui/commands/toolsCommand.ts @@ -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 => { const subCommand = args?.trim(); diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 828bbe6ff6..a0f102c3c3 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -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; diff --git a/packages/cli/src/ui/commands/vimCommand.ts b/packages/cli/src/ui/commands/vimCommand.ts index 822fd21bc2..972a230d35 100644 --- a/packages/cli/src/ui/commands/vimCommand.ts +++ b/packages/cli/src/ui/commands/vimCommand.ts @@ -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(); diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 8305f916f1..31babb1559 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -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(, { + 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(, { + 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(, { + 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(, { + 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, diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 20f454059b..3a4d9badcf 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -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 = ({ 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 } diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx index 188c340011..aa976eb078 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -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, }; } diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index 28a7908450..816d24675b 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -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), }; } diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index 3169db8ade..68cabe2431 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -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; +}