Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cli/src/services/BuiltinCommandLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ describe('BuiltinCommandLoader', () => {
vi.clearAllMocks();
mockConfig = {
getFolderTrust: vi.fn().mockReturnValue(true),
isPlanEnabled: vi.fn().mockReturnValue(false),
getEnableExtensionReloading: () => false,
getEnableHooks: () => false,
getEnableHooksUI: () => false,
Expand Down Expand Up @@ -256,6 +257,7 @@ describe('BuiltinCommandLoader profile', () => {
vi.resetModules();
mockConfig = {
getFolderTrust: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: () => false,
getEnableExtensionReloading: () => false,
getEnableHooks: () => false,
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/src/services/BuiltinCommandLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
Expand Down
133 changes: 133 additions & 0 deletions packages/cli/src/ui/commands/planCommand.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 69 additions & 0 deletions packages/cli/src/ui/commands/planCommand.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
},
};
11 changes: 11 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/tools/exit-plan-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/tools/exit-plan-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading