- #1 hasUI: real 0.80.3 comment is "true in TUI and RPC modes" (doc had the old 0.73.1 "false in print/RPC"). hasUI no longer distinguishes TUI from RPC, so it is NOT a fallback for the interactive branch — only ctx.mode === "tui" is. - #2 info/freetext: buildInfoRequest exists in builders.js but is NOT wired into tht-gate.js (imports only select/multiselect/artifact-gate). info notices are hand-written ctx.ui.notify; freetext is a control, not a descriptor. Table now lists only the 3 real present() descriptors. - #3 cite the REWRITE banner (tht-gate.js:8-10), not :227; id-match quoted verbatim. - #4 new "Note implementative": 227 comment, 4/6 unguarded notify, and the ctx.hasUI guard migration side-effect (now fires in RPC on 0.80.3). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
13 KiB
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).
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: "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: guardarectx.mode === "tui".ctx.hasUI: boolean(types.d.ts:213) — commento reale: "Whether dialog-capable UI is available (true in TUI and RPC modes)". ⚠️ Ètruesia in TUI sia in RPC: quindi non distingue interattivo da RPC e non è un fallback valido per il ramo TUI — soloctx.mode === "tui"lo fa. (Nota migrazione: il gate usa oggiif (ctx.hasUI)a tht-gate.js:319,397 con la vecchia semantica 0.73.1, dovehasUIerafalsein 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<string|undefined>(69),confirm(title, message, opts?) → Promise<boolean>(71),input(title, placeholder?, opts?) → Promise<string|undefined>(73),notify(message, type?)(75),editor(title, prefill?) → Promise<string|undefined>(134), ecustom<T>(factory) → Promise<T>(116, componente TUI a pieno controllo).notifyfunziona 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):
/**
* @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:
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 unpresent(ctx, descriptor): Promise<UiResponse>mode-aware:if (ctx.mode !== "tui")→ percorso ATTUALE (RPC/json/print):ctx.ui.input(JSON.stringify(descriptor))+JSON.parse+ id-match. Nessun cambiamento nel frontend.else(ctx.mode === "tui", interattivo) → renderizza il descrittore con le primitive native e sintetizza unUiResponsecon lo stessoide la stessa forma, così i classificatori a valle non cambiano di una riga.
I 3 gate bloccanti che passano da emitAndWait sono l'intera superficie da rendere:
select (tht-gate.js:492), 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 |
ctx.ui.select(title, [...labels, "Go back", "Exit", "Other — specify"]); "Other"→ctx.ui.input |
re-present una volta; se ancora undefined → {control:"exit"} |
{id, choices:[optId]} o {control:"back"/"exit"/"freetext", text} |
multiselect |
ctx.ui.custom (checkbox list); fallback low-cost: input con indici separati da virgola |
re-present una volta; poi {control:"exit"} |
{id, choices:[TUTTI gli id scelti]} — vedi nota reviewer_decide sotto |
artifact-gate |
artefatto via ctx.ui.editor(title, testo) (multi-linea, meglio di notify) + ctx.ui.confirm/select approve/reject/other |
re-present (advance solo su approve/reject esplicito, mai implicito) | {id, choices:["approve"/"reject"]} o control |
Non sono widget-descriptor (nessuna riga present() dedicata):
freetextnon è un widget a sé: è ilcontrol:"freetext"restituito dall'opzione «Altro/Other» diselect/artifact-gate→ nel ramo TUI si raccoglie conctx.ui.inputdentro il render di quei due.infonon passa dapresent(): sonoctx.ui.notify(..., "info")scritti a mano (es. tht-gate.js:567, avviso F2 memoria vuota), già mode-agnostic.buildInfoRequestesiste inbuilders.jsma 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.selectritorna la label (string), non l'id —options: string[]→Promise<string|undefined>(types.d.ts:69). Il renderer deve rimappare label→option.id.customevita il problema ma costa di più.- Nessuna multiselect nativa:
ExtensionUIContextnon la espone →custom(checkbox, livello A) o fallback a input indicizzato (livello C). - Dismiss / no-limbo nativo:
select/confirm/input/editorritornanoundefinedsu cancel/Esc;ExtensionUIDialogOptionsesponesignal?: AbortSignal(types.d.ts:37) etimeout?per chiudere il dialogo programmaticamente. Il renderer deve gestire l'undefinedesplicitamente (colonna no-limbo). editormulti-linea disponibile (types.d.ts:134) — preferibile anotifyper il testo dell'artefatto nel TUI.- (Non verificabile dai soli tipi, e comunque irrilevante: se
customsia 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("<kind> 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 → servectx.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 suUiResponse. 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: introdurrepresent(ctx, descriptor)e sostituire le 3 chiamate aemitAndWait(reviewer_select/decide/confirm) con essa; il ramo RPC è l'attualeemitAndWait.- Nuovo modulo puro es.
harness/.pi/extensions/gate/interactive-render.js(descriptor → prompt testuale/native + parse risposta →UiResponse), così è L1-testabile in isolamento comebuilders.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 dice
"usa l'API UI NATIVA di Pi (ctx.ui.input)". Quando
emitAndWaitviene incapsulato inpresent()con un ramo che usa i widget TUI nativi (nonctx.ui.input), quel commento diventa fuorviante → va riscritto insieme al codice. ctx.ui.notifyda razionalizzare. 6 notify nel gate; solo 319 e 397 sono guardate dactx.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
ctx.hasUIda rivedere (regresso di migrazione). 319/397 erano "solo TUI" con la semantica 0.73.1 (hasUI=falsein RPC). Su 0.80.3hasUI=trueanche in RPC, quindi ora scattano in RPC (es. riga 321 "Esc non chiude il gate…", priva di senso nel browser). L'intento va espresso conctx.mode === "tui". Indipendente dal dual-mode, ma da sistemare comunque.
Test (il vero guardrail contro la deriva)
- Property test round-trip (non solo "descriptor→UiResponse"): per ogni
descriptor prodotto da
builders.js, ilUiResponsesintetizzato dainteractive-render, passato ai classificatori esistenti (resolveSelectOutcome/resolveConfirmOutcome/selectedChoice), deve produrre lo stesso{kind, ...}delUiResponseche 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_decidenon passa dai classificatori; leggeresp.choicesdirettamente (tht-gate.js:594: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: la sintesi interattiva deve impostare
id = descriptor.id(altrimentiemitAndWaitscarta la risposta, tht-gate.js:312). - 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.