From b5f3eb2c9c6166ae76b59d09970eb47bc9304b93 Mon Sep 17 00:00:00 2001 From: ruomeng Date: Tue, 3 Mar 2026 16:36:51 -0500 Subject: [PATCH] feat(plan): add copy subcommand to plan (#20491) (#20988) --- docs/cli/plan-mode.md | 5 ++ docs/reference/commands.md | 3 ++ .../cli/src/ui/commands/planCommand.test.ts | 50 +++++++++++++++++++ packages/cli/src/ui/commands/planCommand.ts | 45 ++++++++++++++++- 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index a8511d9c42..8a8cebe9ef 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -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. diff --git a/docs/reference/commands.md b/docs/reference/commands.md index ceb064a9bf..bb251bea09 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -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` diff --git a/packages/cli/src/ui/commands/planCommand.test.ts b/packages/cli/src/ui/commands/planCommand.test.ts index 2608b44ca9..fab1267b17 100644 --- a/packages/cli/src/ui/commands/planCommand.test.ts +++ b/packages/cli/src/ui/commands/planCommand.test.ts @@ -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.', + ); + }); + }); }); diff --git a/packages/cli/src/ui/commands/planCommand.ts b/packages/cli/src/ui/commands/planCommand.ts index d9cc6739da..cfa3f9433e 100644 --- a/packages/cli/src/ui/commands/planCommand.ts +++ b/packages/cli/src/ui/commands/planCommand.ts @@ -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, + }, + ], };