Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions docs/cli/plan-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ implementation. It allows you to:
- [Entering Plan Mode](#entering-plan-mode)
- [Planning Workflow](#planning-workflow)
- [Exiting Plan Mode](#exiting-plan-mode)
- [Commands](#commands)
- [Tool Restrictions](#tool-restrictions)
- [Customizing Planning with Skills](#customizing-planning-with-skills)
- [Customizing Policies](#customizing-policies)
Expand Down Expand Up @@ -126,6 +127,10 @@ To exit Plan Mode, you can:
- **Tool:** Gemini CLI calls the [`exit_plan_mode`] tool to present the
finalized plan for your approval.

### Commands

- **`/plan copy`**: Copy the currently approved plan to your clipboard.

## Tool Restrictions

Plan Mode enforces strict safety policies to prevent accidental changes.
Expand Down
3 changes: 3 additions & 0 deletions docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,9 @@ Slash commands provide meta-level control over the CLI itself.
one has been generated.
- **Note:** This feature requires the `experimental.plan` setting to be
enabled in your configuration.
- **Sub-commands:**
- **`copy`**:
- **Description:** Copy the currently approved plan to your clipboard.

### `/policies`

Expand Down
50 changes: 50 additions & 0 deletions packages/cli/src/ui/commands/planCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import {
coreEvents,
processSingleFileContent,
type ProcessedFileReadResult,
readFileWithEncoding,
} from '@google/gemini-cli-core';
import { copyToClipboard } from '../utils/commandUtils.js';

vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
Expand All @@ -25,6 +27,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
emitFeedback: vi.fn(),
},
processSingleFileContent: vi.fn(),
readFileWithEncoding: vi.fn(),
partToString: vi.fn((val) => val),
};
});
Expand All @@ -35,9 +38,14 @@ vi.mock('node:path', async (importOriginal) => {
...actual,
default: { ...actual },
join: vi.fn((...args) => args.join('/')),
basename: vi.fn((p) => p.split('/').pop()),
};
});

vi.mock('../utils/commandUtils.js', () => ({
copyToClipboard: vi.fn(),
}));

describe('planCommand', () => {
let mockContext: CommandContext;

Expand Down Expand Up @@ -115,4 +123,46 @@ describe('planCommand', () => {
text: '# Approved Plan Content',
});
});

describe('copy subcommand', () => {
it('should copy the approved plan to clipboard', async () => {
const mockPlanPath = '/mock/plans/dir/approved-plan.md';
vi.mocked(
mockContext.services.config!.getApprovedPlanPath,
).mockReturnValue(mockPlanPath);
vi.mocked(readFileWithEncoding).mockResolvedValue('# Plan Content');

const copySubCommand = planCommand.subCommands?.find(
(sc) => sc.name === 'copy',
);
if (!copySubCommand?.action) throw new Error('Copy action missing');

await copySubCommand.action(mockContext, '');

expect(readFileWithEncoding).toHaveBeenCalledWith(mockPlanPath);
expect(copyToClipboard).toHaveBeenCalledWith('# Plan Content');
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'info',
'Plan copied to clipboard (approved-plan.md).',
);
});

it('should warn if no approved plan is found', async () => {
vi.mocked(
mockContext.services.config!.getApprovedPlanPath,
).mockReturnValue(undefined);

const copySubCommand = planCommand.subCommands?.find(
(sc) => sc.name === 'copy',
);
if (!copySubCommand?.action) throw new Error('Copy action missing');

await copySubCommand.action(mockContext, '');

expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
'No approved plan found to copy.',
);
});
});
});
45 changes: 43 additions & 2 deletions packages/cli/src/ui/commands/planCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,54 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { CommandKind, type SlashCommand } from './types.js';
import {
type CommandContext,
CommandKind,
type SlashCommand,
} from './types.js';
import {
ApprovalMode,
coreEvents,
debugLogger,
processSingleFileContent,
partToString,
readFileWithEncoding,
} from '@google/gemini-cli-core';
import { MessageType } from '../types.js';
import * as path from 'node:path';
import { copyToClipboard } from '../utils/commandUtils.js';

async function copyAction(context: CommandContext) {
const config = context.services.config;
if (!config) {
debugLogger.debug('Plan copy command: config is not available in context');
return;
}

const planPath = config.getApprovedPlanPath();

if (!planPath) {
coreEvents.emitFeedback('warning', 'No approved plan found to copy.');
return;
}

try {
const content = await readFileWithEncoding(planPath);
await copyToClipboard(content);
coreEvents.emitFeedback(
'info',
`Plan copied to clipboard (${path.basename(planPath)}).`,
);
} catch (error) {
coreEvents.emitFeedback('error', `Failed to copy plan: ${error}`, error);
}
}

export const planCommand: SlashCommand = {
name: 'plan',
description: 'Switch to Plan Mode and view current plan',
kind: CommandKind.BUILT_IN,
autoExecute: true,
autoExecute: false,
action: async (context) => {
const config = context.services.config;
if (!config) {
Expand Down Expand Up @@ -62,4 +94,13 @@ export const planCommand: SlashCommand = {
);
}
},
subCommands: [
{
name: 'copy',
description: 'Copy the currently approved plan to your clipboard',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: copyAction,
},
],
};
Loading