diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 87cd5f7e2..9ba855d41 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added a `c` hotkey in the plan-review overlay that copies the current reviewed plan markdown to the system clipboard, including in-overlay edits. + ## [16.4.6] - 2026-07-12 ### Added diff --git a/packages/coding-agent/src/modes/components/plan-review-overlay.ts b/packages/coding-agent/src/modes/components/plan-review-overlay.ts index 11f7a96d2..4d286cab6 100644 --- a/packages/coding-agent/src/modes/components/plan-review-overlay.ts +++ b/packages/coding-agent/src/modes/components/plan-review-overlay.ts @@ -82,6 +82,8 @@ export interface PlanReviewOverlayCallbacks { onPick: (label: string) => void; /** Invoked on Esc / cancel. */ onCancel: () => void; + /** Invoked with the current full plan text when the copy hotkey is pressed. */ + onCopyPlan?: (content: string) => void | Promise; /** Invoked when the external-editor key is pressed (overlay stays open). */ onExternalEditor?: () => void; /** Invoked when the external-editor key edits the active annotation draft. */ @@ -302,6 +304,10 @@ export class PlanReviewOverlay implements Component { this.callbacks.onExternalEditor(); return; } + if (this.callbacks.onCopyPlan && keyData === "c") { + void this.callbacks.onCopyPlan(joinPlanSections(this.#sections)); + return; + } if (matchesKey(keyData, "tab") || keyData === "\t") { this.#cycleRegion(1); return; @@ -677,6 +683,7 @@ export class PlanReviewOverlay implements Component { parts.push("↑↓ scroll", "⇧ faster", "pgup/pgdn", "g/G ends"); break; } + if (this.callbacks.onCopyPlan) parts.push("c copy"); parts.push("tab regions"); if (this.#externalEditorLabel && this.#focus !== "toc") parts.push(`${this.#externalEditorLabel} editor`); parts.push(this.#helpSuffix); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index dc4716dec..c53bea59b 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -120,6 +120,7 @@ import { formatPhaseDisplayName, todoMatchesAnyDescription } from "../tools/todo import { ToolError } from "../tools/tool-errors"; import { vocalizer } from "../tts/vocalizer"; import { renderTreeList } from "../tui/tree-list"; +import { copyToClipboard } from "../utils/clipboard"; import type { EventBus } from "../utils/event-bus"; import { getEditorCommand, openInEditor } from "../utils/external-editor"; import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color"; @@ -2517,6 +2518,7 @@ export class InteractiveMode implements InteractiveModeContext { { onPick: choice => finish(choice), onCancel: () => finish(undefined), + onCopyPlan: content => void this.#copyPlanToClipboard(content), onExternalEditor: dialogOptions?.onExternalEditor, onAnnotationExternalEditor: (draft, commit) => void this.#openPlanAnnotationInExternalEditor(draft, commit), onPlanEdited: dialogOptions?.onPlanEdited, @@ -2583,6 +2585,17 @@ export class InteractiveMode implements InteractiveModeContext { return contextUsage !== undefined && contextUsage.percent > PLAN_KEEP_CONTEXT_DISABLE_THRESHOLD_PERCENT; } + async #copyPlanToClipboard(content: string): Promise { + try { + await copyToClipboard(content); + this.showStatus("Copied plan to clipboard"); + } catch (error) { + this.showWarning( + `Failed to copy plan to clipboard: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + async #openPlanInExternalEditor(planFilePath: string): Promise { const editorCmd = getEditorCommand(); if (!editorCmd) { diff --git a/packages/coding-agent/test/interactive-mode-plan-review.test.ts b/packages/coding-agent/test/interactive-mode-plan-review.test.ts index 6ebf4a59a..7590f3a58 100644 --- a/packages/coding-agent/test/interactive-mode-plan-review.test.ts +++ b/packages/coding-agent/test/interactive-mode-plan-review.test.ts @@ -18,6 +18,7 @@ import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SILENT_ABORT_MARKER, USER_INTERRUPT_LABEL } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { AUTO_THINKING } from "@oh-my-pi/pi-coding-agent/thinking"; +import * as clipboard from "@oh-my-pi/pi-coding-agent/utils/clipboard"; import { setKeybindings, Text } from "@oh-my-pi/pi-tui"; import { formatNumber, TempDir } from "@oh-my-pi/pi-utils"; @@ -332,6 +333,39 @@ describe("InteractiveMode plan review rendering", () => { } }); + it("copies the overlay's current edited plan markdown from the real plan review overlay", async () => { + let capturedOverlay: PlanReviewOverlay | undefined; + const overlayHandle = { hide: vi.fn() }; + vi.spyOn(mode.ui, "showOverlay").mockImplementation(component => { + capturedOverlay = component as PlanReviewOverlay; + return overlayHandle as never; + }); + const copySpy = vi.spyOn(clipboard, "copyToClipboard").mockResolvedValue(undefined); + const statusSpy = vi.spyOn(mode, "showStatus"); + const constructorPlan = "# Plan\n\nOriginal constructor body.\n"; + const editedPlan = "# Plan\n\nEdited in overlay.\n\n## Verify\n\n- run focused test\n"; + + const choice = mode.showPlanReview(constructorPlan, "Plan mode - next step", [ + "Approve and execute", + "Refine plan", + ]); + + expect(capturedOverlay).toBeDefined(); + const overlay = capturedOverlay!; + overlay.setPlanContent(editedPlan); + overlay.handleInput("c"); + await Promise.resolve(); + + expect(copySpy).toHaveBeenCalledTimes(1); + expect(copySpy).toHaveBeenCalledWith(editedPlan); + expect(copySpy).not.toHaveBeenCalledWith(constructorPlan); + expect(statusSpy).toHaveBeenCalledWith("Copied plan to clipboard"); + + overlay.handleInput("\x1b"); + await expect(choice).resolves.toBeUndefined(); + expect(overlayHandle.hide).toHaveBeenCalled(); + }); + it("Refine with no annotations silently aborts approval and returns to the editor", async () => { const planFilePath = "local://PLAN.md"; const resolvedPlanPath = resolveLocalUrlToPath(planFilePath, { diff --git a/packages/coding-agent/test/modes/components/plan-review-overlay.test.ts b/packages/coding-agent/test/modes/components/plan-review-overlay.test.ts index ef9841fd4..37733fee2 100644 --- a/packages/coding-agent/test/modes/components/plan-review-overlay.test.ts +++ b/packages/coding-agent/test/modes/components/plan-review-overlay.test.ts @@ -195,6 +195,22 @@ describe("PlanReviewOverlay", () => { expect(out).not.toContain("para 199"); }); + it("copies the current plan content on c and advertises the hotkey when available", () => { + const onCopyPlan = vi.fn(); + const overlay = new PlanReviewOverlay( + "# Original plan\n\nold body", + { promptTitle: "next", options: APPROVAL_OPTIONS }, + { onPick: vi.fn(), onCancel: vi.fn(), onCopyPlan }, + ); + + overlay.setPlanContent("# Edited plan\n\nnew body"); + expect(render(overlay)).toContain("c copy"); + overlay.handleInput("c"); + + expect(onCopyPlan).toHaveBeenCalledTimes(1); + expect(onCopyPlan).toHaveBeenCalledWith("# Edited plan\n\nnew body\n"); + }); + // Plan with ≥2 headings + nesting, wide enough for the sidebar at width 80. const SECTION_PLAN = "# Overview\n\nintro body\n\n## Goal\n\ngoal body\n\n## Steps\n\nstep body\n\n# Risks\n\nrisk body\n"; @@ -322,6 +338,25 @@ describe("PlanReviewOverlay", () => { expect(restored).toContain("goal body"); }); + it("copies the edited plan after deleting a section in the overlay", () => { + const onCopyPlan = vi.fn(); + const overlay = new PlanReviewOverlay( + SECTION_PLAN, + { promptTitle: "next", options: APPROVAL_OPTIONS }, + { onPick: vi.fn(), onCancel: vi.fn(), onCopyPlan }, + ); + render(overlay); + overlay.handleInput(TAB); // -> toc (Overview) + overlay.handleInput(DOWN); // -> Goal + overlay.handleInput("d"); + overlay.handleInput("c"); + + expect(onCopyPlan).toHaveBeenCalledTimes(1); + expect(onCopyPlan).toHaveBeenCalledWith( + "# Overview\n\nintro body\n\n## Steps\n\nstep body\n\n# Risks\n\nrisk body\n", + ); + }); + it("annotates a section and emits feedback for the Refine loop", () => { const onFeedbackChange = vi.fn(); const overlay = new PlanReviewOverlay(