diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 2163e4fcd1..ad87bc591b 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -39,7 +39,9 @@ To start Plan Mode while using Gemini CLI: the rotation when Gemini CLI is actively processing or showing confirmation dialogs. -- **Command:** Type `/plan` in the input box. +- **Command:** Type `/plan [goal]` in the input box. The `[goal]` is optional; + for example, `/plan implement authentication` will switch to Plan Mode and + immediately submit the prompt to the model. - **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI calls the diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index 87aacb056b..e7a33672f3 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -75,6 +75,7 @@ const listCommand: SlashCommand = { description: 'List saved manual conversation checkpoints', kind: CommandKind.BUILT_IN, autoExecute: true, + takesArgs: false, action: async (context): Promise => { const chatDetails = await getSavedChatTags(context, false); @@ -406,14 +407,24 @@ export const chatResumeSubCommands: SlashCommand[] = [ checkpointCompatibilityCommand, ]; +import { parseSlashCommand } from '../../utils/commands.js'; + export const chatCommand: SlashCommand = { name: 'chat', description: 'Browse auto-saved conversations and manage chat checkpoints', kind: CommandKind.BUILT_IN, autoExecute: true, - action: async () => ({ - type: 'dialog', - dialog: 'sessionBrowser', - }), + action: async (context, args) => { + if (args) { + const parsed = parseSlashCommand(`/${args}`, chatResumeSubCommands); + if (parsed.commandToExecute?.action) { + return parsed.commandToExecute.action(context, parsed.args); + } + } + return { + type: 'dialog', + dialog: 'sessionBrowser', + }; + }, subCommands: chatResumeSubCommands, }; diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index aed7595389..7a3ada83e0 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -789,6 +789,7 @@ const listExtensionsCommand: SlashCommand = { description: 'List active extensions', kind: CommandKind.BUILT_IN, autoExecute: true, + takesArgs: false, action: listAction, }; @@ -849,6 +850,7 @@ const exploreExtensionsCommand: SlashCommand = { description: 'Open extensions page in your browser', kind: CommandKind.BUILT_IN, autoExecute: true, + takesArgs: false, action: exploreAction, }; @@ -870,6 +872,8 @@ const configCommand: SlashCommand = { action: configAction, }; +import { parseSlashCommand } from '../../utils/commands.js'; + export function extensionsCommand( enableExtensionReloading?: boolean, ): SlashCommand { @@ -883,20 +887,29 @@ export function extensionsCommand( configCommand, ] : []; + const subCommands = [ + listExtensionsCommand, + updateExtensionsCommand, + exploreExtensionsCommand, + reloadCommand, + ...conditionalCommands, + ]; + return { name: 'extensions', description: 'Manage extensions', kind: CommandKind.BUILT_IN, autoExecute: false, - subCommands: [ - listExtensionsCommand, - updateExtensionsCommand, - exploreExtensionsCommand, - reloadCommand, - ...conditionalCommands, - ], - action: (context, args) => + subCommands, + action: async (context, args) => { + if (args) { + const parsed = parseSlashCommand(`/${args}`, subCommands); + if (parsed.commandToExecute?.action) { + return parsed.commandToExecute.action(context, parsed.args); + } + } // Default to list if no subcommand is provided - listExtensionsCommand.action!(context, args), + return listExtensionsCommand.action!(context, args); + }, }; } diff --git a/packages/cli/src/ui/commands/mcpCommand.test.ts b/packages/cli/src/ui/commands/mcpCommand.test.ts index 9a3254fbae..d082c4ed09 100644 --- a/packages/cli/src/ui/commands/mcpCommand.test.ts +++ b/packages/cli/src/ui/commands/mcpCommand.test.ts @@ -17,7 +17,7 @@ import { } from '@google/gemini-cli-core'; import type { CallableTool } from '@google/genai'; -import { MessageType } from '../types.js'; +import { MessageType, type HistoryItemMcpStatus } from '../types.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = @@ -280,5 +280,41 @@ describe('mcpCommand', () => { }), ); }); + + it('should filter servers by name when an argument is provided to list', async () => { + await mcpCommand.action!(mockContext, 'list server1'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.MCP_STATUS, + servers: expect.objectContaining({ + server1: expect.any(Object), + }), + }), + ); + + // Should NOT contain server2 or server3 + const call = vi.mocked(mockContext.ui.addItem).mock + .calls[0][0] as HistoryItemMcpStatus; + expect(Object.keys(call.servers)).toEqual(['server1']); + }); + + it('should filter servers by name and show descriptions when an argument is provided to desc', async () => { + await mcpCommand.action!(mockContext, 'desc server2'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.MCP_STATUS, + showDescriptions: true, + servers: expect.objectContaining({ + server2: expect.any(Object), + }), + }), + ); + + const call = vi.mocked(mockContext.ui.addItem).mock + .calls[0][0] as HistoryItemMcpStatus; + expect(Object.keys(call.servers)).toEqual(['server2']); + }); }); }); diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index 0fb6b5a1dd..3fd214152e 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -31,6 +31,7 @@ import { canLoadServer, } from '../../config/mcp/mcpServerEnablement.js'; import { loadSettings } from '../../config/settings.js'; +import { parseSlashCommand } from '../../utils/commands.js'; const authCommand: SlashCommand = { name: 'auth', @@ -177,6 +178,7 @@ const listAction = async ( context: CommandContext, showDescriptions = false, showSchema = false, + serverNameFilter?: string, ): Promise => { const agentContext = context.services.agentContext; const config = agentContext?.config; @@ -199,11 +201,25 @@ const listAction = async ( }; } - const mcpServers = config.getMcpClientManager()?.getMcpServers() || {}; - const serverNames = Object.keys(mcpServers); + let mcpServers = config.getMcpClientManager()?.getMcpServers() || {}; const blockedMcpServers = config.getMcpClientManager()?.getBlockedMcpServers() || []; + if (serverNameFilter) { + const filter = serverNameFilter.trim().toLowerCase(); + if (filter) { + mcpServers = Object.fromEntries( + Object.entries(mcpServers).filter( + ([name]) => + name.toLowerCase().includes(filter) || + normalizeServerId(name).includes(filter), + ), + ); + } + } + + const serverNames = Object.keys(mcpServers); + const connectingServers = serverNames.filter( (name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING, ); @@ -306,7 +322,7 @@ const listCommand: SlashCommand = { description: 'List configured MCP servers and tools', kind: CommandKind.BUILT_IN, autoExecute: true, - action: (context) => listAction(context), + action: (context, args) => listAction(context, false, false, args), }; const descCommand: SlashCommand = { @@ -315,7 +331,7 @@ const descCommand: SlashCommand = { description: 'List configured MCP servers and tools with descriptions', kind: CommandKind.BUILT_IN, autoExecute: true, - action: (context) => listAction(context, true), + action: (context, args) => listAction(context, true, false, args), }; const schemaCommand: SlashCommand = { @@ -324,7 +340,7 @@ const schemaCommand: SlashCommand = { 'List configured MCP servers and tools with descriptions and schemas', kind: CommandKind.BUILT_IN, autoExecute: true, - action: (context) => listAction(context, true, true), + action: (context, args) => listAction(context, true, true, args), }; const reloadCommand: SlashCommand = { @@ -333,6 +349,7 @@ const reloadCommand: SlashCommand = { description: 'Reloads MCP servers', kind: CommandKind.BUILT_IN, autoExecute: true, + takesArgs: false, action: async ( context: CommandContext, ): Promise => { @@ -530,5 +547,18 @@ export const mcpCommand: SlashCommand = { enableCommand, disableCommand, ], - action: async (context: CommandContext) => listAction(context), + action: async ( + context: CommandContext, + args: string, + ): Promise => { + if (args) { + const parsed = parseSlashCommand(`/${args}`, mcpCommand.subCommands!); + if (parsed.commandToExecute?.action) { + return parsed.commandToExecute.action(context, parsed.args); + } + // If no subcommand matches, treat the whole args as a filter for list + return listAction(context, false, false, args); + } + return listAction(context); + }, }; diff --git a/packages/cli/src/ui/commands/planCommand.test.ts b/packages/cli/src/ui/commands/planCommand.test.ts index 49c00ce8bd..56b6949750 100644 --- a/packages/cli/src/ui/commands/planCommand.test.ts +++ b/packages/cli/src/ui/commands/planCommand.test.ts @@ -104,6 +104,47 @@ describe('planCommand', () => { ); }); + it('should not return a submit_prompt action if arguments are empty', async () => { + vi.mocked( + mockContext.services.agentContext!.config.isPlanEnabled, + ).mockReturnValue(true); + mockContext.invocation = { + raw: '/plan', + name: 'plan', + args: '', + }; + + if (!planCommand.action) throw new Error('Action missing'); + const result = await planCommand.action(mockContext, ''); + + expect(result).toBeUndefined(); + expect( + mockContext.services.agentContext!.config.setApprovalMode, + ).toHaveBeenCalledWith(ApprovalMode.PLAN); + }); + + it('should return a submit_prompt action if arguments are provided', async () => { + vi.mocked( + mockContext.services.agentContext!.config.isPlanEnabled, + ).mockReturnValue(true); + mockContext.invocation = { + raw: '/plan implement auth', + name: 'plan', + args: 'implement auth', + }; + + if (!planCommand.action) throw new Error('Action missing'); + const result = await planCommand.action(mockContext, 'implement auth'); + + expect(result).toEqual({ + type: 'submit_prompt', + content: 'implement auth', + }); + expect( + mockContext.services.agentContext!.config.setApprovalMode, + ).toHaveBeenCalledWith(ApprovalMode.PLAN); + }); + it('should display the approved plan from config', async () => { const mockPlanPath = '/mock/plans/dir/approved-plan.md'; vi.mocked( diff --git a/packages/cli/src/ui/commands/planCommand.ts b/packages/cli/src/ui/commands/planCommand.ts index c38d021d90..b90c74323c 100644 --- a/packages/cli/src/ui/commands/planCommand.ts +++ b/packages/cli/src/ui/commands/planCommand.ts @@ -66,6 +66,13 @@ export const planCommand: SlashCommand = { coreEvents.emitFeedback('info', 'Switched to Plan Mode.'); } + if (context.invocation?.args) { + return { + type: 'submit_prompt', + content: context.invocation.args, + }; + } + const approvedPlanPath = config.getApprovedPlanPath(); if (!approvedPlanPath) { @@ -86,12 +93,14 @@ export const planCommand: SlashCommand = { type: MessageType.GEMINI, text: partToString(content.llmContent), }); + return; } catch (error) { coreEvents.emitFeedback( 'error', `Failed to read approved plan at ${approvedPlanPath}: ${error}`, error, ); + return; } }, subCommands: [ @@ -100,6 +109,7 @@ export const planCommand: SlashCommand = { description: 'Copy the currently approved plan to your clipboard', kind: CommandKind.BUILT_IN, autoExecute: true, + takesArgs: false, action: copyAction, }, ], diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts index a1f9c82445..8c8db2fca5 100644 --- a/packages/cli/src/ui/commands/skillsCommand.ts +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -357,6 +357,8 @@ function enableCompletion( .map((s) => s.name); } +import { parseSlashCommand } from '../../utils/commands.js'; + export const skillsCommand: SlashCommand = { name: 'skills', description: @@ -402,5 +404,13 @@ export const skillsCommand: SlashCommand = { action: reloadAction, }, ], - action: listAction, + action: async (context, args) => { + if (args) { + const parsed = parseSlashCommand(`/${args}`, skillsCommand.subCommands!); + if (parsed.commandToExecute?.action) { + return parsed.commandToExecute.action(context, parsed.args); + } + } + return listAction(context, args); + }, }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 7b48439381..466e70c994 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -240,5 +240,14 @@ export interface SlashCommand { */ showCompletionLoading?: boolean; + /** + * Whether the command expects arguments. + * If false, and the command is a subcommand, the command parser may treat + * any following text as arguments for the parent command instead of this subcommand, + * provided the parent command has an action. + * Defaults to true. + */ + takesArgs?: boolean; + subCommands?: SlashCommand[]; } diff --git a/packages/cli/src/utils/commands.test.ts b/packages/cli/src/utils/commands.test.ts index 85af0c624b..fa2623f1e8 100644 --- a/packages/cli/src/utils/commands.test.ts +++ b/packages/cli/src/utils/commands.test.ts @@ -137,4 +137,105 @@ describe('parseSlashCommand', () => { expect(result.args).toBe(''); expect(result.canonicalPath).toEqual([]); }); + + describe('backtracking', () => { + const backtrackingCommands: readonly SlashCommand[] = [ + { + name: 'parent', + description: 'Parent command', + kind: CommandKind.BUILT_IN, + action: async () => {}, + subCommands: [ + { + name: 'notakes', + description: 'Subcommand that does not take arguments', + kind: CommandKind.BUILT_IN, + takesArgs: false, + action: async () => {}, + }, + { + name: 'takes', + description: 'Subcommand that takes arguments', + kind: CommandKind.BUILT_IN, + takesArgs: true, + action: async () => {}, + }, + ], + }, + ]; + + it('should backtrack to parent if subcommand has takesArgs: false and args are provided', () => { + const result = parseSlashCommand( + '/parent notakes some prompt', + backtrackingCommands, + ); + expect(result.commandToExecute?.name).toBe('parent'); + expect(result.args).toBe('notakes some prompt'); + expect(result.canonicalPath).toEqual(['parent']); + }); + + it('should NOT backtrack if subcommand has takesArgs: false but NO args are provided', () => { + const result = parseSlashCommand('/parent notakes', backtrackingCommands); + expect(result.commandToExecute?.name).toBe('notakes'); + expect(result.args).toBe(''); + expect(result.canonicalPath).toEqual(['parent', 'notakes']); + }); + + it('should NOT backtrack if subcommand has takesArgs: true and args are provided', () => { + const result = parseSlashCommand( + '/parent takes some args', + backtrackingCommands, + ); + expect(result.commandToExecute?.name).toBe('takes'); + expect(result.args).toBe('some args'); + expect(result.canonicalPath).toEqual(['parent', 'takes']); + }); + + it('should NOT backtrack if parent has NO action', () => { + const noActionCommands: readonly SlashCommand[] = [ + { + name: 'parent', + description: 'Parent without action', + kind: CommandKind.BUILT_IN, + subCommands: [ + { + name: 'notakes', + description: 'Subcommand without args', + kind: CommandKind.BUILT_IN, + takesArgs: false, + action: async () => {}, + }, + ], + }, + ]; + const result = parseSlashCommand( + '/parent notakes some args', + noActionCommands, + ); + // It stays with the subcommand because parent can't handle it + expect(result.commandToExecute?.name).toBe('notakes'); + expect(result.args).toBe('some args'); + expect(result.canonicalPath).toEqual(['parent', 'notakes']); + }); + + it('should NOT backtrack if subcommand is NOT marked with takesArgs: false', () => { + const result = parseSlashCommand( + '/parent takes some args', + backtrackingCommands, + ); + expect(result.commandToExecute?.name).toBe('takes'); + expect(result.args).toBe('some args'); + expect(result.canonicalPath).toEqual(['parent', 'takes']); + }); + + it('should backtrack if subcommand has takesArgs: false and args are provided (like /plan copy foo)', () => { + const result = parseSlashCommand( + '/parent notakes some prompt', + backtrackingCommands, + ); + expect(result.commandToExecute?.name).toBe('parent'); + expect(result.args).toBe('notakes some prompt'); + expect(result.canonicalPath).toEqual(['parent']); + }); + }); }); diff --git a/packages/cli/src/utils/commands.ts b/packages/cli/src/utils/commands.ts index c96c8c6ef7..a96537aadf 100644 --- a/packages/cli/src/utils/commands.ts +++ b/packages/cli/src/utils/commands.ts @@ -33,6 +33,7 @@ export const parseSlashCommand = ( let commandToExecute: SlashCommand | undefined; let pathIndex = 0; const canonicalPath: string[] = []; + let parentCommand: SlashCommand | undefined; for (const part of commandPath) { // TODO: For better performance and architectural clarity, this two-pass @@ -52,6 +53,7 @@ export const parseSlashCommand = ( } if (foundCommand) { + parentCommand = commandToExecute; commandToExecute = foundCommand; canonicalPath.push(foundCommand.name); pathIndex++; @@ -67,5 +69,21 @@ export const parseSlashCommand = ( const args = parts.slice(pathIndex).join(' '); + // Backtrack if the matched (sub)command doesn't take arguments but some were provided, + // AND the parent command is capable of handling them. + if ( + commandToExecute && + commandToExecute.takesArgs === false && + args.length > 0 && + parentCommand && + parentCommand.action + ) { + return { + commandToExecute: parentCommand, + args: parts.slice(pathIndex - 1).join(' '), + canonicalPath: canonicalPath.slice(0, -1), + }; + } + return { commandToExecute, args, canonicalPath }; };