mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-16 09:01:17 -07:00
feat(plan): implement plan slash command (#17698)
This commit is contained in:
@@ -98,6 +98,17 @@ vi.mock('../ui/commands/toolsCommand.js', () => ({ toolsCommand: {} }));
|
||||
vi.mock('../ui/commands/skillsCommand.js', () => ({
|
||||
skillsCommand: { name: 'skills' },
|
||||
}));
|
||||
vi.mock('../ui/commands/planCommand.js', async () => {
|
||||
const { CommandKind } = await import('../ui/commands/types.js');
|
||||
return {
|
||||
planCommand: {
|
||||
name: 'plan',
|
||||
description: 'Plan command',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../ui/commands/mcpCommand.js', () => ({
|
||||
mcpCommand: {
|
||||
name: 'mcp',
|
||||
@@ -115,6 +126,7 @@ describe('BuiltinCommandLoader', () => {
|
||||
vi.clearAllMocks();
|
||||
mockConfig = {
|
||||
getFolderTrust: vi.fn().mockReturnValue(true),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(false),
|
||||
getEnableExtensionReloading: () => false,
|
||||
getEnableHooks: () => false,
|
||||
getEnableHooksUI: () => false,
|
||||
@@ -216,6 +228,22 @@ describe('BuiltinCommandLoader', () => {
|
||||
expect(agentsCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include plan command when plan mode is enabled', async () => {
|
||||
(mockConfig.isPlanEnabled as Mock).mockReturnValue(true);
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
const planCmd = commands.find((c) => c.name === 'plan');
|
||||
expect(planCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('should exclude plan command when plan mode is disabled', async () => {
|
||||
(mockConfig.isPlanEnabled as Mock).mockReturnValue(false);
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
const planCmd = commands.find((c) => c.name === 'plan');
|
||||
expect(planCmd).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should exclude agents command when agents are disabled', async () => {
|
||||
mockConfig.isAgentsEnabled = vi.fn().mockReturnValue(false);
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
@@ -256,6 +284,7 @@ describe('BuiltinCommandLoader profile', () => {
|
||||
vi.resetModules();
|
||||
mockConfig = {
|
||||
getFolderTrust: vi.fn().mockReturnValue(false),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(false),
|
||||
getCheckpointingEnabled: () => false,
|
||||
getEnableExtensionReloading: () => false,
|
||||
getEnableHooks: () => false,
|
||||
|
||||
@@ -40,8 +40,9 @@ import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { modelCommand } from '../ui/commands/modelCommand.js';
|
||||
import { oncallCommand } from '../ui/commands/oncallCommand.js';
|
||||
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
|
||||
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
||||
import { planCommand } from '../ui/commands/planCommand.js';
|
||||
import { policiesCommand } from '../ui/commands/policiesCommand.js';
|
||||
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
||||
import { profileCommand } from '../ui/commands/profileCommand.js';
|
||||
import { quitCommand } from '../ui/commands/quitCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
@@ -142,8 +143,9 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
memoryCommand,
|
||||
modelCommand,
|
||||
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
|
||||
privacyCommand,
|
||||
...(this.config?.isPlanEnabled() ? [planCommand] : []),
|
||||
policiesCommand,
|
||||
privacyCommand,
|
||||
...(isDevelopment ? [profileCommand] : []),
|
||||
quitCommand,
|
||||
restoreCommand(this.config),
|
||||
|
||||
133
packages/cli/src/ui/commands/planCommand.test.ts
Normal file
133
packages/cli/src/ui/commands/planCommand.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { planCommand } from './planCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import {
|
||||
ApprovalMode,
|
||||
coreEvents,
|
||||
processSingleFileContent,
|
||||
type ProcessedFileReadResult,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
coreEvents: {
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
processSingleFileContent: vi.fn(),
|
||||
partToString: vi.fn((val) => val),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:path', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:path')>();
|
||||
return {
|
||||
...actual,
|
||||
default: { ...actual },
|
||||
join: vi.fn((...args) => args.join('/')),
|
||||
};
|
||||
});
|
||||
|
||||
describe('planCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
isPlanEnabled: vi.fn(),
|
||||
setApprovalMode: vi.fn(),
|
||||
getApprovedPlanPath: vi.fn(),
|
||||
getApprovalMode: vi.fn(),
|
||||
getFileSystemService: vi.fn(),
|
||||
storage: {
|
||||
getProjectTempPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
|
||||
},
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
addItem: vi.fn(),
|
||||
},
|
||||
} as unknown as CommandContext);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(planCommand.name).toBe('plan');
|
||||
expect(planCommand.description).toBe(
|
||||
'Switch to Plan Mode and view current plan',
|
||||
);
|
||||
});
|
||||
|
||||
it('should switch to plan mode if enabled', async () => {
|
||||
vi.mocked(mockContext.services.config!.isPlanEnabled).mockReturnValue(true);
|
||||
vi.mocked(mockContext.services.config!.getApprovedPlanPath).mockReturnValue(
|
||||
undefined,
|
||||
);
|
||||
|
||||
if (!planCommand.action) throw new Error('Action missing');
|
||||
await planCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.services.config!.setApprovalMode).toHaveBeenCalledWith(
|
||||
ApprovalMode.PLAN,
|
||||
);
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'Switched to Plan Mode.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should show "No approved plan found" if no approved plan path in config', async () => {
|
||||
vi.mocked(mockContext.services.config!.isPlanEnabled).mockReturnValue(true);
|
||||
vi.mocked(mockContext.services.config!.getApprovedPlanPath).mockReturnValue(
|
||||
undefined,
|
||||
);
|
||||
|
||||
if (!planCommand.action) throw new Error('Action missing');
|
||||
await planCommand.action(mockContext, '');
|
||||
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'No approved plan found. Please create and approve a plan first.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should display the approved plan from config', async () => {
|
||||
const mockPlanPath = '/mock/plans/dir/approved-plan.md';
|
||||
vi.mocked(mockContext.services.config!.isPlanEnabled).mockReturnValue(true);
|
||||
vi.mocked(mockContext.services.config!.getApprovedPlanPath).mockReturnValue(
|
||||
mockPlanPath,
|
||||
);
|
||||
vi.mocked(processSingleFileContent).mockResolvedValue({
|
||||
llmContent: '# Approved Plan Content',
|
||||
returnDisplay: '# Approved Plan Content',
|
||||
} as ProcessedFileReadResult);
|
||||
|
||||
if (!planCommand.action) throw new Error('Action missing');
|
||||
await planCommand.action(mockContext, '');
|
||||
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'Approved Plan: approved-plan.md',
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
|
||||
type: MessageType.GEMINI,
|
||||
text: '# Approved Plan Content',
|
||||
});
|
||||
});
|
||||
});
|
||||
69
packages/cli/src/ui/commands/planCommand.ts
Normal file
69
packages/cli/src/ui/commands/planCommand.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
import {
|
||||
ApprovalMode,
|
||||
coreEvents,
|
||||
debugLogger,
|
||||
processSingleFileContent,
|
||||
partToString,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { MessageType } from '../types.js';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export const planCommand: SlashCommand = {
|
||||
name: 'plan',
|
||||
description: 'Switch to Plan Mode and view current plan',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (context) => {
|
||||
const config = context.services.config;
|
||||
if (!config) {
|
||||
debugLogger.debug('Plan command: config is not available in context');
|
||||
return;
|
||||
}
|
||||
|
||||
const previousApprovalMode = config.getApprovalMode();
|
||||
config.setApprovalMode(ApprovalMode.PLAN);
|
||||
|
||||
if (previousApprovalMode !== ApprovalMode.PLAN) {
|
||||
coreEvents.emitFeedback('info', 'Switched to Plan Mode.');
|
||||
}
|
||||
|
||||
const approvedPlanPath = config.getApprovedPlanPath();
|
||||
|
||||
if (!approvedPlanPath) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
'No approved plan found. Please create and approve a plan first.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await processSingleFileContent(
|
||||
approvedPlanPath,
|
||||
config.storage.getProjectTempPlansDir(),
|
||||
config.getFileSystemService(),
|
||||
);
|
||||
const fileName = path.basename(approvedPlanPath);
|
||||
|
||||
coreEvents.emitFeedback('info', `Approved Plan: ${fileName}`);
|
||||
|
||||
context.ui.addItem({
|
||||
type: MessageType.GEMINI,
|
||||
text: partToString(content.llmContent),
|
||||
});
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Failed to read approved plan at ${approvedPlanPath}: ${error}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -627,9 +627,12 @@ export class Config {
|
||||
private latestApiRequest: GenerateContentParameters | undefined;
|
||||
private lastModeSwitchTime: number = Date.now();
|
||||
|
||||
private approvedPlanPath: string | undefined;
|
||||
|
||||
constructor(params: ConfigParameters) {
|
||||
this.sessionId = params.sessionId;
|
||||
this.clientVersion = params.clientVersion ?? 'unknown';
|
||||
this.approvedPlanPath = undefined;
|
||||
this.embeddingModel =
|
||||
params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL;
|
||||
this.fileSystemService = new StandardFileSystemService();
|
||||
@@ -1706,6 +1709,14 @@ export class Config {
|
||||
return this.planEnabled;
|
||||
}
|
||||
|
||||
getApprovedPlanPath(): string | undefined {
|
||||
return this.approvedPlanPath;
|
||||
}
|
||||
|
||||
setApprovedPlanPath(path: string | undefined): void {
|
||||
this.approvedPlanPath = path;
|
||||
}
|
||||
|
||||
isAgentsEnabled(): boolean {
|
||||
return this.enableAgents;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ describe('ExitPlanModeTool', () => {
|
||||
mockConfig = {
|
||||
getTargetDir: vi.fn().mockReturnValue(tempRootDir),
|
||||
setApprovalMode: vi.fn(),
|
||||
setApprovedPlanPath: vi.fn(),
|
||||
storage: {
|
||||
getProjectTempPlansDir: vi.fn().mockReturnValue(mockPlansDir),
|
||||
} as unknown as Config['storage'],
|
||||
@@ -200,6 +201,7 @@ The approved implementation plan is stored at: ${expectedPath}
|
||||
Read and follow the plan strictly during implementation.`,
|
||||
returnDisplay: `Plan approved: ${expectedPath}`,
|
||||
});
|
||||
expect(mockConfig.setApprovedPlanPath).toHaveBeenCalledWith(expectedPath);
|
||||
});
|
||||
|
||||
it('should return approval message when plan is approved with AUTO_EDIT mode', async () => {
|
||||
@@ -230,6 +232,7 @@ Read and follow the plan strictly during implementation.`,
|
||||
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
|
||||
ApprovalMode.AUTO_EDIT,
|
||||
);
|
||||
expect(mockConfig.setApprovedPlanPath).toHaveBeenCalledWith(expectedPath);
|
||||
});
|
||||
|
||||
it('should return feedback message when plan is rejected with feedback', async () => {
|
||||
|
||||
@@ -224,6 +224,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
|
||||
if (payload?.approved) {
|
||||
const newMode = payload.approvalMode ?? ApprovalMode.DEFAULT;
|
||||
this.config.setApprovalMode(newMode);
|
||||
this.config.setApprovedPlanPath(resolvedPlanPath);
|
||||
|
||||
const description = getApprovalModeDescription(newMode);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user