Human-in-the-Loop extension for pi coding agent
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

498 lines
16 KiB

import { complete, type UserMessage } from "@earendil-works/pi-ai";
import {
BorderedLoader,
DynamicBorder,
type ExtensionAPI,
type ExtensionContext,
isToolCallEventType,
type WriteToolCallEvent,
type EditToolCallEvent,
} from "@earendil-works/pi-coding-agent";
import {
Container,
SelectList,
Text,
type SelectItem,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
matchesKey,
Key,
type Component,
} from "@earendil-works/pi-tui";
type HitlAction = "accept" | "modify" | "discuss" | "reject";
const EXPLAIN_SYSTEM_PROMPT = `Sei un revisore di codice. Analizza il codice proposto e rispondi in italiano con una spiegazione BREVISSIMA (massimo 5-6 righe).
Indica solo:
- Cosa fa il codice in sintesi.
- Le modifiche principali rispetto al file esistente (se e un edit).
- Un eventuale rischio o miglioramento rilevante.
Non ripetere il codice. Non aggiungere introduzioni o conclusioni.`;
const HITL_SYSTEM_PROMPT_APPENDIX = `
## Human-in-the-Loop per scrittura/modifica file
Quando intendi scrivere o modificare un file, specialmente codice sorgente o file di configurazione, usa SEMPRE gli strumenti
write
o
edit
. Non usare
bash
con redirezioni (>>, >) o comandi come
echo ... > file
per creare o modificare file, perche queste operazioni aggirerebbero il controllo umano obbligatorio. Se l'utente rifiuta o chiede di discutere una modifica, riporta il motivo e attendi istruzioni.`;
// Estensioni di file che rientrano nel focus "programmazione".
const CODE_EXTENSIONS = new Set([
".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
".java", ".kt", ".scala", ".clj",
".c", ".h", ".cpp", ".hpp", ".cc", ".cxx",
".go", ".rs", ".rb", ".php", ".swift",
".cs", ".fs", ".fsx", ".vb",
".sh", ".bash", ".zsh", ".fish", ".ps1",
".html", ".htm", ".css", ".scss", ".sass", ".less",
".vue", ".svelte", ".astro",
".sql", ".graphql", ".prisma",
".yaml", ".yml", ".json", ".toml", ".ini", ".cfg", ".conf",
".md", ".mdx", ".rst", ".tex",
]);
function isProgrammingFile(path: string): boolean {
const lower = path.toLowerCase();
for (const ext of CODE_EXTENSIONS) {
if (lower.endsWith(ext)) return true;
}
return false;
}
function limitCodeSnippet(text: string, maxLines = 80, maxChars = 8000): string {
if (text.length > maxChars) {
text = text.slice(0, maxChars) + "\n\n[... testo troncato per la spiegazione ...]";
}
const lines = text.split("\n");
if (lines.length > maxLines) {
return lines.slice(0, maxLines).join("\n") + "\n\n[... altre righe omesse ...]";
}
return text;
}
class ScrollablePanel implements Component {
private lines: string[] = [];
private scroll = 0;
private maxRows: number;
private colorFn: (s: string) => string;
private cachedWidth?: number;
private cachedLines?: string[];
constructor(maxRows: number, colorFn: (s: string) => string) {
this.maxRows = maxRows;
this.colorFn = colorFn;
}
setLines(lines: string[]) {
this.lines = lines;
this.scroll = 0;
this.invalidate();
}
scrollBy(delta: number) {
const maxScroll = Math.max(0, this.lines.length - this.maxRows);
this.scroll = Math.max(0, Math.min(this.scroll + delta, maxScroll));
this.invalidate();
}
goTop() {
this.scroll = 0;
this.invalidate();
}
goBottom() {
this.scroll = Math.max(0, this.lines.length - this.maxRows);
this.invalidate();
}
handleInput(data: string): boolean {
if (matchesKey(data, Key.up)) {
this.scrollBy(-1);
return true;
}
if (matchesKey(data, Key.down)) {
this.scrollBy(1);
return true;
}
if (matchesKey(data, Key.ctrl("u"))) {
this.scrollBy(-this.maxRows);
return true;
}
if (matchesKey(data, Key.ctrl("d"))) {
this.scrollBy(this.maxRows);
return true;
}
if (matchesKey(data, Key.home)) {
this.goTop();
return true;
}
if (matchesKey(data, Key.end)) {
this.goBottom();
return true;
}
return false;
}
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
}
const pad = 2;
const availableWidth = Math.max(1, width - pad);
const wrapped: string[] = [];
for (const line of this.lines) {
const colored = this.colorFn(line);
if (visibleWidth(colored) > availableWidth) {
wrapped.push(...wrapTextWithAnsi(colored, availableWidth));
} else {
wrapped.push(colored);
}
}
const maxScroll = Math.max(0, wrapped.length - this.maxRows);
const start = Math.min(this.scroll, maxScroll);
const window = wrapped.slice(start, start + this.maxRows);
const out: string[] = [];
for (let i = 0; i < this.maxRows; i++) {
const raw = window[i] ?? "";
const line = truncateToWidth(raw, availableWidth, "");
out.push(line);
}
this.cachedWidth = width;
this.cachedLines = out;
return out;
}
invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
getScrollInfo(): { start: number; end: number; total: number } {
const maxScroll = Math.max(0, this.lines.length - this.maxRows);
const start = Math.min(this.scroll, maxScroll);
const visible = Math.min(this.maxRows, this.lines.length);
return { start: start + 1, end: start + visible, total: this.lines.length };
}
}
async function buildUserPrompt(event: WriteToolCallEvent | EditToolCallEvent): Promise<string> {
if (isToolCallEventType("write", event)) {
const { path, content } = event.input;
return `File da scrivere: ${path}\n\nCodice proposto:\n\`\`\`\n${limitCodeSnippet(content, 400, 60000)}\n\`\`\`\n\nSpiega in italiano cosa fa questo codice e quali sono le scelte tecniche rilevanti.`;
}
const { path, edits } = event.input;
const editsText = edits
.map(
(e, i) =>
`=== Modifica ${i + 1} ===\nTROVA:\n${limitCodeSnippet(e.oldText, 200, 20000)}\nSOSTITUISCI CON:\n${limitCodeSnippet(e.newText, 400, 60000)}`,
)
.join("\n\n");
return `File da modificare: ${path}\n\nModifiche proposte:\n\n${editsText}\n\nSpiega in italiano cosa cambia, perche, e quali sono le implicazioni tecniche.`;
}
async function generateExplanation(
event: WriteToolCallEvent | EditToolCallEvent,
ctx: ExtensionContext,
): Promise<string | undefined> {
const model = ctx.model;
if (!model) return undefined;
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
if (!auth.ok || !auth.apiKey) return undefined;
const userPrompt = await buildUserPrompt(event);
const userMessage: UserMessage = {
role: "user",
content: [{ type: "text", text: userPrompt }],
timestamp: Date.now(),
};
return ctx.ui.custom<string | undefined>((tui, theme, _kb, done) => {
const loader = new BorderedLoader(tui, theme, "Genero spiegazione del codice proposto...");
loader.onAbort = () => done(undefined);
const run = async () => {
try {
const response = await complete(
model,
{ systemPrompt: EXPLAIN_SYSTEM_PROMPT, messages: [userMessage] },
{ apiKey: auth.apiKey, headers: auth.headers, signal: ctx.signal },
);
const text = response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n");
done(text || undefined);
} catch {
done(undefined);
}
};
run();
return loader;
});
}
function buildCodePreview(event: WriteToolCallEvent | EditToolCallEvent): string[] {
if (isToolCallEventType("write", event)) {
return event.input.content.split("\n");
}
const { edits } = event.input;
const out: string[] = [];
for (let i = 0; i < edits.length; i++) {
out.push(`=== Modifica ${i + 1} ===`);
out.push("--- TROVA ---");
out.push(...edits[i].oldText.split("\n"));
out.push("+++ SOSTITUISCI CON +++");
out.push(...edits[i].newText.split("\n"));
if (i < edits.length - 1) out.push("");
}
return out;
}
async function askHitlAction(
event: WriteToolCallEvent | EditToolCallEvent,
explanation: string | undefined,
ctx: ExtensionContext,
): Promise<HitlAction> {
const path = event.input.path;
const isWrite = event.toolName === "write";
const title = `${isWrite ? "Scrittura" : "Modifica"} file: ${path}`;
const items: SelectItem[] = [
{ value: "accept", label: "Accetta e applica", description: "Applica il codice proposto" },
{ value: "modify", label: "Modifica prima di applicare", description: "Edita il contenuto prima di scrivere/modificare" },
{ value: "discuss", label: "Discuti / chiedi", description: "Blocca e spiega cosa vuoi cambiare" },
{ value: "reject", label: "Rifiuta", description: "Non applicare la modifica" },
];
const contentLines: string[] = [];
if (explanation) {
contentLines.push("=== SPIEGAZIONE ===");
contentLines.push(...explanation.split("\n"));
contentLines.push("");
} else {
contentLines.push("Spiegazione non generata (modello non disponibile).");
contentLines.push("");
}
contentLines.push("=== CODICE PROPOSTO ===");
contentLines.push(...buildCodePreview(event));
return ctx.ui.custom<HitlAction>((tui, theme, _kb, done) => {
const container = new Container();
let focus: "panel" | "menu" = "panel";
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
container.addChild(new Text(theme.fg("accent", theme.bold(`🛡 ${title}`)), 1, 0));
container.addChild(new Text(theme.fg("muted", "Human-in-the-Loop: conferma richiesta"), 1, 0));
const panel = new ScrollablePanel(16, (s) => theme.fg("text", s));
panel.setLines(contentLines);
const panelLabel = new Text("", 1, 0);
container.addChild(panelLabel);
container.addChild(panel);
const selectList = new SelectList(items, Math.min(items.length, 10), {
selectedPrefix: (t: string) => theme.fg("accent", t),
selectedText: (t: string) => theme.fg("accent", t),
description: (t: string) => theme.fg("muted", t),
scrollInfo: (t: string) => theme.fg("dim", t),
noMatch: (t: string) => theme.fg("warning", t),
});
selectList.onSelect = (item) => done(item.value as HitlAction);
selectList.onCancel = () => done("reject");
container.addChild(selectList);
const helpText = new Text("", 1, 0);
container.addChild(helpText);
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
function updateLabels(width: number) {
const info = panel.getScrollInfo();
const label = focus === "panel"
? theme.fg("accent", theme.bold(`Spiegazione e codice [focus] (${info.start}-${info.end} di ${info.total})`))
: theme.fg("muted", `Spiegazione e codice (${info.start}-${info.end} di ${info.total})`);
panelLabel.setText(label);
const hint = focus === "panel"
? "↑↓ scrolla • Ctrl+U/Ctrl+D pagina • Tab menu • Esc rifiuta"
: "↑↓ naviga azione • Enter seleziona • Tab torna al testo • Esc rifiuta";
helpText.setText(theme.fg("dim", hint));
}
function refresh() {
panel.invalidate();
container.invalidate();
tui.requestRender();
}
return {
render: (width: number) => {
updateLabels(width);
return container.render(width);
},
invalidate: () => {
panel.invalidate();
container.invalidate();
},
handleInput: (data: string) => {
if (matchesKey(data, Key.escape)) {
done("reject");
return;
}
if (matchesKey(data, Key.tab)) {
focus = focus === "panel" ? "menu" : "panel";
refresh();
return;
}
if (focus === "panel") {
if (panel.handleInput(data)) {
refresh();
}
} else {
selectList.handleInput(data);
refresh();
}
},
};
});
}
async function applyModify(
event: WriteToolCallEvent | EditToolCallEvent,
ctx: ExtensionContext,
): Promise<string | undefined> {
if (isToolCallEventType("write", event)) {
const original = event.input.content;
const edited = await ctx.ui.editor(`Modifica il contenuto di ${event.input.path}:`, original);
if (edited === undefined) return "Modifica annullata dall'utente";
event.input.content = edited;
return undefined;
}
const { path, edits } = event.input;
if (edits.length === 0) return undefined;
if (edits.length === 1) {
const edited = await ctx.ui.editor(`Modifica il nuovo testo per ${path}:`, edits[0].newText);
if (edited === undefined) return "Modifica annullata dall'utente";
edits[0].newText = edited;
return undefined;
}
const labels = edits.map((e, i) => `Blocco ${i + 1}: ${e.oldText.slice(0, 40).replace(/\n/g, " ")}...`);
const selected = await ctx.ui.select("Quale blocco vuoi modificare?", labels);
if (!selected) return "Modifica annullata dall'utente";
const idx = labels.indexOf(selected);
if (idx < 0) return "Blocco non trovato";
const edited = await ctx.ui.editor(`Modifica il nuovo testo del blocco ${idx + 1} per ${path}:`, edits[idx].newText);
if (edited === undefined) return "Modifica annullata dall'utente";
edits[idx].newText = edited;
return undefined;
}
let hitlEnabled = true;
export default function hitlProgramming(pi: ExtensionAPI) {
console.log("HITL programming extension loaded");
pi.registerCommand("hitl", {
description: "Attiva o disattiva HITL programming",
handler: async (args, ctx) => {
const arg = args.trim().toLowerCase();
if (arg === "off" || arg === "disattiva" || arg === "disable") {
hitlEnabled = false;
} else if (arg === "on" || arg === "attiva" || arg === "enable") {
hitlEnabled = true;
} else {
hitlEnabled = !hitlEnabled;
}
ctx.ui.notify(
`HITL programming ${hitlEnabled ? "attivato" : "disattivato"} (F10 oppure /hitl ${hitlEnabled ? "off" : "on"})`,
hitlEnabled ? "info" : "warning",
);
},
});
pi.registerShortcut("f10", {
description: "Attiva o disattiva HITL programming",
handler: async (ctx) => {
hitlEnabled = !hitlEnabled;
ctx.ui.notify(
`HITL programming ${hitlEnabled ? "attivato" : "disattivato"} (F10 oppure /hitl ${hitlEnabled ? "off" : "on"})`,
hitlEnabled ? "info" : "warning",
);
},
});
pi.on("before_agent_start", async (_event, ctx) => {
return {
systemPrompt: ctx.getSystemPrompt() + HITL_SYSTEM_PROMPT_APPENDIX,
};
});
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "write" && event.toolName !== "edit") return undefined;
if (!hitlEnabled) return undefined;
if (!isToolCallEventType("write", event) && !isToolCallEventType("edit", event)) {
return undefined;
}
const path = event.input.path;
if (!isProgrammingFile(path)) {
ctx.ui.notify(`HITL: salto file non di programmazione ${path}`, "info");
return undefined;
}
if (!ctx.hasUI) {
return {
block: true,
reason: "HITL programming extension richiede la UI interattiva di pi. Riprova in modalita TUI.",
};
}
ctx.ui.notify(`HITL: richiedo conferma per ${event.toolName} ${path}`, "info");
const explanation = await generateExplanation(event, ctx);
const action = await askHitlAction(event, explanation, ctx);
if (action === "reject") {
return { block: true, reason: `L'utente ha rifiutato ${event.toolName} su ${path}` };
}
if (action === "discuss") {
const topic = await ctx.ui.input("Cosa vuoi discutere o modificare?", "");
return {
block: true,
reason: `L'utente vuole discutere ${event.toolName} su ${path}: ${topic || "(nessun dettaglio fornito)"}`,
};
}
if (action === "modify") {
const reason = await applyModify(event, ctx);
if (reason) {
return { block: true, reason };
}
}
return undefined;
});
}