Add plan review copy hotkey

This commit is contained in:
Hayden Evan
2026-07-09 21:29:55 +07:00
committed by can1357
parent 1b0b18c7a6
commit 4fa5b61b05
5 changed files with 93 additions and 0 deletions
+4
View File
@@ -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
@@ -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<void>;
/** 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);
@@ -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<void> {
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<void> {
const editorCmd = getEditorCommand();
if (!editorCmd) {
@@ -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, {
@@ -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(