From e4dd46e3ea17940d3fc034cd339274bd38cf5924 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 03:58:19 +0000 Subject: [PATCH 1/2] feat: add /plannotator-copy-last command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copies the last rendered assistant message to the system clipboard. Reuses the annotate-last session-log lookup (PPID → CWD slug → ancestor walk for Claude Code; CODEX_THREAD_ID for Codex; session dir for Copilot CLI) but skips the UI and writes straight to the clipboard. - Adds cross-platform clipboard helper (pbcopy / clip.exe / wl-copy, xclip, xsel fallback / clip.exe under WSL) - Adds `plannotator copy-last` and `plannotator copilot-copy-last` subcommands - Registers the /plannotator-copy-last slash command in the Claude Code and Copilot plugins plus all three install scripts and their tests --- .github/workflows/test.yml | 4 +- .../copilot/commands/plannotator-copy-last.md | 6 ++ apps/hook/commands/plannotator-copy-last.md | 6 ++ apps/hook/server/cli.ts | 2 + apps/hook/server/index.ts | 87 ++++++++++++++++++- packages/server/clipboard.ts | 81 +++++++++++++++++ packages/server/package.json | 1 + scripts/install.cmd | 13 ++- scripts/install.ps1 | 16 +++- scripts/install.sh | 16 +++- scripts/install.test.ts | 3 + 11 files changed, 227 insertions(+), 8 deletions(-) create mode 100644 apps/copilot/commands/plannotator-copy-last.md create mode 100644 apps/hook/commands/plannotator-copy-last.md create mode 100644 packages/server/clipboard.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d319f940..d4217f38 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -235,7 +235,7 @@ jobs: # regressions of the same class. # Claude Code slash commands — `.md` files with `!`\`plannotator ...\`` invocation syntax $cmdDir = "$env:USERPROFILE\.claude\commands" - foreach ($file in @("plannotator-review.md", "plannotator-annotate.md", "plannotator-last.md")) { + foreach ($file in @("plannotator-review.md", "plannotator-annotate.md", "plannotator-last.md", "plannotator-copy-last.md")) { $path = Join-Path $cmdDir $file if (-not (Test-Path $path)) { throw "Expected slash command file missing: $path" @@ -245,7 +245,7 @@ jobs: throw "Slash command file $file is missing the '!' shell-invocation prefix. Content: $content" } } - Write-Host "✓ All three Claude Code slash command files contain the '!' prefix" + Write-Host "✓ All Claude Code slash command files contain the '!' prefix" # Gemini slash commands — `.toml` files with `!{plannotator ...}` invocation syntax. # Same `^^!` cmd-escape class as the Claude Code files. The earlier integration diff --git a/apps/copilot/commands/plannotator-copy-last.md b/apps/copilot/commands/plannotator-copy-last.md new file mode 100644 index 00000000..863cc36c --- /dev/null +++ b/apps/copilot/commands/plannotator-copy-last.md @@ -0,0 +1,6 @@ +--- +description: Copy the last rendered assistant message to the clipboard +allowed-tools: shell(plannotator:*) +--- + +!`plannotator copilot-copy-last` diff --git a/apps/hook/commands/plannotator-copy-last.md b/apps/hook/commands/plannotator-copy-last.md new file mode 100644 index 00000000..a4bda5c4 --- /dev/null +++ b/apps/hook/commands/plannotator-copy-last.md @@ -0,0 +1,6 @@ +--- +description: Copy the last rendered assistant message to the clipboard +allowed-tools: Bash(plannotator:*) +--- + +!`plannotator copy-last` diff --git a/apps/hook/server/cli.ts b/apps/hook/server/cli.ts index b39ef544..66fb0aaf 100644 --- a/apps/hook/server/cli.ts +++ b/apps/hook/server/cli.ts @@ -17,6 +17,7 @@ export function formatTopLevelHelp(): string { " plannotator review [PR_URL]", " plannotator annotate [--no-jina]", " plannotator last", + " plannotator copy-last", " plannotator archive", " plannotator sessions", " plannotator improve-context", @@ -35,6 +36,7 @@ export function formatInteractiveNoArgClarification(): string { " plannotator review", " plannotator annotate ", " plannotator last", + " plannotator copy-last", " plannotator archive", " plannotator sessions", "", diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 89284c5e..468ae6e2 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -37,7 +37,11 @@ * - Annotate the last assistant message from a Copilot CLI session * - Parses events.jsonl from session state * - * 8. Improve Context (`plannotator improve-context`): + * 8. Copy Last (`plannotator copy-last` / `plannotator copilot-copy-last`): + * - Copy the last rendered assistant message to the system clipboard + * - Same lookup as annotate-last, but no UI is opened + * + * 9. Improve Context (`plannotator improve-context`): * - Spawned by PreToolUse hook on EnterPlanMode * - Reads improvement hook file from ~/.plannotator/hooks/ * - Returns additionalContext or silently passes through @@ -76,6 +80,7 @@ import { statSync, rmSync, realpathSync, existsSync } from "fs"; import { parseRemoteUrl } from "@plannotator/shared/repo"; import { registerSession, unregisterSession, listSessions } from "@plannotator/server/sessions"; import { openBrowser } from "@plannotator/server/browser"; +import { copyToClipboard } from "@plannotator/server/clipboard"; import { detectProjectName } from "@plannotator/server/project"; import { hostnameOrFallback } from "@plannotator/shared/project"; import { planDenyFeedback } from "@plannotator/shared/feedback-templates"; @@ -729,6 +734,86 @@ if (args[0] === "sessions") { } process.exit(0); +} else if (args[0] === "copy-last") { + // ============================================ + // COPY LAST MESSAGE TO CLIPBOARD MODE + // ============================================ + // + // Finds the last rendered assistant message (same lookup as annotate-last) + // and writes it to the system clipboard. No UI, no server — just copy. + + const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd(); + const codexThreadId = process.env.CODEX_THREAD_ID; + + let lastMessage: RenderedMessage | null = null; + + if (codexThreadId) { + const rolloutPath = findCodexRolloutByThreadId(codexThreadId); + if (rolloutPath) { + const msg = getLastCodexMessage(rolloutPath); + if (msg) { + lastMessage = { messageId: codexThreadId, text: msg.text, lineNumbers: [] }; + } + } + } else { + function tryLogCandidates(getPaths: () => string[]): void { + if (lastMessage) return; + for (const logPath of getPaths()) { + lastMessage = getLastRenderedMessage(logPath); + if (lastMessage) return; + } + } + + const ppidLog = resolveSessionLogByPpid(); + tryLogCandidates(() => ppidLog ? [ppidLog] : []); + tryLogCandidates(() => findSessionLogsForCwd(projectRoot)); + tryLogCandidates(() => findSessionLogsByAncestorWalk(projectRoot)); + } + + if (!lastMessage) { + console.error("No rendered assistant message found in session logs."); + process.exit(1); + } + + const result = await copyToClipboard(lastMessage.text); + + if (result.ok) { + console.log(`Copied last message to clipboard (${lastMessage.text.length} chars).`); + process.exit(0); + } else { + console.error(`Failed to copy to clipboard: ${result.error}`); + process.exit(1); + } + +} else if (args[0] === "copilot-copy-last") { + // ============================================ + // COPILOT CLI COPY LAST MESSAGE TO CLIPBOARD MODE + // ============================================ + + const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd(); + const sessionDir = findCopilotSessionForCwd(projectRoot); + + if (!sessionDir) { + console.error("No Copilot CLI session found."); + process.exit(1); + } + + const msg = getLastCopilotMessage(sessionDir); + if (!msg) { + console.error("No assistant message found in Copilot CLI session."); + process.exit(1); + } + + const result = await copyToClipboard(msg.text); + + if (result.ok) { + console.log(`Copied last message to clipboard (${msg.text.length} chars).`); + process.exit(0); + } else { + console.error(`Failed to copy to clipboard: ${result.error}`); + process.exit(1); + } + } else if (args[0] === "archive") { // ============================================ // ARCHIVE BROWSER MODE diff --git a/packages/server/clipboard.ts b/packages/server/clipboard.ts new file mode 100644 index 00000000..fda62632 --- /dev/null +++ b/packages/server/clipboard.ts @@ -0,0 +1,81 @@ +/** + * Cross-platform clipboard utility. + * + * Writes text to the system clipboard. Returns `{ ok: true, tool }` on success + * or `{ ok: false, error }` when no clipboard tool is available or all attempts + * failed. + * + * Platform support: + * - macOS: pbcopy + * - Windows: clip.exe + * - WSL: clip.exe (forwards to the Windows host clipboard) + * - Linux: wl-copy (Wayland) → xclip → xsel (first that exists wins) + */ + +import { spawn } from "node:child_process"; +import os from "node:os"; + +import { isWSL } from "./browser"; + +export interface ClipboardResult { + ok: boolean; + /** Tool name used for the successful write (e.g. "pbcopy", "clip.exe"). */ + tool?: string; + error?: string; +} + +function pipeToCommand(cmd: string, args: string[], text: string): Promise { + return new Promise((resolve) => { + try { + const proc = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] }); + proc.on("error", () => resolve(false)); + proc.on("exit", (code) => resolve(code === 0)); + proc.stdin.end(text); + } catch { + resolve(false); + } + }); +} + +export async function copyToClipboard(text: string): Promise { + const platform = os.platform(); + + if (platform === "darwin") { + const ok = await pipeToCommand("pbcopy", [], text); + return ok + ? { ok: true, tool: "pbcopy" } + : { ok: false, error: "pbcopy failed" }; + } + + if (platform === "win32") { + const ok = await pipeToCommand("clip.exe", [], text); + return ok + ? { ok: true, tool: "clip.exe" } + : { ok: false, error: "clip.exe failed" }; + } + + if (platform === "linux") { + if (await isWSL()) { + const ok = await pipeToCommand("clip.exe", [], text); + if (ok) return { ok: true, tool: "clip.exe" }; + } + + const candidates: { cmd: string; args: string[] }[] = [ + { cmd: "wl-copy", args: [] }, + { cmd: "xclip", args: ["-selection", "clipboard"] }, + { cmd: "xsel", args: ["--clipboard", "--input"] }, + ]; + + for (const { cmd, args } of candidates) { + const ok = await pipeToCommand(cmd, args, text); + if (ok) return { ok: true, tool: cmd }; + } + + return { + ok: false, + error: "No clipboard tool found. Install wl-copy, xclip, or xsel.", + }; + } + + return { ok: false, error: `Unsupported platform: ${platform}` }; +} diff --git a/packages/server/package.json b/packages/server/package.json index 10d09ccc..8da30aa1 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -11,6 +11,7 @@ "./annotate": "./annotate.ts", "./remote": "./remote.ts", "./browser": "./browser.ts", + "./clipboard": "./clipboard.ts", "./storage": "./storage.ts", "./git": "./git.ts", "./p4": "./p4.ts", diff --git a/scripts/install.cmd b/scripts/install.cmd index a4f347cb..2b65f617 100644 --- a/scripts/install.cmd +++ b/scripts/install.cmd @@ -454,6 +454,17 @@ echo Address the annotation feedback above. The user has reviewed your last mess echo Installed /plannotator-last command to !CLAUDE_COMMANDS_DIR!\plannotator-last.md +( +echo --- +echo description: Copy the last rendered assistant message to the clipboard +echo allowed-tools: Bash^(plannotator:*^) +echo --- +echo. +echo ^^!`plannotator copy-last` +) > "!CLAUDE_COMMANDS_DIR!\plannotator-copy-last.md" + +echo Installed /plannotator-copy-last command to !CLAUDE_COMMANDS_DIR!\plannotator-copy-last.md + REM Install skills (requires git) where git >nul 2>&1 if !ERRORLEVEL! equ 0 ( @@ -591,7 +602,7 @@ echo Then install the Claude Code plugin: echo /plugin marketplace add backnotprop/plannotator echo /plugin install plannotator@plannotator echo. -echo The /plannotator-review, /plannotator-annotate, and /plannotator-last commands are ready to use! +echo The /plannotator-review, /plannotator-annotate, /plannotator-last, and /plannotator-copy-last commands are ready to use! REM Warn if plannotator is configured in both settings.json hooks AND the plugin (causes double execution) REM Only warn when the plugin is installed — manual-only users won't have overlap diff --git a/scripts/install.ps1 b/scripts/install.ps1 index d521a376..f5a97081 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -338,6 +338,18 @@ Address the annotation feedback above. The user has reviewed your last message a Write-Host "Installed /plannotator-last command to $claudeCommandsDir\plannotator-last.md" +# Install Claude Code /plannotator-copy-last slash command +@' +--- +description: Copy the last rendered assistant message to the clipboard +allowed-tools: Bash(plannotator:*) +--- + +!`plannotator copy-last` +'@ | Set-Content -Path "$claudeCommandsDir\plannotator-copy-last.md" + +Write-Host "Installed /plannotator-copy-last command to $claudeCommandsDir\plannotator-copy-last.md" + # Install OpenCode slash command $opencodeCommandsDir = "$env:USERPROFILE\.config\opencode\command" New-Item -ItemType Directory -Force -Path $opencodeCommandsDir | Out-Null @@ -532,7 +544,7 @@ Write-Host "Add the plugin to your opencode.json:" Write-Host "" Write-Host ' "plugin": ["@plannotator/opencode@latest"]' Write-Host "" -Write-Host "Then restart OpenCode. The /plannotator-review, /plannotator-annotate, and /plannotator-last commands are ready!" +Write-Host "Then restart OpenCode. The /plannotator-review, /plannotator-annotate, /plannotator-last, and /plannotator-copy-last commands are ready!" Write-Host "" Write-Host "==========================================" Write-Host " PI USERS" @@ -550,7 +562,7 @@ Write-Host "Install the Claude Code plugin:" Write-Host " /plugin marketplace add backnotprop/plannotator" Write-Host " /plugin install plannotator@plannotator" Write-Host "" -Write-Host "The /plannotator-review, /plannotator-annotate, and /plannotator-last commands are ready to use after you restart Claude Code!" +Write-Host "The /plannotator-review, /plannotator-annotate, /plannotator-last, and /plannotator-copy-last commands are ready to use after you restart Claude Code!" # Warn if plannotator is configured in both settings.json hooks AND the plugin (causes double execution) # Only warn when the plugin is installed — manual-only users won't have overlap diff --git a/scripts/install.sh b/scripts/install.sh index 69fa110b..a26ee3e6 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -407,6 +407,18 @@ COMMAND_EOF echo "Installed /plannotator-last command to ${CLAUDE_COMMANDS_DIR}/plannotator-last.md" +# Install /plannotator-copy-last slash command for Claude Code +cat > "$CLAUDE_COMMANDS_DIR/plannotator-copy-last.md" << 'COMMAND_EOF' +--- +description: Copy the last rendered assistant message to the clipboard +allowed-tools: Bash(plannotator:*) +--- + +!`plannotator copy-last` +COMMAND_EOF + +echo "Installed /plannotator-copy-last command to ${CLAUDE_COMMANDS_DIR}/plannotator-copy-last.md" + # Install OpenCode slash command OPENCODE_COMMANDS_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/command" mkdir -p "$OPENCODE_COMMANDS_DIR" @@ -593,7 +605,7 @@ echo "Add the plugin to your opencode.json:" echo "" echo ' "plugin": ["@plannotator/opencode@latest"]' echo "" -echo "Then restart OpenCode. The /plannotator-review, /plannotator-annotate, and /plannotator-last commands are ready!" +echo "Then restart OpenCode. The /plannotator-review, /plannotator-annotate, /plannotator-last, and /plannotator-copy-last commands are ready!" echo "" echo "==========================================" echo " PI USERS" @@ -624,7 +636,7 @@ echo "Install the Claude Code plugin:" echo " /plugin marketplace add backnotprop/plannotator" echo " /plugin install plannotator@plannotator" echo "" -echo "The /plannotator-review, /plannotator-annotate, and /plannotator-last commands are ready to use after you restart Claude Code!" +echo "The /plannotator-review, /plannotator-annotate, /plannotator-last, and /plannotator-copy-last commands are ready to use after you restart Claude Code!" # Warn if plannotator is configured in both settings.json hooks AND the plugin (causes double execution) # Only warn when the plugin is installed — manual-only users won't have overlap diff --git a/scripts/install.test.ts b/scripts/install.test.ts index e9d0d8f4..b1eb7a8a 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -64,6 +64,7 @@ describe("install.sh", () => { expect(script).toContain("plannotator-review.md"); expect(script).toContain("plannotator-annotate.md"); expect(script).toContain("plannotator-last.md"); + expect(script).toContain("plannotator-copy-last.md"); expect(script).toContain("CLAUDE_COMMANDS_DIR"); expect(script).toContain("OPENCODE_COMMANDS_DIR"); }); @@ -133,6 +134,7 @@ describe("install.ps1", () => { expect(script).toContain("plannotator-review.md"); expect(script).toContain("plannotator-annotate.md"); expect(script).toContain("plannotator-last.md"); + expect(script).toContain("plannotator-copy-last.md"); }); }); @@ -194,6 +196,7 @@ describe("install.cmd", () => { expect(script).toContain("plannotator-review.md"); expect(script).toContain("plannotator-annotate.md"); expect(script).toContain("plannotator-last.md"); + expect(script).toContain("plannotator-copy-last.md"); }); test("Gemini settings merge uses || idiom (issue #506 regression)", () => { From cc9f32bafa63adb5431ad6502e43361548342449 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 05:07:14 +0000 Subject: [PATCH 2/2] feat(pi): add /plannotator-copy-last parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers the Pi command that mirrors /plannotator-copy-last in the Claude Code and Copilot CLI plugins. Pi runs on Node, so the clipboard helper was refactored to drop its Bun.file dependency (inline WSL detection via fs.readFileSync) and vendored into apps/pi-extension/ generated/ via vendor.sh — the same reuse pattern used for other shared server utilities. The Pi handler reads the last assistant message from the session manager (same source as /plannotator-last) and writes it to the system clipboard, surfacing success / failure through ctx.ui.notify. --- apps/pi-extension/README.md | 5 +++++ apps/pi-extension/index.ts | 25 +++++++++++++++++++++++++ apps/pi-extension/vendor.sh | 6 ++++++ packages/server/clipboard.ts | 18 ++++++++++++++---- 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/apps/pi-extension/README.md b/apps/pi-extension/README.md index 3f53ea77..915d1de9 100644 --- a/apps/pi-extension/README.md +++ b/apps/pi-extension/README.md @@ -178,6 +178,10 @@ Run `/plannotator-annotate ` to open any markdown file in the annotatio Run `/plannotator-last` to annotate the agent's most recent response. The message opens in the annotation UI where you can highlight text, add comments, and send structured feedback back to the agent. +### Copy last message + +Run `/plannotator-copy-last` to copy the agent's most recent response to the system clipboard. No UI opens — the message is written directly via `pbcopy` (macOS), `clip.exe` (Windows/WSL), or `wl-copy` / `xclip` / `xsel` (Linux). + ### Archive browser The Plannotator archive browser is available through the shared event API as `archive`, which opens the saved plan/decision browser for future callers. The orchestrator does not expose a dedicated archive command yet. @@ -196,6 +200,7 @@ During execution, the agent marks completed steps with `[DONE:n]` markers. Progr | `/plannotator-review` | Open code review UI for current changes | | `/plannotator-annotate ` | Open markdown file in annotation UI | | `/plannotator-last` | Annotate the last assistant message | +| `/plannotator-copy-last` | Copy the last assistant message to the clipboard | ## Flags diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts index afe9f472..b3df3c9f 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -39,6 +39,7 @@ import { FILE_BROWSER_EXCLUDED } from "./generated/reference-common.js"; import { htmlToMarkdown } from "./generated/html-to-markdown.js"; import { urlToMarkdown } from "./generated/url-to-markdown.js"; import { loadConfig, resolveUseJina } from "./generated/config.js"; +import { copyToClipboard } from "./generated/clipboard.js"; import { getLastAssistantMessageText, hasPlanBrowserHtml, @@ -518,6 +519,30 @@ export default function plannotator(pi: ExtensionAPI): void { }, }); + pi.registerCommand("plannotator-copy-last", { + description: "Copy the last assistant message to the clipboard", + handler: async (_args, ctx) => { + const lastText = await getLastAssistantMessageText(ctx); + if (!lastText) { + ctx.ui.notify("No assistant message found in session.", "error"); + return; + } + + const result = await copyToClipboard(lastText); + if (result.ok) { + ctx.ui.notify( + `Copied last message to clipboard (${lastText.length} chars).`, + "info", + ); + } else { + ctx.ui.notify( + `Failed to copy to clipboard: ${result.error}`, + "error", + ); + } + }, + }); + pi.registerCommand("plannotator-archive", { description: "Browse saved plan decisions", handler: async (_args, ctx) => { diff --git a/apps/pi-extension/vendor.sh b/apps/pi-extension/vendor.sh index e7dfce03..ab507072 100755 --- a/apps/pi-extension/vendor.sh +++ b/apps/pi-extension/vendor.sh @@ -21,6 +21,12 @@ for f in codex-review claude-review path-utils; do > "generated/$f.ts" done +# Vendor runtime-agnostic server utilities (no Bun APIs). +for f in clipboard; do + src="../../packages/server/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/server/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" +done + for f in index types provider session-manager endpoints context base-session; do src="../../packages/ai/$f.ts" printf '// @generated — DO NOT EDIT. Source: packages/ai/%s.ts\n' "$f" | cat - "$src" > "generated/ai/$f.ts" diff --git a/packages/server/clipboard.ts b/packages/server/clipboard.ts index fda62632..4032ce32 100644 --- a/packages/server/clipboard.ts +++ b/packages/server/clipboard.ts @@ -1,5 +1,5 @@ /** - * Cross-platform clipboard utility. + * Cross-platform clipboard utility. Runtime-agnostic (Node + Bun). * * Writes text to the system clipboard. Returns `{ ok: true, tool }` on success * or `{ ok: false, error }` when no clipboard tool is available or all attempts @@ -13,10 +13,9 @@ */ import { spawn } from "node:child_process"; +import { readFileSync } from "node:fs"; import os from "node:os"; -import { isWSL } from "./browser"; - export interface ClipboardResult { ok: boolean; /** Tool name used for the successful write (e.g. "pbcopy", "clip.exe"). */ @@ -24,6 +23,17 @@ export interface ClipboardResult { error?: string; } +function isWSL(): boolean { + if (os.platform() !== "linux") return false; + if (os.release().toLowerCase().includes("microsoft")) return true; + try { + const content = readFileSync("/proc/version", "utf-8").toLowerCase(); + return content.includes("wsl") || content.includes("microsoft"); + } catch { + return false; + } +} + function pipeToCommand(cmd: string, args: string[], text: string): Promise { return new Promise((resolve) => { try { @@ -55,7 +65,7 @@ export async function copyToClipboard(text: string): Promise { } if (platform === "linux") { - if (await isWSL()) { + if (isWSL()) { const ok = await pipeToCommand("clip.exe", [], text); if (ok) return { ok: true, tool: "clip.exe" }; }