feat(plan): add copy subcommand to plan (#20491) (#20988)

This commit is contained in:
ruomeng
2026-03-03 16:36:51 -05:00
committed by GitHub
parent 2a84090dd5
commit b5f3eb2c9c
4 changed files with 101 additions and 2 deletions

View File

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

View File

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

View File

@@ -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 =
@@ -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),
};
});
@@ -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;
@@ -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.',
);
});
});
});

View File

@@ -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) {
@@ -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,
},
],
};