import { createHash } from "node:crypto"; import type { ExtensionAPI } from "@selesai/code"; import { copyToClipboard } from "@selesai/code"; import { Text } from "@earendil-works/pi-tui"; type AnyMessage = any; const EXT = "copy-turn"; const HASH_LEN = 6; const MARK_RE = /\n?\n?\s*⧉ copy(?:\s+\w+)?: \/cp ([0-9a-f]{6,12})\s*$/; function stripAnsi(s: string): string { return s.replace(/\x1b\[[0-9;]*m/g, ""); } function stripMark(s: string): string { return s.replace(MARK_RE, "").trimEnd(); } function textFromContent(content: unknown): string { if (typeof content === "string") return stripMark(content); if (!Array.isArray(content)) return ""; return content .map((b: any) => b?.type === "text" ? stripMark(b.text ?? "") : "") .filter(Boolean) .join("\n"); } function cleanMessage(m: AnyMessage): AnyMessage { const c = structuredClone(m); if (typeof c.content === "string") c.content = stripMark(c.content); else if (Array.isArray(c.content)) { c.content = c.content.map((b: any) => b?.type === "text" ? { ...b, text: stripMark(b.text ?? "") } : b); } return c; } function resultOf(message: AnyMessage): string { const clean = cleanMessage(message); if (clean.role === "bashExecution") { return [clean.command ? `$ ${clean.command}` : "", clean.output ?? ""].filter(Boolean).join("\n").trim(); } return textFromContent(clean.content).trim(); } function hashFor(m: AnyMessage): string { // Hash the stable copy identity (role + cleaned copy result) only, so the hash // shown at display time (computed from the transient message) still matches the // one /cp recomputes from the persisted message, whose metadata may have changed. const role = m.role ?? "message"; const text = resultOf(m); return createHash("sha1").update(JSON.stringify([role, text])).digest("hex").slice(0, HASH_LEN); } function branchMessages(ctx: any): AnyMessage[] { return (ctx.sessionManager?.getBranch?.() ?? []) .filter((e: any) => e?.type === "message" && e.message) .map((e: any) => e.message) .filter((m: any) => !(m.role === "custom" && m.customType === EXT)); } const LIST_COUNT = 8; const PREVIEW_CHARS = 60; /** Copyable messages on the branch, oldest first, with their hashes. */ function copyable(ctx: any): { hash: string; message: AnyMessage; text: string }[] { return branchMessages(ctx) .map((message) => ({ hash: hashFor(message), message, text: stripAnsi(resultOf(message)).trim() })) .filter((entry) => entry.text); } export default function (pi: ExtensionAPI) { // Nothing here adds to the conversation: no marker in message text, no custom message, no extra turn. // No automatic footer hints. The renderer and context filter below exist only for sessions // saved by older versions, which stored `⧉ copy` rows and markers. pi.registerMessageRenderer(EXT, (message: any, _options: any, theme: any) => { const h = message.details?.hash ?? textFromContent(message.content).match(/[0-9a-f]{6,12}/)?.[0] ?? "??????"; const role = message.details?.role ?? "message"; return new Text(theme.fg("dim", `⧉ copy ${role}: /cp ${h}`), 0, 0); }); pi.on("context", async (event: any) => ({ messages: event.messages .filter((m: any) => !(m.role === "custom" && m.customType === EXT)) .map(cleanMessage), })); pi.registerCommand("cp", { description: "Copy a message: /cp (last reply), /cp user, /cp list, or /cp ", handler: async (args: string, ctx: any) => { const arg = args.trim().split(/\s+/).filter(Boolean)[0]; const entries = copyable(ctx); if (arg === "list") { if (entries.length === 0) return ctx.ui.notify("Nothing to copy yet", "info"); const rows = entries.slice(-LIST_COUNT).reverse().map(({ hash, message, text }) => { const flat = text.replace(/\s+/g, " "); const preview = flat.length > PREVIEW_CHARS ? `${flat.slice(0, PREVIEW_CHARS)}…` : flat; return `${hash} ${message.role ?? "message"} ${preview}`; }); return ctx.ui.notify(`Recent messages (/cp ):\n${rows.join("\n")}`, "info"); } let picked: { hash: string; text: string } | undefined; if (!arg || arg === "user") { const role = arg === "user" ? "user" : "assistant"; picked = entries.findLast((entry) => entry.message.role === role); if (!picked) return ctx.ui.notify(`No ${role} message to copy`, "warning"); } else { const matches = entries.filter((entry) => entry.hash.startsWith(arg)); if (matches.length === 0) return ctx.ui.notify(`No message for hash ${arg}`, "warning"); if (matches.length > 1) return ctx.ui.notify(`Hash ${arg} ambiguous: ${matches.map((entry) => entry.hash).join(", ")}`, "warning"); picked = matches[0]!; } await copyToClipboard(picked.text); ctx.ui.notify(`Copied ${picked.hash} (${picked.text.length} chars)`, "info"); }, }); }