Skip to content

Commit 9cf4104

Browse files
authored
Text can be added after /plan command (#22833)
1 parent a255529 commit 9cf4104

11 files changed

Lines changed: 303 additions & 22 deletions

File tree

docs/cli/plan-mode.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ To start Plan Mode while using Gemini CLI:
3939
the rotation when Gemini CLI is actively processing or showing confirmation
4040
dialogs.
4141

42-
- **Command:** Type `/plan` in the input box.
42+
- **Command:** Type `/plan [goal]` in the input box. The `[goal]` is optional;
43+
for example, `/plan implement authentication` will switch to Plan Mode and
44+
immediately submit the prompt to the model.
4345

4446
- **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI
4547
calls the

packages/cli/src/ui/commands/chatCommand.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ const listCommand: SlashCommand = {
7575
description: 'List saved manual conversation checkpoints',
7676
kind: CommandKind.BUILT_IN,
7777
autoExecute: true,
78+
takesArgs: false,
7879
action: async (context): Promise<void> => {
7980
const chatDetails = await getSavedChatTags(context, false);
8081

@@ -406,14 +407,24 @@ export const chatResumeSubCommands: SlashCommand[] = [
406407
checkpointCompatibilityCommand,
407408
];
408409

410+
import { parseSlashCommand } from '../../utils/commands.js';
411+
409412
export const chatCommand: SlashCommand = {
410413
name: 'chat',
411414
description: 'Browse auto-saved conversations and manage chat checkpoints',
412415
kind: CommandKind.BUILT_IN,
413416
autoExecute: true,
414-
action: async () => ({
415-
type: 'dialog',
416-
dialog: 'sessionBrowser',
417-
}),
417+
action: async (context, args) => {
418+
if (args) {
419+
const parsed = parseSlashCommand(`/${args}`, chatResumeSubCommands);
420+
if (parsed.commandToExecute?.action) {
421+
return parsed.commandToExecute.action(context, parsed.args);
422+
}
423+
}
424+
return {
425+
type: 'dialog',
426+
dialog: 'sessionBrowser',
427+
};
428+
},
418429
subCommands: chatResumeSubCommands,
419430
};

packages/cli/src/ui/commands/extensionsCommand.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,7 @@ const listExtensionsCommand: SlashCommand = {
789789
description: 'List active extensions',
790790
kind: CommandKind.BUILT_IN,
791791
autoExecute: true,
792+
takesArgs: false,
792793
action: listAction,
793794
};
794795

@@ -849,6 +850,7 @@ const exploreExtensionsCommand: SlashCommand = {
849850
description: 'Open extensions page in your browser',
850851
kind: CommandKind.BUILT_IN,
851852
autoExecute: true,
853+
takesArgs: false,
852854
action: exploreAction,
853855
};
854856

@@ -870,6 +872,8 @@ const configCommand: SlashCommand = {
870872
action: configAction,
871873
};
872874

875+
import { parseSlashCommand } from '../../utils/commands.js';
876+
873877
export function extensionsCommand(
874878
enableExtensionReloading?: boolean,
875879
): SlashCommand {
@@ -883,20 +887,29 @@ export function extensionsCommand(
883887
configCommand,
884888
]
885889
: [];
890+
const subCommands = [
891+
listExtensionsCommand,
892+
updateExtensionsCommand,
893+
exploreExtensionsCommand,
894+
reloadCommand,
895+
...conditionalCommands,
896+
];
897+
886898
return {
887899
name: 'extensions',
888900
description: 'Manage extensions',
889901
kind: CommandKind.BUILT_IN,
890902
autoExecute: false,
891-
subCommands: [
892-
listExtensionsCommand,
893-
updateExtensionsCommand,
894-
exploreExtensionsCommand,
895-
reloadCommand,
896-
...conditionalCommands,
897-
],
898-
action: (context, args) =>
903+
subCommands,
904+
action: async (context, args) => {
905+
if (args) {
906+
const parsed = parseSlashCommand(`/${args}`, subCommands);
907+
if (parsed.commandToExecute?.action) {
908+
return parsed.commandToExecute.action(context, parsed.args);
909+
}
910+
}
899911
// Default to list if no subcommand is provided
900-
listExtensionsCommand.action!(context, args),
912+
return listExtensionsCommand.action!(context, args);
913+
},
901914
};
902915
}

packages/cli/src/ui/commands/mcpCommand.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
} from '@google/gemini-cli-core';
1818

1919
import type { CallableTool } from '@google/genai';
20-
import { MessageType } from '../types.js';
20+
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
2121

2222
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
2323
const actual =
@@ -280,5 +280,41 @@ describe('mcpCommand', () => {
280280
}),
281281
);
282282
});
283+
284+
it('should filter servers by name when an argument is provided to list', async () => {
285+
await mcpCommand.action!(mockContext, 'list server1');
286+
287+
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
288+
expect.objectContaining({
289+
type: MessageType.MCP_STATUS,
290+
servers: expect.objectContaining({
291+
server1: expect.any(Object),
292+
}),
293+
}),
294+
);
295+
296+
// Should NOT contain server2 or server3
297+
const call = vi.mocked(mockContext.ui.addItem).mock
298+
.calls[0][0] as HistoryItemMcpStatus;
299+
expect(Object.keys(call.servers)).toEqual(['server1']);
300+
});
301+
302+
it('should filter servers by name and show descriptions when an argument is provided to desc', async () => {
303+
await mcpCommand.action!(mockContext, 'desc server2');
304+
305+
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
306+
expect.objectContaining({
307+
type: MessageType.MCP_STATUS,
308+
showDescriptions: true,
309+
servers: expect.objectContaining({
310+
server2: expect.any(Object),
311+
}),
312+
}),
313+
);
314+
315+
const call = vi.mocked(mockContext.ui.addItem).mock
316+
.calls[0][0] as HistoryItemMcpStatus;
317+
expect(Object.keys(call.servers)).toEqual(['server2']);
318+
});
283319
});
284320
});

