/** * Copy All Extension * * Registers the /copy-all command that copies all user and assistant messages * from the current conversation branch to the system clipboard. * * Clipboard strategy (cross-platform): * Windows: Set-Clipboard (PowerShell) → clip.exe (fallback) * macOS: pbcopy * Linux: wl-copy (Wayland) → xclip → xsel → temp file (fallback) * Fallback: write to a temp file and notify the user */ import { tmpdir } from "node:os"; import { join } from "node:path"; import { writeFileSync } from "node:fs"; import { randomUUID } from "node:crypto"; import { spawnSync } from "node:child_process"; import type { ExtensionAPI, ExtensionCommandContext, } from "@earendil-works/pi-coding-agent"; // ── Clipboard ────────────────────────────────────────────────────────────── /** Copy text to the system clipboard. Returns true on success. */ export async function copyToClipboard(text: string): Promise { const platform = process.platform; if (platform === "win32") { return await windowsCopy(text); } else if (platform === "darwin") { return await macCopy(text); } else { return await linuxCopy(text); } } async function windowsCopy(text: string): Promise { // Pipe through stdin instead of interpolating conversation text into a // command. This avoids quoting bugs, command injection, and command-length // limits while preserving Unicode. for (const shell of ["pwsh", "powershell"]) { const result = spawnSync( shell, [ "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "$input | Set-Clipboard", ], { input: text, timeout: 5_000, encoding: "utf-8", windowsHide: true }, ); if (!result.error && result.status === 0) return true; } const fallback = spawnSync("clip.exe", [], { input: text, timeout: 5_000, encoding: "utf-8", windowsHide: true, }); return !fallback.error && fallback.status === 0; } async function macCopy(text: string): Promise { try { const { spawnSync } = await import("node:child_process"); const result = spawnSync("pbcopy", [], { input: text, timeout: 5000, encoding: "utf-8", }); return result.status === 0; } catch { return false; } } async function linuxCopy(text: string): Promise { // Try wl-copy (Wayland) first try { const { spawnSync } = await import("node:child_process"); const result = spawnSync("wl-copy", [], { input: text, timeout: 5000, encoding: "utf-8", }); if (result.status === 0) return true; } catch { // continue } // Try xclip try { const { spawnSync } = await import("node:child_process"); const result = spawnSync("xclip", ["-selection", "clipboard"], { input: text, timeout: 5000, encoding: "utf-8", }); if (result.status === 0) return true; } catch { // continue } // Try xsel try { const { spawnSync } = await import("node:child_process"); const result = spawnSync("xsel", ["--clipboard", "--input"], { input: text, timeout: 5000, encoding: "utf-8", }); if (result.status === 0) return true; } catch { // continue } return false; } /** Write text to a temporary file and return the file path. */ export function writeToTempFile(text: string): string { const filePath = join( tmpdir(), `pi-copy-all-${Date.now()}-${randomUUID()}.md`, ); writeFileSync(filePath, text, "utf-8"); return filePath; } // ── Message extraction ───────────────────────────────────────────────────── /** * Extract user and assistant messages from the active branch. * Returns an array of formatted message strings in chronological order. */ export function extractMessages( branch: any[], ): Array<{ role: string; content: string }> { const messages: Array<{ role: string; content: string }> = []; for (const entry of branch) { if (entry.type !== "message") continue; const msg = entry.message; if (!msg) continue; // Only user and assistant messages if (msg.role !== "user" && msg.role !== "assistant") continue; // Get text content const content = extractTextContent(msg.content); if (!content) continue; messages.push({ role: msg.role, content }); } // SessionManager.getBranch() returns root → leaf chronological order. return messages; } function extractTextContent( content: unknown, ): string { if (typeof content === "string") return content.trim(); if (Array.isArray(content)) { return content .filter( (block: any) => block && block.type === "text" && typeof block.text === "string", ) .map((block: any) => block.text) .join("\n") .trim(); } return ""; } /** Format messages as a readable markdown conversation. */ export function formatConversation( messages: Array<{ role: string; content: string }>, ): string { const lines: string[] = []; for (const msg of messages) { const label = msg.role === "user" ? "User" : "Assistant"; // Add a separator between messages if (lines.length > 0) { lines.push(""); lines.push("---"); lines.push(""); } lines.push(`### ${label}`); lines.push(""); lines.push(msg.content); } return lines.join("\n") + "\n"; } // ── Extension ────────────────────────────────────────────────────────────── export default function copyAllExtension(pi: ExtensionAPI): void { pi.registerCommand("copy-all", { description: "Copy all user and assistant messages from the current branch to clipboard", handler: async (_args: string, ctx: ExtensionCommandContext) => { // Get the current branch from session const branch = ctx.sessionManager.getBranch(); if (!branch || branch.length === 0) { ctx.ui.notify("No messages in the current branch.", "warning"); return; } const messages = extractMessages(branch); if (messages.length === 0) { ctx.ui.notify( "No user or assistant messages found in the current branch.", "warning", ); return; } const formatted = formatConversation(messages); const copied = await copyToClipboard(formatted); if (copied) { const userCount = messages.filter((m) => m.role === "user").length; const assistantCount = messages.filter((m) => m.role === "assistant").length; ctx.ui.notify( `Copied ${userCount} user and ${assistantCount} assistant message(s) to clipboard.`, "info", ); } else { // Fallback: write to temp file const filePath = writeToTempFile(formatted); ctx.ui.notify( `Clipboard unavailable. Conversation written to:\n${filePath}`, "warning", ); } }, }); }