# Valutazione: doppia gestione del gate (TUI nativo interattivo + widget-descriptor RPC) Date: 2026-07-04 Status: valutazione (nessuna implementazione — solo analisi di fattibilità) Pi runtime di riferimento: `@earendil-works/pi-coding-agent` **0.80.3** — versione live sul PATH dal 2026-07-04 (migrazione dal vecchio scope `@mariozechner`, congelato a 0.73.1; validata via `model-matrix` con GLM 5.2, scenari `new`+`resume` entrambi CHAINED, e uno smoke full-stack con gate F1 risposto). Tutte le citazioni `types.d.ts` sotto sono relative a **questa** versione. ## Context Domanda: è pensabile una **doppia gestione** del gate HITL? Interattiva con le primitive UI standard di Pi quando `pi` è lanciato da CLI, e quella attuale (widget-descriptor JSON verso il frontend) quando è lanciato in RPC. Oggi il gate ha **una sola** via di emissione: `emitAndWait` → `ctx.ui.input(JSON.stringify(descriptor), "")` ([tht-gate.js:300](../../../harness/.pi/extensions/tht-gate.js)). In RPC il backend fa da bridge (`extension_ui_request` method `input` → SSE) e il frontend React rende i descrittori come widget veri. In interattivo, invece, `ctx.ui.input` mostra il **JSON grezzo** come prompt e pretende una risposta `UiResponse` in JSON digitata a mano → non usabile. Il design ha volutamente sostituito i widget TUI nativi con l'emissione descriptor — banner REWRITE punto (2), [tht-gate.js:8-10](../../../harness/.pi/extensions/tht-gate.js): *"the reviewer interaction is emitted as a widget-descriptor JSON … instead of rendered by blocking native TUI primitives (ctx.ui.select/custom)"*. ## Verdetto: SÌ, è pensabile ed è architetturalmente supportato Pi **è già mode-aware**, quindi la doppia gestione non va "inventata", va solo sfruttata: - **`ctx.mode: ExtensionMode`** (types.d.ts:207,212) — `"tui" | "rpc" | "json" | "print"`, con commento dei tipi: *"Use 'tui' to guard terminal-only UI such as custom components"*. È **il** discriminatore corretto per il ramo interattivo: guardare `ctx.mode === "tui"`. - **`ctx.hasUI: boolean`** (types.d.ts:213) — commento reale: *"Whether dialog-capable UI is available (**true in TUI and RPC modes**)"*. ⚠️ È `true` **sia** in TUI **sia** in RPC: quindi **non** distingue interattivo da RPC e **non** è un fallback valido per il ramo TUI — solo `ctx.mode === "tui"` lo fa. (Nota migrazione: il gate usa oggi `if (ctx.hasUI)` a [tht-gate.js:319,397](../../../harness/.pi/extensions/tht-gate.js) con la vecchia semantica 0.73.1, dove `hasUI` era `false` in RPC. Su 0.80.3 quelle due guardie scattano **anche** in RPC → vedi *Note implementative*.) - **`ExtensionUIContext`** (types.d.ts:63-67) — "Each mode (interactive, RPC, print) provides its own implementation". Espone nativamente: `select(title, options: string[], opts?) → Promise` (69), `confirm(title, message, opts?) → Promise` (71), `input(title, placeholder?, opts?) → Promise` (73), `notify(message, type?)` (75), `editor(title, prefill?) → Promise` (134), e `custom(factory) → Promise` (116, componente TUI a pieno controllo). `notify` funziona già in entrambe le modalità. ## `UiResponse` è un tipo INTERNO ThothII (non un export di Pi) Tutta la logica a valle consuma un `UiResponse`: **non** è un tipo di Pi, è il contratto condiviso tra il gate e il frontend/renderer per la risposta a un descriptor. Va documentato esplicitamente (JSDoc typedef nel modulo del gate): ```js /** * @typedef {Object} UiResponse Risposta a un widget-descriptor. Tipo INTERNO * ThothII (NON esportato da Pi): contratto condiviso gate ↔ frontend/renderer. * @property {string} [id] deve combaciare con descriptor.id (invariante emitAndWait) * @property {string[]} [choices] id opzione/i scelte (select: 1 elem; multiselect: N) * @property {string} [choice] forma singola, alternativa a choices * @property {string} [control] "back" | "exit" | "freetext" | "cancel" * @property {string} [text] testo libero (freetext) o input "Altro — specificare" */ ``` ## Design proposto (una sola cucitura, riuso di tutto il resto) L'eleganza sta nel fatto che a valle tutto consuma un `UiResponse`, classificato da `resolveSelectOutcome` / `resolveConfirmOutcome` / `selectedChoice`, con l'invariante id-match in `emitAndWait` ([tht-gate.js:312](../../../harness/.pi/extensions/tht-gate.js): `if (resp && resp.control !== "cancel" && resp.id === descriptor.id)`). Quindi: - **Invariati**: `gate/builders.js` (costruzione descrittori) e i classificatori di esito. Sono il contratto condiviso tra le due modalità. - **Unica modifica**: sostituire `emitAndWait(ctx, descriptor)` con `presentBlockingWidget(ctx, descriptor): Promise` **mode-aware**. Il confine **non** è solo il trasporto: è **presentazione + loop no-limbo + validazione della risposta** — oggi `emitAndWait` fa già trasporto → `JSON.parse` → check `cancel` → id-match → re-present su risposta invalida ([tht-gate.js:298](../../../harness/.pi/extensions/tht-gate.js)). Entrambi i rami confluiscono in un **unico validatore condiviso** `validateUiResponse(descriptor, resp)`: - `if (ctx.mode !== "tui")` → percorso ATTUALE (RPC/json/print): `ctx.ui.input(JSON.stringify(descriptor))` + `JSON.parse` → `validateUiResponse`. Nessun cambiamento nel frontend. - `else` (`ctx.mode === "tui"`) → `renderTuiDescriptor` con le primitive native → **sintetizza** un `UiResponse` → **stesso** `validateUiResponse`. ⚠️ **Critico**: in TUI l'id-match di `emitAndWait` **non gira più** (present lo sostituisce), quindi l'invariante `resp.id === descriptor.id` va **validato esplicitamente** qui — non è più protetto in automatico. I **3 gate bloccanti** che passano da `emitAndWait` sono l'intera superficie da rendere: `select` ([tht-gate.js:492](../../../harness/.pi/extensions/tht-gate.js)), `multiselect` (576), `artifact-gate` (639). La colonna **no-limbo** dice cosa sintetizzare quando la primitiva nativa ritorna `undefined` (Esc / cancel / timeout / `signal`): | widget | render interattivo | no-limbo (nativo → `undefined`) | UiResponse sintetizzato | |---|---|---|---| | `select` | opzioni **numerate** (`1. label … / b) Torna / e) Esci / o) Altro`); parsing **per indice → id**, MAI per label | re-present una volta; se ancora `undefined` → `{control:"exit"}` | `{id, choices:[optId]}` o `{control:"back"/"exit"/"freetext", text}` | | `multiselect` | `ctx.ui.custom` (checkbox) **oppure** `ctx.ui.input` **indicizzato** (`1,3,4`) → dedupe + ordine stabile + **rifiuto indici invalidi** | re-present una volta; poi `{control:"exit"}` | `{id, choices:[TUTTI gli id scelti]}` — vedi nota reviewer_decide sotto | | `artifact-gate` | artefatto mostrato con `ctx.ui.editor(title, testo)` **come viewer** (il contenuto ritornato è **ignorato** — il gate approva/rifiuta, non modifica) + scelta approve/reject **numerata** | re-present (advance solo su approve/reject **esplicito**, mai implicito) | `{id, choices:["approve"/"reject"]}` o control | **Non sono widget-descriptor** (nessuna riga `present()` dedicata): - **`freetext`** non è un widget a sé: è il `control:"freetext"` restituito dall'opzione «Altro/Other» di `select`/`artifact-gate` → nel ramo TUI si raccoglie con `ctx.ui.input` dentro il render di quei due. - **`info`** non passa da `present()`: sono `ctx.ui.notify(..., "info")` scritti a mano (es. [tht-gate.js:567](../../../harness/.pi/extensions/tht-gate.js), avviso F2 memoria vuota), già mode-agnostic. `buildInfoRequest` **esiste** in `builders.js` ma **non è cablato** nel gate (l'import a tht-gate.js:29-31 porta solo select/multiselect/artifact-gate) — builder morto. ## Vincoli del runtime Pi verificati (0.80.3) - **`ctx.ui.select` ritorna la label** (string), non l'id — `options: string[]` → `Promise` (types.d.ts:69). **NON rimappare per label** (label duplicate/localizzate collidono): opzioni **numerate** + parsing **per indice → id**. Il contratto ThothII è sugli id, e il frontend risponde già sempre con id, non label ([SelectWidget:26](../../../frontend/src/widgets/SelectWidget.tsx), [MultiselectWidget:66](../../../frontend/src/widgets/MultiselectWidget.tsx)). - **Nessuna multiselect nativa**: `ExtensionUIContext` non la espone → `custom` (checkbox, livello A) o fallback a input indicizzato (livello C). - **Dismiss / no-limbo nativo**: `select`/`confirm`/`input`/`editor` ritornano `undefined` su cancel/Esc; `ExtensionUIDialogOptions` espone `signal?: AbortSignal` (types.d.ts:37) e `timeout?` per chiudere il dialogo programmaticamente. Il renderer **deve** gestire l'`undefined` esplicitamente (colonna no-limbo). - **`editor` multi-linea** disponibile (types.d.ts:134) — preferibile a `notify` per il testo dell'artefatto nel TUI. - (Non verificabile dai soli tipi, e comunque **irrilevante**: se `custom` sia supportato in RPC — lo usiamo **solo** nel ramo interattivo.) ## Contratto d'estensibilità (nuovo `widget` kind) Per mitigare a livello di codice il rischio "due percorsi da tenere in sync": il seam interattivo ha un `default:` esplicito. Un `widget` privo di renderer TUI dedicato **non** deve half-funzionare in silenzio → `ctx.ui.notify(" non supportato in TUI", "warning")` + re-present/abort controllato. Così `builders.js` + classificatori restano l'unica fonte condivisa, e un kind nuovo **fallisce forte** in TUI finché non gli si dà un renderer, invece di derivare silenziosamente dal percorso RPC. ## Due livelli di implementazione (scegliibili in seguito) - **A — widget nativi Pi** (`select/confirm/editor/custom`): esperienza TUI curata; il pezzo più costoso è la **multiselect** (Pi non ha una multiselect nativa → serve `ctx.ui.custom`). - **C — render testuale + input numerato** (minimo): in interattivo stampo il descrittore ben formattato (titolo/intro/opzioni numerate) e leggo un numero/lettera via `ctx.ui.input`, poi mappo su `UiResponse`. Molto meno lavoro, sufficiente "a fini di valutazione/dev". **Consigliato** dato lo scopo dichiarato. ## Superficie di modifica (se/quando si implementa) - `harness/.pi/extensions/tht-gate.js`: introdurre `present(ctx, descriptor)` e sostituire le 3 chiamate a `emitAndWait` (reviewer_select/decide/confirm) con essa; il ramo RPC è l'attuale `emitAndWait`. - Nuovo modulo puro `harness/.pi/extensions/gate/interactive-render.js` a **due strati**: `renderTuiDescriptor(descriptor)` (descriptor → prompt numerato/native) e `validateSyntheticResponse(descriptor, resp)` (= `validateUiResponse`, **condiviso** dal ramo TUI e dal ramo RPC dopo `JSON.parse`). Entrambi L1-testabili come `builders.js`. - Nessun cambiamento a `builders.js`, ai classificatori, al backend o al frontend. ## Note implementative (checklist prima di scrivere present()) - **Il commento a [tht-gate.js:227](../../../harness/.pi/extensions/tht-gate.js)** dice *"usa l'API UI NATIVA di Pi (ctx.ui.input)"*. Quando `emitAndWait` viene incapsulato in `present()` con un ramo che usa i **widget TUI nativi** (non `ctx.ui.input`), quel commento diventa fuorviante → va riscritto insieme al codice. - **`ctx.ui.notify` da razionalizzare.** 6 notify nel gate; solo 319 e 397 sono guardate da `ctx.hasUI`, le altre 4 sono **non guardate** (567, 651, 824, 831). Oggi in RPC sono fire-and-forget verso il frontend; in TUI diventerebbero warning a terminale. Decidere per-notify se appartiene a entrambe le modalità — in particolare la **651** è dentro il loop di re-present del confirm gate. - **Guardie modo-dipendenti (risolte).** Entrambi i messaggi TUI-only sono ora guardati da `ctx.mode === "tui"`: reLoop anti-limbo ([tht-gate.js:322](../../../harness/.pi/extensions/tht-gate.js)) e steering-durante-lock ([tht-gate.js:400](../../../harness/.pi/extensions/tht-gate.js)). In RPC il testo libero durante il lock resta comunque `{action:"handled"}` (silenziato) — cambia solo che il warning da terminale non viene più inviato al browser. Nessuna guardia `ctx.hasUI` residua per messaggi TUI-only. ## Test (il vero guardrail contro la deriva) - **Property test round-trip** (non solo "descriptor→UiResponse"): per **ogni** descriptor prodotto da `builders.js`, il `UiResponse` sintetizzato da `interactive-render`, passato ai classificatori esistenti (`resolveSelectOutcome` / `resolveConfirmOutcome` / `selectedChoice`), deve produrre **lo stesso** `{kind, ...}` del `UiResponse` che il frontend invierebbe per lo stesso descriptor. È **questo** il guardrail contro la deriva tra i due percorsi, non un test di sola forma. - **reviewer_decide (multiselect) — lettura inline**: `reviewer_decide` **non** passa dai classificatori; legge `resp.choices` **direttamente** ([tht-gate.js:594](../../../harness/.pi/extensions/tht-gate.js): `opts.filter(o => (resp.choices ?? []).includes(o.id))`). Il test di non-regressione deve quindi verificare che la sintesi interattiva del multiselect popoli **tutti** gli id scelti (non solo il primo), altrimenti in TUI si perderebbero selezioni. - **Invariante id-match**: in TUI `emitAndWait` non gira più → `validateUiResponse` deve verificare `resp.id === descriptor.id` **esplicitamente** (non basta che la sintesi imposti l'id: va validato, come faceva tht-gate.js:312). - **Casi negativi** (oltre al round-trip): id sbagliato, scelta inesistente, **label duplicate**, multiselect con **duplicati**, `allow_empty=false` con input vuoto, `undefined` ripetuto (no-limbo), `control:"cancel"`. - **Smoke TUI reale su Pi 0.80.3**: i property test coprono la *deriva del contratto* ma **non** il comportamento delle primitive native (`select`/`input`/`editor`/`custom`) → serve almeno uno smoke interattivo vero, come già fatto per il percorso RPC. - **RPC invariato**: backend bridge + 115 test frontend + 53 test harness restano da rigirare per non-regressione; il percorso descriptor non cambia. ## Trade-off e limiti (onesti) - **Fedeltà interattiva ridotta**: niente modal ricco, niente **erDiagram**/grafo schema-linking, niente rendering strutturato dell'artefatto — in TUI l'artefatto è testo (via `editor`). Va benissimo per debug/valutazione, **non** per la review clinica completa. - **Multiselect**: nessuna primitiva nativa → `ctx.ui.custom` (livello A) o fallback a input indicizzato (livello C). - **Testabilità**: il render nativo richiede il runtime TUI di Pi (difficile da unit-testare); la parte pura descriptor→`UiResponse` è testabile in L1 (round-trip sopra). Il percorso RPC resta invariato e già coperto. ## Conclusione Fattibile e pulito: la doppia gestione si ottiene con **una sola cucitura mode-aware** (`present`) attorno a `emitAndWait`, sfruttando che Pi è già mode-aware via `ctx.mode` (`=== "tui"` per il ramo interattivo; **non** `hasUI`, che su 0.80.3 è `true` anche in RPC) e che a valle tutto consuma un `UiResponse` interno. Il costo dipende dal livello di fedeltà interattiva voluto (C minimo, A curato); il guardrail è il property test round-trip. Nessun impatto su RPC/frontend.