Browse Source

Initial commit: pi-hitl-programming extension

master
Matteo Benedetto 3 days ago
commit
e76accd1ee
  1. 7
      .gitignore
  2. 498
      .pi/extensions/hitl-programming.ts
  3. 84
      README.md
  4. 3151
      package-lock.json
  5. 20
      package.json
  6. 498
      src/index.ts
  7. 141
      tetris.py

7
.gitignore vendored

@ -0,0 +1,7 @@
node_modules/
__pycache__/
*.pyc
*.log
.DS_Store
*.tmp
/tmp/

498
.pi/extensions/hitl-programming.ts

@ -0,0 +1,498 @@
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;
});
}

84
README.md

@ -0,0 +1,84 @@
# pi-hitl-programming
Estensione per **pi coding agent** che introduce un **Human-in-the-Loop (HITL)** focalizzato sulla programmazione.
Ogni volta che l'agente tenta di **scrivere** (`write`) o **modificare** (`edit`) un file di programmazione, l'estensione:
1. Mostra il percorso del file.
2. Genera una **spiegazione brevissima** (massimo 5-6 righe) del codice proposto.
3. Mostra il **codice proposto** in una finestra scrollabile.
4. Chiede all'utente cosa fare:
- ✅ **Accetta e applica**
- ✏ **Modifica prima di applicare**
- 💬 **Discuti / chiedi**
- ❌ **Rifiuta**
Se l'utente sceglie di modificare, può editare il contenuto direttamente nell'editor di pi. Se sceglie di discutere, può scrivere una nota che viene riportata all'agente come motivo di blocco.
## Controlli nella schermata HITL
- **↑ / ↓**: scrolla il testo riga per riga.
- **Ctrl+U / Ctrl+D**: scrolla di una pagina.
- **Home / End**: va all'inizio / fine del testo.
- **Tab**: sposta il focus tra il pannello di testo e il menu azioni.
- **Enter**: seleziona l'azione evidenziata (quando il menu ha il focus).
- **Esc**: rifiuta la modifica.
## Attivare / disattivare HITL al volo
L'estensione può essere accesa o spenta senza uscire da pi:
- Tasto **F10**.
- Comando slash **`/hitl`**, `/hitl on`, `/hitl off`.
Una notifica in TUI conferma lo stato attuale.
## File intercettati
L'HITL si attiva solo su file con estensione di programmazione/configurazione (es. `.py`, `.js`, `.ts`, `.java`, `.c`, `.cpp`, `.go`, `.rs`, `.sh`, `.html`, `.css`, `.json`, `.yaml`, `.toml`, `.md`, …). I file generici (es. `.txt`) vengono lasciati passare.
## Installazione
L'estensione e auto-scoperta da pi quando si trova in un percorso riconosciuto.
### Project-local (consigliato per questo progetto)
Il repository contiene gia una copia in `.pi/extensions/hitl-programming.ts`. Devi solo **fidarti del progetto** e ricaricare:
```bash
cd /home/enne2/Development/pi-hitl
pi
# dentro pi, quando richiesto, seleziona "Trust"
/reload
```
### Globale
```bash
cp /home/enne2/Development/pi-hitl/src/index.ts ~/.pi/agent/extensions/hitl-programming.ts
# riavvia pi o esegui /reload
```
## Test rapido
Dopo il caricamento, dentro pi chiedi:
```
scrivi un file hello.py con contenuto print("ciao")
```
Dovrebbe apparire una schermata HITL con:
- spiegazione breve del codice proposto;
- anteprima del codice scrollabile;
- menu per accettare, modificare, discutere o rifiutare.
## Modalita non interattiva
In modalita `--mode print` o `--mode json` l'estensione **blocca** scritture e modifiche perche non puo mostrare la UI. Usala solo in modalita TUI interattiva.
## Note
- L'estensione aggiunge un'istruzione al system prompt per scoraggiare l'agente dall'usare `bash` con redirezioni (`>`, `>>`) per aggirare la conferma.
- La spiegazione e generata a partire dal codice completo proposto (fino a 60.000 caratteri), non dalla sola anteprima TUI.
- Quando HITL e disattivato, le operazioni `write`/`edit` su file di programmazione procedono normalmente.

3151
package-lock.json generated

File diff suppressed because it is too large Load Diff

20
package.json

@ -0,0 +1,20 @@
{
"name": "pi-hitl-programming",
"version": "0.1.0",
"description": "Human-in-the-Loop extension for pi coding agent, focused on programming",
"type": "module",
"main": "./src/index.ts",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"@earendil-works/pi-coding-agent": "^0.79.6",
"@earendil-works/pi-ai": "^0.79.6",
"@earendil-works/pi-tui": "^0.79.6"
},
"pi": {
"extensions": [
"./src/index.ts"
]
}
}