packages/cli/src/ui/commands/mcpCommand.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
canLoadServer,
3232
} from '../../config/mcp/mcpServerEnablement.js';
3333
import { loadSettings } from '../../config/settings.js';
34+
import { parseSlashCommand } from '../../utils/commands.js';
3435

3536
const authCommand: SlashCommand = {
3637
name: 'auth',
@@ -177,6 +178,7 @@ const listAction = async (
177178
context: CommandContext,
178179
showDescriptions = false,
179180
showSchema = false,
181+
serverNameFilter?: string,
180182
): Promise<void | MessageActionReturn> => {
181183
const agentContext = context.services.agentContext;
182184
const config = agentContext?.config;
@@ -199,11 +201,25 @@ const listAction = async (
199201
};
200202
}
201203

202-
const mcpServers = config.getMcpClientManager()?.getMcpServers() || {};
203-
const serverNames = Object.keys(mcpServers);
204+
let mcpServers = config.getMcpClientManager()?.getMcpServers() || {};
204205
const blockedMcpServers =
205206
config.getMcpClientManager()?.getBlockedMcpServers() || [];
206207

208+
if (serverNameFilter) {
209+
const filter = serverNameFilter.trim().toLowerCase();
210+
if (filter) {
211+
mcpServers = Object.fromEntries(
212+
Object.entries(mcpServers).filter(
213+
([name]) =>
214+
name.toLowerCase().includes(filter) ||
215+
normalizeServerId(name).includes(filter),
216+
),
217+
);
218+
}
219+
}
220+
221+
const serverNames = Object.keys(mcpServers);
222+
207223
const connectingServers = serverNames.filter(
208224
(name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING,
209225
);
@@ -306,7 +322,7 @@ const listCommand: SlashCommand = {
306322
description: 'List configured MCP servers and tools',
307323
kind: CommandKind.BUILT_IN,
308324
autoExecute: true,
309-
action: (context) => listAction(context),
325+
action: (context, args) => listAction(context, false, false, args),
310326
};
311327

312328
const descCommand: SlashCommand = {
@@ -315,7 +331,7 @@ const descCommand: SlashCommand = {
315331
description: 'List configured MCP servers and tools with descriptions',
316332
kind: CommandKind.BUILT_IN,
317333
autoExecute: true,
318-
action: (context) => listAction(context, true),
334+
action: (context, args) => listAction(context, true, false, args),
319335
};
320336

321337
const schemaCommand: SlashCommand = {
@@ -324,7 +340,7 @@ const schemaCommand: SlashCommand = {
324340
'List configured MCP servers and tools with descriptions and schemas',
325341
kind: CommandKind.BUILT_IN,
326342
autoExecute: true,
327-
action: (context) => listAction(context, true, true),
343+
action: (context, args) => listAction(context, true, true, args),
328344
};
329345

330346
const reloadCommand: SlashCommand = {
@@ -333,6 +349,7 @@ const reloadCommand: SlashCommand = {
333349
description: 'Reloads MCP servers',
334350
kind: CommandKind.BUILT_IN,
335351
autoExecute: true,
352+
takesArgs: false,
336353
action: async (
337354
context: CommandContext,
338355
): Promise<void | SlashCommandActionReturn> => {
@@ -530,5 +547,18 @@ export const mcpCommand: SlashCommand = {
530547
enableCommand,
531548
disableCommand,
532549
],
533-
action: async (context: CommandContext) => listAction(context),
550+
action: async (
551+
context: CommandContext,
552+
args: string,
553+
): Promise<void | SlashCommandActionReturn> => {
554+
if (args) {
555+
const parsed = parseSlashCommand(`/${args}`, mcpCommand.subCommands!);
556+
if (parsed.commandToExecute?.action) {
557+
return parsed.commandToExecute.action(context, parsed.args);
558+
}
559+
// If no subcommand matches, treat the whole args as a filter for list
560+
return listAction(context, false, false, args);
561+
}
562+
return listAction(context);
563+
},
534564
};

packages/cli/src/ui/commands/planCommand.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,47 @@ describe('planCommand', () => {
104104
);
105105
});
106106

107+
it('should not return a submit_prompt action if arguments are empty', async () => {
108+
vi.mocked(
109+
mockContext.services.agentContext!.config.isPlanEnabled,
110+
).mockReturnValue(true);
111+
mockContext.invocation = {
112+
raw: '/plan',
113+
name: 'plan',
114+
args: '',
115+
};
116+
117+
if (!planCommand.action) throw new Error('Action missing');
118+
const result = await planCommand.action(mockContext, '');
119+
120+
expect(result).toBeUndefined();
121+
expect(
122+
mockContext.services.agentContext!.config.setApprovalMode,
123+
).toHaveBeenCalledWith(ApprovalMode.PLAN);
124+
});
125+
126+
it('should return a submit_prompt action if arguments are provided', async () => {
127+
vi.mocked(
128+
mockContext.services.agentContext!.config.isPlanEnabled,
129+
).mockReturnValue(true);
130+
mockContext.invocation = {
131+
raw: '/plan implement auth',
132+
name: 'plan',
133+
args: 'implement auth',
134+
};
135+
136+
if (!planCommand.action) throw new Error('Action missing');
137+
const result = await planCommand.action(mockContext, 'implement auth');
138+
139+
expect(result).toEqual({
140+
type: 'submit_prompt',
141+
content: 'implement auth',
142+
});
143+
expect(
144+
mockContext.services.agentContext!.config.setApprovalMode,
145+
).toHaveBeenCalledWith(ApprovalMode.PLAN);
146+
});
147+
107148
it('should display the approved plan from config', async () => {
108149
const mockPlanPath = '/mock/plans/dir/approved-plan.md';
109150
vi.mocked(

packages/cli/src/ui/commands/planCommand.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@ export const planCommand: SlashCommand = {
6666
coreEvents.emitFeedback('info', 'Switched to Plan Mode.');
6767
}
6868

69+
if (context.invocation?.args) {
70+
return {
71+
type: 'submit_prompt',
72+
content: context.invocation.args,
73+
};
74+
}
75+
6976
const approvedPlanPath = config.getApprovedPlanPath();
7077

7178
if (!approvedPlanPath) {
@@ -86,12 +93,14 @@ export const planCommand: SlashCommand = {
8693
type: MessageType.GEMINI,
8794
text: partToString(content.llmContent),
8895
});
96+
return;
8997
} catch (error) {
9098
coreEvents.emitFeedback(
9199
'error',
92100
`Failed to read approved plan at ${approvedPlanPath}: ${error}`,
93101
error,
94102
);
103+
return;
95104
}
96105
},
97106
subCommands: [
@@ -100,6 +109,7 @@ export const planCommand: SlashCommand = {
100109
description: 'Copy the currently approved plan to your clipboard',
101110
kind: CommandKind.BUILT_IN,
102111
autoExecute: true,
112+
takesArgs: false,
103113
action: copyAction,
104114
},
105115
],

packages/cli/src/ui/commands/skillsCommand.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,8 @@ function enableCompletion(
357357
.map((s) => s.name);
358358
}
359359

360+
import { parseSlashCommand } from '../../utils/commands.js';
361+
360362
export const skillsCommand: SlashCommand = {
361363
name: 'skills',
362364
description:
@@ -402,5 +404,13 @@ export const skillsCommand: SlashCommand = {
402404
action: reloadAction,
403405
},
404406
],
405-
action: listAction,
407+
action: async (context, args) => {
408+
if (args) {
409+
const parsed = parseSlashCommand(`/${args}`, skillsCommand.subCommands!);
410+
if (parsed.commandToExecute?.action) {
411+
return parsed.commandToExecute.action(context, parsed.args);
412+
}
413+
}
414+
return listAction(context, args);
415+
},
406416
};

packages/cli/src/ui/commands/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,5 +240,14 @@ export interface SlashCommand {
240240
*/
241241
showCompletionLoading?: boolean;
242242

243+
/**
244+
* Whether the command expects arguments.
245+
* If false, and the command is a subcommand, the command parser may treat
246+
* any following text as arguments for the parent command instead of this subcommand,
247+
* provided the parent command has an action.
248+
* Defaults to true.
249+
*/
250+
takesArgs?: boolean;
251+
243252
subCommands?: SlashCommand[];
244253
}

0 commit comments

Comments
 (0)