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/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 new file mode 100644 index 00000000..4032ce32 --- /dev/null +++ b/packages/server/clipboard.ts @@ -0,0 +1,91 @@ +/** + * 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 + * 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 { readFileSync } from "node:fs"; +import os from "node:os"; + +export interface ClipboardResult { + ok: boolean; + /** Tool name used for the successful write (e.g. "pbcopy", "clip.exe"). */ + tool?: string; + 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 { + 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 (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)", () => {