Files
pi-bash-code-actions/index.ts
T

121 lines
7.4 KiB
TypeScript

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<boolean> {
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<boolean> {
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<boolean> {
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<boolean>(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<void> {
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<void> {
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);
} });
}