Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions apps/copilot/commands/plannotator-copy-last.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
description: Copy the last rendered assistant message to the clipboard
allowed-tools: shell(plannotator:*)
---

!`plannotator copilot-copy-last`
6 changes: 6 additions & 0 deletions apps/hook/commands/plannotator-copy-last.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
description: Copy the last rendered assistant message to the clipboard
allowed-tools: Bash(plannotator:*)
---

!`plannotator copy-last`
2 changes: 2 additions & 0 deletions apps/hook/server/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export function formatTopLevelHelp(): string {
" plannotator review [PR_URL]",
" plannotator annotate <file.md | file.html | https://... | folder/> [--no-jina]",
" plannotator last",
" plannotator copy-last",
" plannotator archive",
" plannotator sessions",
" plannotator improve-context",
Expand All @@ -35,6 +36,7 @@ export function formatInteractiveNoArgClarification(): string {
" plannotator review",
" plannotator annotate <file.md | file.html | https://...>",
" plannotator last",
" plannotator copy-last",
" plannotator archive",
" plannotator sessions",
"",
Expand Down
87 changes: 86 additions & 1 deletion apps/hook/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions apps/pi-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,10 @@ Run `/plannotator-annotate <file.md>` 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.
Expand All @@ -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 <file>` | 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

Expand Down
25 changes: 25 additions & 0 deletions apps/pi-extension/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down
6 changes: 6 additions & 0 deletions apps/pi-extension/vendor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
91 changes: 91 additions & 0 deletions packages/server/clipboard.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<ClipboardResult> {
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}` };
}
1 change: 1 addition & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 12 additions & 1 deletion scripts/install.cmd
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions scripts/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down
Loading