mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -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', () => ({
|
vi.mock('../ui/commands/skillsCommand.js', () => ({
|
||||||
skillsCommand: { name: 'skills' },
|
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', () => ({
|
vi.mock('../ui/commands/mcpCommand.js', () => ({
|
||||||
mcpCommand: {
|
mcpCommand: {
|
||||||
name: 'mcp',
|
name: 'mcp',
|
||||||
@@ -115,6 +126,7 @@ describe('BuiltinCommandLoader', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockConfig = {
|
mockConfig = {
|
||||||
getFolderTrust: vi.fn().mockReturnValue(true),
|
getFolderTrust: vi.fn().mockReturnValue(true),
|
||||||
|
isPlanEnabled: vi.fn().mockReturnValue(false),
|
||||||
getEnableExtensionReloading: () => false,
|
getEnableExtensionReloading: () => false,
|
||||||
getEnableHooks: () => false,
|
getEnableHooks: () => false,
|
||||||
getEnableHooksUI: () => false,
|
getEnableHooksUI: () => false,
|
||||||
@@ -216,6 +228,22 @@ describe('BuiltinCommandLoader', () => {
|
|||||||
expect(agentsCmd).toBeDefined();
|
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 () => {
|
it('should exclude agents command when agents are disabled', async () => {
|
||||||
mockConfig.isAgentsEnabled = vi.fn().mockReturnValue(false);
|
mockConfig.isAgentsEnabled = vi.fn().mockReturnValue(false);
|
||||||
const loader = new BuiltinCommandLoader(mockConfig);
|
const loader = new BuiltinCommandLoader(mockConfig);
|
||||||
@@ -256,6 +284,7 @@ describe('BuiltinCommandLoader profile', () => {
|
|||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
mockConfig = {
|
mockConfig = {
|
||||||
getFolderTrust: vi.fn().mockReturnValue(false),
|
getFolderTrust: vi.fn().mockReturnValue(false),
|
||||||
|
isPlanEnabled: vi.fn().mockReturnValue(false),
|
||||||
getCheckpointingEnabled: () => false,
|
getCheckpointingEnabled: () => false,
|
||||||
getEnableExtensionReloading: () => false,
|
getEnableExtensionReloading: () => false,
|
||||||
getEnableHooks: () => false,
|
getEnableHooks: () => false,
|
||||||
|
|||||||
@@ -40,8 +40,9 @@ import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
|||||||
import { modelCommand } from '../ui/commands/modelCommand.js';
|
import { modelCommand } from '../ui/commands/modelCommand.js';
|
||||||
import { oncallCommand } from '../ui/commands/oncallCommand.js';
|
import { oncallCommand } from '../ui/commands/oncallCommand.js';
|
||||||
import { permissionsCommand } from '../ui/commands/permissionsCommand.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 { policiesCommand } from '../ui/commands/policiesCommand.js';
|
||||||
|
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
||||||
import { profileCommand } from '../ui/commands/profileCommand.js';
|
import { profileCommand } from '../ui/commands/profileCommand.js';
|
||||||
import { quitCommand } from '../ui/commands/quitCommand.js';
|
import { quitCommand } from '../ui/commands/quitCommand.js';
|
||||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||||
@@ -142,8 +143,9 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||||||
memoryCommand,
|
memoryCommand,
|
||||||
modelCommand,
|
modelCommand,
|
||||||
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
|
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
|
||||||
privacyCommand,
|
...(this.config?.isPlanEnabled() ? [planCommand] : []),
|
||||||
policiesCommand,
|
policiesCommand,
|
||||||
|
privacyCommand,
|
||||||
...(isDevelopment ? [profileCommand] : []),
|
...(isDevelopment ? [profileCommand] : []),
|
||||||
quitCommand,
|
quitCommand,
|
||||||
restoreCommand(this.config),
|
restoreCommand(this.config),
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 latestApiRequest: GenerateContentParameters | undefined;
|
||||||
private lastModeSwitchTime: number = Date.now();
|
private lastModeSwitchTime: number = Date.now();
|
||||||
|
|
||||||
|
private approvedPlanPath: string | undefined;
|
||||||
|
|
||||||
constructor(params: ConfigParameters) {
|
constructor(params: ConfigParameters) {
|
||||||
this.sessionId = params.sessionId;
|
this.sessionId = params.sessionId;
|
||||||
this.clientVersion = params.clientVersion ?? 'unknown';
|
this.clientVersion = params.clientVersion ?? 'unknown';
|
||||||
|
this.approvedPlanPath = undefined;
|
||||||
this.embeddingModel =
|
this.embeddingModel =
|
||||||
params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL;
|
params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL;
|
||||||
this.fileSystemService = new StandardFileSystemService();
|
this.fileSystemService = new StandardFileSystemService();
|
||||||
@@ -1706,6 +1709,14 @@ export class Config {
|
|||||||
return this.planEnabled;
|
return this.planEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getApprovedPlanPath(): string | undefined {
|
||||||
|
return this.approvedPlanPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
setApprovedPlanPath(path: string | undefined): void {
|
||||||
|
this.approvedPlanPath = path;
|
||||||
|
}
|
||||||
|
|
||||||
isAgentsEnabled(): boolean {
|
isAgentsEnabled(): boolean {
|
||||||
return this.enableAgents;
|
return this.enableAgents;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ describe('ExitPlanModeTool', () => {
|
|||||||
mockConfig = {
|
mockConfig = {
|
||||||
getTargetDir: vi.fn().mockReturnValue(tempRootDir),
|
getTargetDir: vi.fn().mockReturnValue(tempRootDir),
|
||||||
setApprovalMode: vi.fn(),
|
setApprovalMode: vi.fn(),
|
||||||
|
setApprovedPlanPath: vi.fn(),
|
||||||
storage: {
|
storage: {
|
||||||
getProjectTempPlansDir: vi.fn().mockReturnValue(mockPlansDir),
|
getProjectTempPlansDir: vi.fn().mockReturnValue(mockPlansDir),
|
||||||
} as unknown as Config['storage'],
|
} 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.`,
|
Read and follow the plan strictly during implementation.`,
|
||||||
returnDisplay: `Plan approved: ${expectedPath}`,
|
returnDisplay: `Plan approved: ${expectedPath}`,
|
||||||
});
|
});
|
||||||
|
expect(mockConfig.setApprovedPlanPath).toHaveBeenCalledWith(expectedPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return approval message when plan is approved with AUTO_EDIT mode', async () => {
|
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(
|
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
|
||||||
ApprovalMode.AUTO_EDIT,
|
ApprovalMode.AUTO_EDIT,
|
||||||
);
|
);
|
||||||
|
expect(mockConfig.setApprovedPlanPath).toHaveBeenCalledWith(expectedPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return feedback message when plan is rejected with feedback', async () => {
|
it('should return feedback message when plan is rejected with feedback', async () => {
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
|
|||||||
if (payload?.approved) {
|
if (payload?.approved) {
|
||||||
const newMode = payload.approvalMode ?? ApprovalMode.DEFAULT;
|
const newMode = payload.approvalMode ?? ApprovalMode.DEFAULT;
|
||||||
this.config.setApprovalMode(newMode);
|
this.config.setApprovalMode(newMode);
|
||||||
|
this.config.setApprovedPlanPath(resolvedPlanPath);
|
||||||
|
|
||||||
const description = getApprovalModeDescription(newMode);
|
const description = getApprovalModeDescription(newMode);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user