feat(plan): implement plan slash command (#17698)

This commit is contained in:
Adib234
2026-02-04 12:01:43 -05:00
committed by GitHub
parent fedc0c5d60
commit 0012d95848
7 changed files with 250 additions and 2 deletions

View File

@@ -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,

View File

@@ -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),

View 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',
});
});
});

View 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,
);
}
},
};

View File

@@ -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;
}

View File

@@ -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 () => {

View File

@@ -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);