113 lines
6.8 KiB
TypeScript
113 lines
6.8 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 { Text } 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[] };
|
|
|
|
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 render(data: Data, theme: any): Text {
|
|
const count = data.blocks.length;
|
|
const lines = [
|
|
theme.fg("muted", `${count} blocco${count === 1 ? "" : "chi"} shell rilevat${count === 1 ? "o" : "i"}.`),
|
|
theme.fg("dim", "Usa /bash-blocks per copiare, aprire nel terminale o eseguire con conferma."),
|
|
...data.blocks.map(b => theme.fg("dim", `${b.index}. ${preview(b.code)}`)),
|
|
];
|
|
return new Text(lines.join("\n"), 1, 0);
|
|
}
|
|
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) => { await recover(pi, ctx); });
|
|
pi.on("turn_end", (e, 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);
|
|
} });
|
|
}
|