499 lines
16 KiB
TypeScript
499 lines
16 KiB
TypeScript
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;
|
|
});
|
|
}
|