import { spawn } from "node:child_process"; import { promises as fs } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AssistantMessage } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Box, MouseRegion, Text, type TuiMouseEvent } from "@earendil-works/pi-tui"; const TYPE = "bash-code-actions"; const LANGS = new Set(["bash", "sh", "shell", "zsh"]); type Block = { index: number; language: string; code: string }; type Data = { sourceEntryId: string; cwd: string; blocks: Block[] }; let current: ExtensionContext | undefined; function body(message: unknown): string { const parts = (message as AssistantMessage | undefined)?.content; return Array.isArray(parts) ? parts.filter(p => p.type === "text").map(p => p.text).join("\n") : ""; } function parse(text: string): Block[] { const out: Block[] = []; for (const m of text.matchAll(/```([^\n`]*)\n([\s\S]*?)```/g)) { const language = m[1].trim().toLowerCase().split(/\s+/)[0] || ""; if (LANGS.has(language)) out.push({ index: out.length + 1, language, code: m[2].replace(/\n$/, "") }); } return out; } function quote(s: string): string { return `'${s.replace(/'/g, `'\\''`)}'`; } function stdin(command: string, args: string[], text: string): Promise { return new Promise(resolve => { const p = spawn(command, args, { stdio: ["pipe", "ignore", "ignore"] }); let settled = false; const done = (ok: boolean) => { if (!settled) { settled = true; resolve(ok); } }; p.once("error", () => done(false)); p.once("close", code => done(code === 0)); p.stdin.end(text); }); } async function clipboard(text: string): Promise { if (process.platform === "darwin") return stdin("pbcopy", [], text); if (process.platform === "win32") return stdin("clip", [], text); return await stdin("wl-copy", [], text) || await stdin("xclip", ["-selection", "clipboard"], text) || stdin("xsel", ["--clipboard", "--input"], text); } async function terminal(code: string, cwd: string): Promise { const dir = await fs.mkdtemp(join(tmpdir(), "pi-bash-code-")), script = join(dir, "run.sh"); await fs.writeFile(script, `#!/usr/bin/env bash\ntrap 'rm -rf -- "$(dirname -- "$0")"' EXIT\ncd -- ${quote(cwd)} 2>/dev/null || true\n${code}\n`, { mode: 0o700 }); const candidates: Array<[string, string[]]> = []; const configured = process.env.TERMINAL?.trim(); if (configured && !configured.includes(" ")) candidates.push([configured, ["bash", script]]); candidates.push(["konsole", ["--hold", "-e", "bash", script]], ["gnome-terminal", ["--", "bash", script]], ["x-terminal-emulator", ["-e", "bash", script]]); for (const [cmd, args] of candidates) { const ok = await new Promise(resolve => { const p = spawn(cmd, args, { detached: true, stdio: "ignore" }); p.once("error", () => resolve(false)); p.once("spawn", () => { p.unref(); resolve(true); }); }); if (ok) return true; } await fs.rm(dir, { recursive: true, force: true }); return false; } function warning(code: string): string { if (/\bsudo\b|\brm\s+-rf\b|\bdd\s+if=|\bmkfs\b|\bshutdown\b|\breboot\b/.test(code)) return "⚠ Comandi privilegiati o potenzialmente distruttivi."; if (/\bcurl\b|\bwget\b|\|\s*(bash|sh)\b/.test(code)) return "⚠ Comandi di rete o pipe a una shell."; return ""; } async function action(kind: "copy" | "open" | "execute", block: Block, data: Data, ctx: ExtensionContext): Promise { if (!ctx.hasUI) return; if (kind === "copy") { const ok = await clipboard(block.code); ctx.ui.notify(ok ? `Blocco ${block.index} copiato.` : "Clipboard non disponibile.", ok ? "info" : "error"); return; } if (kind === "execute") { const shown = block.code.length > 1600 ? `${block.code.slice(0, 1597)}...` : block.code; if (!(await ctx.ui.confirm("Conferma esecuzione", [`Eseguire il blocco bash ${block.index}?`, "", shown, warning(block.code)].filter(Boolean).join("\n")))) return; } const ok = await terminal(block.code, data.cwd); ctx.ui.notify(ok ? "Blocco avviato in un nuovo terminale." : "Terminale non disponibile.", ok ? "info" : "error"); } function preview(code: string): string { const line = code.split("\n").find(s => s.trim())?.trim() || "(vuoto)"; return line.length > 65 ? `${line.slice(0, 62)}...` : line; } function button(label: string, kind: "copy" | "open" | "execute", block: Block, data: Data, theme: any): MouseRegion { return new MouseRegion(new Text(theme.fg(kind === "execute" ? "warning" : "accent", `[ ${label} ]`), 1, 0), (e: TuiMouseEvent) => { if (e.type !== "click" || e.button !== "left") return undefined; if (current) void action(kind, block, data, current); return { handled: true, render: false }; }); } function render(data: Data, theme: any): Box { const box = new Box(1, 0, s => theme.bg("customMessageBg", s)); box.addChild(new Text(theme.fg("muted", "bash code actions"), 1, 0)); for (const b of data.blocks) { box.addChild(new Text(theme.fg("dim", `${b.index}. ${preview(b.code)}`), 1, 0)); box.addChild(button("copia", "copy", b, data, theme)); box.addChild(button("terminale", "open", b, data, theme)); box.addChild(button("esegui", "execute", b, data, theme)); } return box; } function known(ctx: ExtensionContext): Data[] { return ctx.sessionManager.getBranch().filter((e: any) => e.type === "custom" && e.customType === TYPE).map((e: any) => e.data).filter((d: any) => d?.blocks); } async function recover(pi: ExtensionAPI, ctx: ExtensionContext): Promise { const branch = ctx.sessionManager.getBranch() as any[]; const ids = new Set(branch.filter(e => e.type === "custom" && e.customType === TYPE).map(e => e.data?.sourceEntryId)); for (const e of branch) { if (e.type !== "message" || e.message?.role !== "assistant" || ids.has(e.id)) continue; const blocks = parse(body(e.message)); if (blocks.length) pi.appendEntry(TYPE, { sourceEntryId: e.id, cwd: ctx.cwd, blocks } satisfies Data); } } export default function (pi: ExtensionAPI) { pi.registerEntryRenderer(TYPE, (entry, _opts, theme) => { const d = entry.data as Data | undefined; return d?.blocks?.length ? render(d, theme) : undefined; }); pi.on("session_start", async (_e, ctx) => { current = ctx; await recover(pi, ctx); }); pi.on("session_shutdown", () => { current = undefined; }); pi.on("turn_end", (e, ctx) => { current = ctx; if (e.message.role !== "assistant") return; const blocks = parse(body(e.message)); if (blocks.length) pi.appendEntry(TYPE, { sourceEntryId: e.messageEntryId, cwd: ctx.cwd, blocks } satisfies Data); }); pi.registerCommand("bash-blocks", { description: "Gestisce i blocchi shell della sessione", handler: async (_args, ctx) => { const choices = known(ctx).flatMap(d => d.blocks.map(b => ({ d, b, label: `${d.sourceEntryId.slice(0, 8)} · ${b.index}: ${preview(b.code)}` }))); if (!choices.length) { ctx.ui.notify("Nessun blocco shell trovato.", "info"); return; } const selected = await ctx.ui.select("Scegli blocco", choices.map(c => c.label)), choice = choices.find(c => c.label === selected); if (!choice) return; const selectedAction = await ctx.ui.select("Azione", ["Copia", "Apri terminale", "Esegui"]); if (selectedAction === "Copia") await action("copy", choice.b, choice.d, ctx); if (selectedAction === "Apri terminale") await action("open", choice.b, choice.d, ctx); if (selectedAction === "Esegui") await action("execute", choice.b, choice.d, ctx); } }); }