Add plan review copy hotkey
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user