mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-01 00:40:42 -07:00
Text can be added after /plan command (#22833)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<void> => {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void | MessageActionReturn> => {
|
||||
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<void | SlashCommandActionReturn> => {
|
||||
@@ -530,5 +547,18 @@ export const mcpCommand: SlashCommand = {
|
||||
enableCommand,
|
||||
disableCommand,
|
||||
],
|
||||
action: async (context: CommandContext) => listAction(context),
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<void | SlashCommandActionReturn> => {
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user