498
src/index.ts

@ -0,0 +1,498 @@
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;
});
}

141
tetris.py

@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Implementazione minimale di Tetris da terminale con curses."""
import curses
import random
import time
# Tetromini rappresentati come matrici di 0/1
SHAPES = [
[[1, 1, 1, 1]], # I
[[1, 1], [1, 1]], # O
[[0, 1, 0], [1, 1, 1]], # T
[[0, 1, 1], [1, 1, 0]], # S
[[1, 1, 0], [0, 1, 1]], # Z
[[1, 0, 0], [1, 1, 1]], # J
[[0, 0, 1], [1, 1, 1]], # L
]
COLORS = [1, 2, 3, 4, 5, 6, 7]
def new_piece():
"""Restituisce un pezzo casuale (deep copy)."""
return [row[:] for row in random.choice(SHAPES)]
def rotate(piece):
"""Ruota il pezzo di 90 gradi in senso orario."""
return [list(row) for row in zip(*piece[::-1])]
def collide(grid, piece, x, y):
"""Verifica se il pezzo collide con i bordi o con blocchi già presenti."""
for py, row in enumerate(piece):
for px, cell in enumerate(row):
if cell:
nx, ny = x + px, y + py
if nx < 0 or nx >= len(grid[0]) or ny >= len(grid):
return True
if ny >= 0 and grid[ny][nx]:
return True
return False
def lock(grid, piece, x, y, color):
"""Blocca il pezzo nella griglia."""
for py, row in enumerate(piece):
for px, cell in enumerate(row):
if cell:
grid[y + py][x + px] = color
def clear_lines(grid):
"""Elimina le linee complete e ne conta quante."""
full = [i for i, row in enumerate(grid) if all(row)]
for i in full:
del grid[i]
grid.insert(0, [0] * len(grid[0]))
return len(full)
def draw(stdscr, grid, piece, px, py, score):
"""Disegna la griglia, il pezzo attivo e il punteggio."""
stdscr.clear()
h, w = len(grid), len(grid[0])
for y, row in enumerate(grid):
for x, cell in enumerate(row):
if cell:
stdscr.addstr(y + 1, x * 2 + 1, "[]", curses.color_pair(cell))
else:
stdscr.addstr(y + 1, x * 2 + 1, " .")
for y, row in enumerate(piece):
for x, cell in enumerate(row):
if cell and py + y >= 0:
stdscr.addstr(py + y + 1, (px + x) * 2 + 1, "[]", curses.color_pair(8))
stdscr.addstr(0, w * 2 + 4, f"Score: {score}")
stdscr.refresh()
def main(stdscr):
curses.curs_set(0)
stdscr.nodelay(True)
stdscr.timeout(50)
# Inizializza le coppie di colori
for i in range(1, 8):
curses.init_pair(i, i, curses.COLOR_BLACK)
curses.init_pair(8, curses.COLOR_WHITE, curses.COLOR_WHITE)
grid = [[0] * 10 for _ in range(20)]
piece = new_piece()
px, py = 3, 0
score = 0
fall_time = 0.5
last_fall = time.time()
while True:
key = stdscr.getch()
if key == curses.KEY_LEFT:
if not collide(grid, piece, px - 1, py):
px -= 1
elif key == curses.KEY_RIGHT:
if not collide(grid, piece, px + 1, py):
px += 1
elif key == curses.KEY_DOWN:
if not collide(grid, piece, px, py + 1):
py += 1
elif key == ord(" "):
rotated = rotate(piece)
if not collide(grid, rotated, px, py):
piece = rotated
elif not collide(grid, rotated, px + 1, py):
piece, px = rotated, px + 1
elif not collide(grid, rotated, px - 1, py):
piece, px = rotated, px - 1
elif key == ord("q"):
break
now = time.time()
if now - last_fall > fall_time:
if collide(grid, piece, px, py + 1):
lock(grid, piece, px, py, random.choice(COLORS))
score += clear_lines(grid) * 100
piece = new_piece()
px, py = 3, 0
if collide(grid, piece, px, py):
break
else:
py += 1
last_fall = now
draw(stdscr, grid, piece, px, py, score)
stdscr.nodelay(False)
stdscr.addstr(len(grid) // 2, 0, "GAME OVER - Premi un tasto per uscire")
stdscr.refresh()
stdscr.getch()
if __name__ == "__main__":
curses.wrapper(main)
Loading…
Cancel
Save