// pi-power-paste: OpenCode-grade paste for pi on Windows. // // Features: // 1. Ctrl+V probes the clipboard through a PERSISTENT PowerShell helper // (~5-10ms after warm-up, instead of pi core's cold ~500ms spawn per paste). // 2. Clipboard image -> real attachment with [#image N] placeholder. // 3. Files copied in Explorer (Ctrl+C -> Ctrl+V) -> thumbnail blocks: // image files become [#image N] attachments, text files become pi's // native collapsed [paste #N +X lines] blocks (content sent at submit). // 4. Large clipboard text -> collapsed into pi's native [paste #N +X lines] // marker (same as bracketed-paste collapsing, expanded at submit). // 5. Ctrl+V is registered via pi.registerShortcut, whose key matching is // Kitty-keyboard-protocol aware (do NOT match raw \x16: with Kitty active, // e.g. Windows Terminal 1.22+, Ctrl+V arrives as a CSI-u sequence). // The terminal must still FORWARD the key: Windows Terminal users must // unbind its built-in Ctrl+V paste action (see README). Alt+V (pi's // default binding) always works as fallback, no setup needed. // 6. Non-Windows platforms keep pi-paster's original clipboard-image behavior. // // Built on top of pi-paster's exported building blocks (PasterEditor, // AttachmentStore, preview widgets, image optimizer). pi-paster is bundled as // a library dependency; its own extension is NOT loaded. // pi-paster: https://github.com/beowulf11/pi-paster (MIT) import { spawn, spawnSync } from "node:child_process"; import { readFileSync, statSync } from "node:fs"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { AttachmentStore, CursorImagePreviewWidget, ImagePreviewMessage, PasterEditor, appendImagePathContext, dimensionsForImage, imagesForTextOptimized, loadImageFromPath, readClipboardImage, } from "pi-paster"; // --------------------------------------------------------------------------- // Windows clipboard helper (persistent PowerShell, one-shot fallback) // --------------------------------------------------------------------------- const SENTINEL = "__PP_END__"; const PS_INIT = "Add-Type -AssemblyName System.Windows.Forms;Add-Type -AssemblyName System.Drawing"; const PS_QUERY = "$c=[System.Windows.Forms.Clipboard];" + "$f=$c::GetFileDropList();" + "if($f -and $f.Count -gt 0){$o=@();foreach($p in $f){$o+=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($p))};Write-Output ('FILES '+($o -join ' '))}" + "elseif($c::ContainsImage()){$img=$c::GetImage();$ms=New-Object System.IO.MemoryStream;$img.Save($ms,[System.Drawing.Imaging.ImageFormat]::Png);Write-Output ('IMAGE '+[Convert]::ToBase64String($ms.ToArray()));$ms.Dispose();$img.Dispose()}" + "elseif($c::ContainsText()){Write-Output ('TEXT '+[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($c::GetText())))}" + "else{Write-Output 'EMPTY'};" + `Write-Output '${SENTINEL}'`; const PS_ARGS = ["-NoProfile", "-STA", "-NoLogo", "-NonInteractive", "-NoExit", "-Command", "-"]; type ClipboardProbe = | { kind: "image"; data: string; mimeType: string } | { kind: "files"; paths: string[] } | { kind: "text"; text: string } | { kind: "empty" }; function parseProbeLines(lines: string[]): ClipboardProbe { for (const line of lines) { const sp = line.indexOf(" "); const kind = sp === -1 ? line.trim() : line.slice(0, sp); const payload = sp === -1 ? "" : line.slice(sp + 1).trim(); try { if (kind === "IMAGE" && payload) return { kind: "image", data: payload, mimeType: "image/png" }; if (kind === "FILES" && payload) return { kind: "files", paths: payload .split(" ") .filter(Boolean) .map((p) => Buffer.from(p, "base64").toString("utf8")), }; if (kind === "TEXT") return { kind: "text", text: Buffer.from(payload, "base64").toString("utf8") }; if (kind === "EMPTY") return { kind: "empty" }; } catch { // malformed payload -> keep scanning / fall through to empty } } return { kind: "empty" }; } class WindowsClipboardHelper { private proc: ReturnType | undefined; private buffer = ""; private waiters: Array<(lines: string[]) => void> = []; private dead = false; get alive(): boolean { return !this.dead && !!this.proc && !this.proc.killed; } private ensureProcess(): boolean { if (this.dead) return false; if (this.proc && !this.proc.killed) return true; try { this.proc = spawn("powershell.exe", PS_ARGS, { stdio: ["pipe", "pipe", "ignore"] }); } catch { this.dead = true; return false; } this.buffer = ""; this.proc.stdout?.on("data", (d: Buffer) => { this.buffer += d.toString("utf8"); for (;;) { const idx = this.buffer.indexOf(SENTINEL); if (idx === -1) break; const chunk = this.buffer.slice(0, idx); this.buffer = this.buffer.slice(idx + SENTINEL.length); const resolve = this.waiters.shift(); if (resolve) resolve(chunk.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)); } }); const onDeath = () => { this.dead = true; const pending = this.waiters.splice(0); for (const resolve of pending) resolve([]); }; this.proc.on("exit", onDeath); this.proc.on("error", onDeath); try { this.proc.stdin?.write(PS_INIT + "\r\n"); } catch { // surfaces as a failed probe; one-shot fallback takes over } return true; } async probe(timeoutMs = 4000): Promise { if (this.ensureProcess()) { const result = await new Promise((resolve) => { const wrapped = (lines: string[]) => { clearTimeout(timer); resolve(lines); }; const timer = setTimeout(() => { const i = this.waiters.indexOf(wrapped); if (i >= 0) this.waiters.splice(i, 1); this.kill(true); resolve(undefined); }, timeoutMs); this.waiters.push(wrapped); try { this.proc!.stdin!.write(PS_QUERY + "\r\n"); } catch { clearTimeout(timer); const i = this.waiters.indexOf(wrapped); if (i >= 0) this.waiters.splice(i, 1); this.kill(true); resolve(undefined); } }); if (result) return parseProbeLines(result); } return this.oneShotProbe(); } private oneShotProbe(): ClipboardProbe { try { const res = spawnSync( "powershell.exe", ["-NoProfile", "-STA", "-NoLogo", "-NonInteractive", "-Command", `${PS_INIT};${PS_QUERY}`], { encoding: "utf8", timeout: 8000, maxBuffer: 64 * 1024 * 1024 }, ); if (res.error || typeof res.stdout !== "string") return { kind: "empty" }; const idx = res.stdout.indexOf(SENTINEL); const chunk = idx === -1 ? res.stdout : res.stdout.slice(0, idx); return parseProbeLines(chunk.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)); } catch { return { kind: "empty" }; } } /** Kill the helper. `permanent` disables respawn (used after timeouts). */ kill(permanent: boolean): void { if (permanent) this.dead = true; try { this.proc?.kill(); } catch { // ignore } this.proc = undefined; const pending = this.waiters.splice(0); for (const resolve of pending) resolve([]); } /** Session shutdown cleanup; the helper may respawn in the next session. */ dispose(): void { this.kill(false); } } // --------------------------------------------------------------------------- // Editor: pi-paster's PasterEditor + full-clipboard paste // --------------------------------------------------------------------------- /** Text files above this size are inserted as a plain path instead of a block. */ const MAX_FILE_BLOCK_BYTES = 256 * 1024; /** Skip the image magic-byte probe for files above this size. */ const MAX_IMAGE_PROBE_BYTES = 64 * 1024 * 1024; class PowerPasteEditor extends PasterEditor { constructor(tui: any, theme: any, keybindings: any, options: any) { super(tui, theme, keybindings, options); if (process.platform === "win32") { this.onPasteImage = () => { void this.triggerPowerPaste(); }; } } /** Public so the registered Ctrl+V shortcut can trigger it as well. */ async triggerPowerPaste(): Promise { const options = (this as any).pasterOptions; try { const probe: ClipboardProbe = await options.probeClipboard(); if (probe.kind === "image") { const attachment = options.store.add({ originalPath: "clipboard.png", mimeType: probe.mimeType, data: probe.data, dimensions: dimensionsForImage(probe.data, probe.mimeType), }); // Bypass path-transform: the placeholder is not a path. super.insertTextAtCursor(attachment.placeholder); this.updateCursorPreview(); } else if (probe.kind === "files") { let first = true; for (const p of probe.paths) { this.insertCopiedFile(p, first); first = false; } } else if (probe.kind === "text") { this.insertClipboardText(probe.text.replace(/\r\n/g, "\n")); } (this as any).tui.requestRender(); } catch { options.notify?.("power-paste: clipboard read failed"); } } /** Insert a file copied in Explorer as a thumbnail-style block. */ private insertCopiedFile(rawPath: string, first: boolean): void { const options = (this as any).pasterOptions; const prefix = first ? "" : " "; const insertPath = () => this.insertTextAtCursor(prefix + (/\s/.test(rawPath) ? `"${rawPath}"` : rawPath)); let stat; try { stat = statSync(rawPath); } catch { insertPath(); return; } if (stat.isDirectory()) { insertPath(); return; } // Image file -> real attachment with [#image N] placeholder. if (stat.size > 0 && stat.size <= MAX_IMAGE_PROBE_BYTES) { const loaded = loadImageFromPath(rawPath, options.cwd); if (loaded.ok) { const attachment = options.store.add(loaded.image); super.insertTextAtCursor(prefix + attachment.placeholder); this.updateCursorPreview(); return; } } // Text file -> pi's native collapsed paste block (content sent at submit). if (stat.size > 0 && stat.size <= MAX_FILE_BLOCK_BYTES) { try { const buf = readFileSync(rawPath); if (!buf.includes(0)) { const content = buf.toString("utf8").replace(/\r\n/g, "\n"); if (this.insertPasteMarker(content, prefix)) return; } } catch { // fall through to plain path } } // Binary / huge / unreadable -> plain path (model can read it via tools). insertPath(); } /** * Insert text as pi's native collapsed paste marker ([paste #N +X lines]): * atomic-deletable block, expanded to the full content at submit. * Returns false when pi's internals are unavailable (caller falls back). */ private insertPasteMarker(text: string, prefix = ""): boolean { const pastes = (this as any).pastes; if (!(pastes instanceof Map) || typeof (this as any).pasteCounter !== "number") return false; const id = ++(this as any).pasteCounter; pastes.set(id, text); const lines = text.split("\n"); const marker = lines.length > 3 ? `[paste #${id} +${lines.length} lines]` : `[paste #${id} ${text.length} chars]`; super.insertTextAtCursor(prefix + marker); return true; } private insertClipboardText(text: string): void { const lines = text.split("\n"); // Reuse pi's native large-paste collapsing, exactly like a terminal // bracketed paste; feature-detect the internals, fall back to literal. if (lines.length > 3 || text.length > 1000) { if (this.insertPasteMarker(text)) return; } super.insertTextAtCursor(text); } } // --------------------------------------------------------------------------- // Extension wiring (mirrors pi-paster's plumbing, with our own store) // --------------------------------------------------------------------------- export default function (pi: ExtensionAPI) { const store = new AttachmentStore(); const helper = new WindowsClipboardHelper(); let pendingPreview: any[] = []; let activeEditor: PowerPasteEditor | undefined; // Ctrl+V via pi's shortcut registry: key matching is Kitty-protocol aware // (raw \x16 matching would never fire on Windows Terminal 1.22+). if (process.platform === "win32") { pi.registerShortcut("ctrl+v", { description: "Paste clipboard contents (image / files / text)", handler: async () => { await activeEditor?.triggerPowerPaste(); }, }); } pi.registerMessageRenderer("power-paste-preview", (message: any, options: any, theme: any) => { const placeholders: string[] = message.details?.placeholders ?? []; const attachments = store.list().filter((a: any) => placeholders.includes(a.placeholder)); if (attachments.length === 0) return undefined; return new ImagePreviewMessage( attachments, { fallbackColor: (t: string) => theme.fg("muted", t), background: (t: string) => theme.bg("toolSuccessBg", t), title: (t: string) => theme.fg("toolTitle", theme.bold(t)), muted: (t: string) => theme.fg("muted", t), }, { expanded: options.expanded, style: "raw" }, ); }); pi.on("session_start", (_event, ctx: ExtensionContext) => { store.clear(); pendingPreview = []; if (!ctx.hasUI) return; activeEditor = undefined; ctx.ui.setEditorComponent((tui: any, theme: any, keybindings: any) => { activeEditor = new PowerPasteEditor(tui, theme, keybindings, { cwd: ctx.cwd, store, notify: (m: string) => ctx.ui.notify(m, "warning"), deletePlaceholderAsBlock: true, // Used on non-Windows only (we keep PasterEditor's default Ctrl+V there). pasteClipboardImage: () => { const result = readClipboardImage(); if (!result.ok) return undefined; return store.add(result.image); }, probeClipboard: () => helper.probe(), setCursorPreview: (attachment: any) => { ctx.ui.setWidget( "power-paste-cursor-preview", attachment ? (_tui: any, widgetTheme: any) => new CursorImagePreviewWidget(attachment, { title: (t: string) => widgetTheme.fg("accent", t), muted: (t: string) => widgetTheme.fg("muted", t), accent: (t: string) => widgetTheme.fg("accent", t), }) : undefined, { placement: "aboveEditor" }, ); }, }); return activeEditor; }); }); pi.on("session_shutdown", (_event, ctx: ExtensionContext) => { pendingPreview = []; if (ctx.hasUI) { activeEditor?.clearCursorPreview(); activeEditor = undefined; ctx.ui.setWidget("power-paste-cursor-preview", undefined, { placement: "aboveEditor" }); ctx.ui.setEditorComponent(undefined); } store.clear(); helper.dispose(); }); function previewMessage(attachments: any[]) { const placeholders = attachments.map((a: any) => a.placeholder); return { customType: "power-paste-preview", content: `(attachment preview: ${placeholders.join(", ")})`, display: true, details: { placeholders }, }; } pi.on("input", async (event: any, ctx: ExtensionContext) => { if (event.source === "extension") return { action: "continue" }; if (ctx.hasUI) activeEditor?.clearCursorPreview(); const attachments = store.matchingPlaceholders(event.text); if (attachments.length === 0) return { action: "continue" }; if (ctx.isIdle()) pendingPreview = attachments; else pi.sendMessage(previewMessage(attachments), { deliverAs: "followUp" }); const images = await imagesForTextOptimized(store, event.text, event.images); return { action: "transform", text: appendImagePathContext(event.text, attachments), images, }; }); pi.on("before_agent_start", () => { if (pendingPreview.length === 0) return; const message = previewMessage(pendingPreview); pendingPreview = []; return { message }; }); pi.registerCommand("power-paste", { description: "Show power-paste status (runs a live clipboard probe)", handler: async (_args, ctx) => { if (process.platform !== "win32") { ctx.ui.notify(`power-paste: platform ${process.platform} (pi-paster fallback)`, "info"); return; } // Live self-test: probe() lazily spawns the persistent helper, so the // reported mode is accurate (previously it always showed "fallback" // before the first real paste). const t0 = Date.now(); const probe = await helper.probe(); const ms = Date.now() - t0; const mode = helper.alive ? "persistent helper" : "one-shot fallback"; ctx.ui.notify( `power-paste: ${mode}; probe ${ms}ms -> ${probe.kind}; ${store.list().length} attachment(s) in store`, "info", ); }, }); }