From 0012d958489489822a6015c9c0f4c40cabc5bccb Mon Sep 17 00:00:00 2001 From: Adib234 <30782825+Adib234@users.noreply.github.com> Date: Wed, 4 Feb 2026 12:01:43 -0500 Subject: [PATCH] feat(plan): implement `plan` slash command (#17698) --- .../src/services/BuiltinCommandLoader.test.ts | 29 ++++ .../cli/src/services/BuiltinCommandLoader.ts | 6 +- .../cli/src/ui/commands/planCommand.test.ts | 133 ++++++++++++++++++ packages/cli/src/ui/commands/planCommand.ts | 69 +++++++++ packages/core/src/config/config.ts | 11 ++ .../core/src/tools/exit-plan-mode.test.ts | 3 + packages/core/src/tools/exit-plan-mode.ts | 1 + 7 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/ui/commands/planCommand.test.ts create mode 100644 packages/cli/src/ui/commands/planCommand.ts diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 2740d9ed3e..2f7a2a5c8a 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -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, diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 75cbe74cc2..3c9b09e739 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -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), diff --git a/packages/cli/src/ui/commands/planCommand.test.ts b/packages/cli/src/ui/commands/planCommand.test.ts new file mode 100644 index 0000000000..410694b2ed --- /dev/null +++ b/packages/cli/src/ui/commands/planCommand.test.ts @@ -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(); + return { + ...actual, + coreEvents: { + emitFeedback: vi.fn(), + }, + processSingleFileContent: vi.fn(), + partToString: vi.fn((val) => val), + }; +}); + +vi.mock('node:path', async (importOriginal) => { + const actual = await importOriginal(); + 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', + }); + }); +}); diff --git a/packages/cli/src/ui/commands/planCommand.ts b/packages/cli/src/ui/commands/planCommand.ts new file mode 100644 index 0000000000..53fad50c65 --- /dev/null +++ b/packages/cli/src/ui/commands/planCommand.ts @@ -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, + ); + } + }, +}; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 3f3a0ac7bb..c6458dcc1f 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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; } diff --git a/packages/core/src/tools/exit-plan-mode.test.ts b/packages/core/src/tools/exit-plan-mode.test.ts index ab1ffd6aad..1c6ad7d876 100644 --- a/packages/core/src/tools/exit-plan-mode.test.ts +++ b/packages/core/src/tools/exit-plan-mode.test.ts @@ -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 () => { diff --git a/packages/core/src/tools/exit-plan-mode.ts b/packages/core/src/tools/exit-plan-mode.ts index 08fa97601c..3916eb79eb 100644 --- a/packages/core/src/tools/exit-plan-mode.ts +++ b/packages/core/src/tools/exit-plan-mode.ts @@ -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);