From c4c1ad45204f80f39ebeb2861b8c8aaefd0e7341 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Thu, 15 Oct 2026 11:06:44 +0200 Subject: [PATCH 01/17] Documentazione: ADR su GUI Avalonia multipiattaforma e motori CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce docs/adr con ADR-0001 (GUI in solo C# con Avalonia per Windows e Linux) e ADR-0002 (Codex e Antigravity oltre a Claude Code), entrambi in stato Proposta. Aggiunge docs/README.md come indice unico con regole SSOT e checklist di rinomina, perché il nome del progetto non è ancora deciso. La roadmap ha la nuova Fase 5 e la Fase 4 ora contiene solo i passi, con le motivazioni spostate negli ADR. Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 6 +- README.md | 4 +- docs/MVP.md | 11 +++- docs/README.md | 37 ++++++++++++ docs/ROADMAP.md | 49 +++++++-------- .../adr/0001-gui-multipiattaforma-avalonia.md | 60 +++++++++++++++++++ ...002-motori-cli-claude-codex-antigravity.md | 47 +++++++++++++++ 7 files changed, 182 insertions(+), 32 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/adr/0001-gui-multipiattaforma-avalonia.md create mode 100644 docs/adr/0002-motori-cli-claude-codex-antigravity.md diff --git a/CLAUDE.md b/CLAUDE.md index 7edc027..bd538ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,8 +8,10 @@ LlmRun: eseguibile C# (.NET 10, Windows) che invoca Claude Code (`claude -p`) da ## Workflow doc-first (obbligatorio) -- Specifica e criteri di accettazione: [docs/MVP.md](docs/MVP.md). Fasi e stato: [docs/ROADMAP.md](docs/ROADMAP.md). Uso: [README.md](README.md). -- Ad ogni richiesta di modifica: aggiornare prima i doc interessati (MVP.md, ROADMAP.md con le spunte, README.md se cambia l'uso), poi il codice, poi questo file se cambiano comandi, design o vincoli. Nessuna modifica deve lasciare i doc disallineati. +- Specifica e criteri di accettazione: [docs/MVP.md](docs/MVP.md). Fasi e stato: [docs/ROADMAP.md](docs/ROADMAP.md). Indice dei doc e regole (SSOT, ADR): [docs/README.md](docs/README.md); una decisione architetturale si scrive solo nel suo ADR, gli altri doc la richiamano con un link. Uso: [README.md](README.md). +- Ad ogni richiesta di modifica: aggiornare prima i doc interessati (MVP.md, ROADMAP.md con le spunte, README.md se cambia l'uso; un nuovo ADR in `docs/adr/` per ogni scelta architetturale), poi il codice, poi questo file se cambiano comandi, design o vincoli. Nessuna modifica deve lasciare i doc disallineati. + +> In corso: migrazione a GUI Avalonia multipiattaforma ([ADR-0001](docs/adr/0001-gui-multipiattaforma-avalonia.md), ROADMAP Fase 5). Finché non è conclusa, le sezioni sotto descrivono lo stato attuale (WinForms + WebView2). ## Comandi diff --git a/README.md b/README.md index 95f9c81..67d60a2 100644 --- a/README.md +++ b/README.md @@ -32,4 +32,6 @@ bin\Debug\net10.0-windows\LlmRun.exe dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true ``` -Documentazione: [docs/MVP.md](docs/MVP.md), [docs/ROADMAP.md](docs/ROADMAP.md). +> **Piattaforme**: oggi solo Windows. È pianificato un eseguibile standalone per Windows e Linux con GUI in solo C# (Avalonia): vedi [ADR-0001](docs/adr/0001-gui-multipiattaforma-avalonia.md). La CLI è già portabile, la GUI attuale no. Pianificato anche l'uso di Codex CLI e Antigravity CLI già installati da terminale, oltre a Claude Code: [ADR-0002](docs/adr/0002-motori-cli-claude-codex-antigravity.md). + +Documentazione: [docs/MVP.md](docs/MVP.md), [docs/ROADMAP.md](docs/ROADMAP.md), [indice e decisioni architetturali](docs/README.md). diff --git a/docs/MVP.md b/docs/MVP.md index a89e052..2e0dcfb 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -5,7 +5,7 @@ Eseguibile C# a riga di comando che inoltra un prompt a Claude Code (`claude`) l ## Uso ``` -llmrun.exe "spiega questo progetto" + "spiega questo progetto" ``` Tutti gli argomenti sono uniti in un unico prompt. @@ -17,6 +17,8 @@ Tutti gli argomenti sono uniti in un unico prompt. 5. Se `claude` non è trovato: messaggio chiaro ed exit code 127. ## GUI (chat) +> **Stato**: questa sezione descrive la GUI attuale (WinForms + WebView2 + `ui.html`, solo Windows). La destinazione è una GUI Avalonia in solo C# per Windows e Linux: vedi [ADR-0001](adr/0001-gui-multipiattaforma-avalonia.md) e [ROADMAP.md](ROADMAP.md), Fase 5. Il comportamento funzionale (chat, sessioni, cronologia, selettori) resta valido; cambiano tecnologia, protocollo pagina↔C# e percorso della cronologia. + Senza argomenti si apre una finestra WinForms che contiene solo un WebView2: l'intera interfaccia (header, conversazione, composer) è HTML/CSS in `ui.html`, in stile claude.ai/ChatGPT. Con argomenti resta la CLI. **Struttura della pagina** @@ -26,7 +28,7 @@ Senza argomenti si apre una finestra WinForms che contiene solo un WebView2: l'i - **Composer** (in basso, scheda arrotondata): textarea che cresce fino a ~8 righe, selettori **Modello** (Predefinito, Fable 5.1, Opus 5.5, Sonnet 5.5, Haiku 5.5, "Altro ID…" con campo libero) ed **Effort** (Predefinito, low, medium, high, xhigh, max), pulsante Invia circolare con icona. `Invio` invia, `Maiusc+Invio` va a capo. Hint sulla tastiera sotto la scheda. - Passati come `--model` / `--effort`; con "Predefinito" il flag viene omesso. Solo GUI. - **Contesto**: `claude -p --session-id ` al primo messaggio, `--resume ` ai successivi. "Nuova chat" svuota la vista e genera un nuovo guid. -- **Cronologia**: dopo ogni scambio la chat è salvata in `%LOCALAPPDATA%\LlmRun\chats\.json` (guid = id di sessione di `claude`, titolo, data, flag `started`, messaggi con ruolo e testo). Riaprendo una chat si rimostra la conversazione e si prosegue con `--resume `, quindi `claude` ricorda il contesto. Il messaggio dell'utente viene salvato subito, prima della risposta (così non si perde se l'app si chiude mentre Claude risponde); la risposta si aggiunge appena arriva. Una chat nuova e vuota non viene salvata. **All'avvio l'app riapre automaticamente l'ultima conversazione** (la più recente); se non ce ne sono parte con la schermata iniziale. "Nuova chat" resta sempre disponibile. Eliminare una chat cancella solo il file JSON, non la sessione di `claude`. Non si può aprire/eliminare una chat mentre Claude sta rispondendo. +- **Cronologia**: dopo ogni scambio la chat è salvata in `\\chats\.json` (oggi `%LOCALAPPDATA%`; vedi [Nome del progetto](README.md#nome-del-progetto)) (guid = id di sessione di `claude`, titolo, data, flag `started`, messaggi con ruolo e testo). Riaprendo una chat si rimostra la conversazione e si prosegue con `--resume `, quindi `claude` ricorda il contesto. Il messaggio dell'utente viene salvato subito, prima della risposta (così non si perde se l'app si chiude mentre Claude risponde); la risposta si aggiunge appena arriva. Una chat nuova e vuota non viene salvata. **All'avvio l'app riapre automaticamente l'ultima conversazione** (la più recente); se non ce ne sono parte con la schermata iniziale. "Nuova chat" resta sempre disponibile. Eliminare una chat cancella solo il file JSON, non la sessione di `claude`. Non si può aprire/eliminare una chat mentre Claude sta rispondendo. - **Limite noto**: la sessione di `claude` è legata alla cartella in cui gira (quella dell'exe): se si sposta l'exe, le vecchie chat si leggono ancora ma `--resume` non ritrova il contesto. - La risposta compare a fine generazione (nessuno streaming); durante l'attesa un indicatore a puntini animati e Invia disabilitato. Se `claude` esce con errore, lo stderr appare come messaggio di errore. - I link si aprono nel browser di sistema. L'HTML grezzo nel markdown è disabilitato. @@ -45,13 +47,15 @@ Sorgente di verità per l'aspetto, derivato dalla skill `ui-ux-pro-max` (stile " - **Non incluso**: evidenziazione sintattica del codice (richiederebbe una libreria da incorporare). ## Scelte tecniche +Le scelte architetturali sono negli [ADR](README.md#indice-adr). Quanto segue descrive lo stato attuale. + - .NET 10 (WinForms). `Program.cs` (CLI + avvio) e `Gui.cs` (chat). Dipendenze NuGet solo per la GUI: `Markdig` (markdown→HTML) e `Microsoft.Web.WebView2` (richiede il runtime WebView2, già presente su Windows 11). - `ProcessStartInfo.ArgumentList` per passare il prompt (niente problemi di quoting). - `claude` è un .exe nativo (`~/.local/bin`): si avvia direttamente, senza `cmd /c`. Lo stdin è chiuso subito per evitare l'attesa di 3s di claude. - Working dir: `AppContext.BaseDirectory`, non la cwd del chiamante. ## Fuori scope (MVP) -Streaming token-per-token nella GUI, annullamento di una risposta in corso, rinomina/ricerca delle chat, config file, altri motori oltre a Claude Code (Codex, Antigravity: vedi [ROADMAP.md](ROADMAP.md), Fase 4), logging su file, test automatici, supporto Linux/macOS. +Streaming token-per-token nella GUI, annullamento di una risposta in corso, rinomina/ricerca delle chat, config file, altri motori oltre a Claude Code (Codex, Antigravity: vedi [ADR-0002](adr/0002-motori-cli-claude-codex-antigravity.md)), logging su file, test automatici, supporto macOS. Il supporto Linux è pianificato: [ADR-0001](adr/0001-gui-multipiattaforma-avalonia.md), Fase 5. ## Criteri di accettazione - [ ] Eseguito da un'altra cartella, `claude` parte nella cartella dell'exe (verificabile chiedendo "pwd"). @@ -60,5 +64,6 @@ Streaming token-per-token nella GUI, annullamento di una risposta in corso, rino - [ ] Un prompt con virgolette e spazi arriva intatto. ## Domande aperte +- Nome definitivo del progetto (oggi provvisorio: vedi [Nome del progetto](README.md#nome-del-progetto)). - Permessi: serve `--allowedTools` o `--permission-mode` per l'uso non interattivo? - Formato output: testo semplice o `--output-format stream-json`? diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b6d7c8e --- /dev/null +++ b/docs/README.md @@ -0,0 +1,37 @@ +# Documentazione + +Indice e regole. Ogni informazione vive in **un solo documento**; gli altri la richiamano con un link. + +| Documento | Contenuto (sorgente di verità per…) | +|---|---| +| [../README.md](../README.md) | Uso, requisiti, build e publish | +| [MVP.md](MVP.md) | Specifica funzionale, design UI/UX, criteri di accettazione | +| [ROADMAP.md](ROADMAP.md) | Fasi e stato (spunte); solo passi, mai motivazioni | +| `adr/NNNN-*.md` | Decisioni architetturali e loro motivazioni (indice sotto) | +| [../CLAUDE.md](../CLAUDE.md) | Guida per Claude Code: comandi, architettura del codice, vincoli | + +## Nome del progetto +Il nome è **provvisorio** (oggi `LlmRun`) e non è ancora deciso. Questa sezione è l'unica sorgente: gli altri doc scrivono "l'app" o ``. + +Se il nome cambia, aggiornare solo questi punti: +- [ ] `AssemblyName`/nome del csproj e dell'eseguibile (`.exe`, ``) +- [ ] Costante del nome nel codice (titolo finestra, intestazione della barra laterale, cartella dati) +- [ ] **Cartella dati** `//chats`: cambiando nome le chat salvate non si trovano più; migrare o leggere anche la vecchia cartella +- [ ] Titolo di [../README.md](../README.md) e riga iniziale di [../CLAUDE.md](../CLAUDE.md) (nome del csproj nei comandi) + +## Flusso (doc-first, doc-as-code) +1. Prima i doc, poi il codice: ADR (se c'è una scelta architetturale) → MVP → ROADMAP → README → codice → CLAUDE.md. +2. I doc stanno nel repo e cambiano con lo stesso commit del codice a cui si riferiscono. +3. Una modifica non deve lasciare i doc disallineati. + +## Regole degli ADR +- Un file per decisione, `adr/NNNN-titolo-breve.md`, numerazione progressiva. +- Stati: `Proposta` → `Accettata` → eventualmente `Sostituita da ADR-NNNN` o `Rifiutata`. +- Un ADR accettato non si riscrive: se la decisione cambia se ne scrive uno nuovo che lo sostituisce e si aggiorna lo stato del vecchio. +- Sezioni: Contesto, Criteri, Opzioni, Decisione, Conseguenze, Da verificare, Riferimenti. + +## Indice ADR +| ADR | Titolo | Stato | +|---|---|---| +| [0001](adr/0001-gui-multipiattaforma-avalonia.md) | GUI multipiattaforma in solo C# con Avalonia | Proposta | +| [0002](adr/0002-motori-cli-claude-codex-antigravity.md) | Più motori CLI (Claude Code, Codex, Antigravity) su Windows e Linux | Proposta | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 70ca0a6..3fea131 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -26,32 +26,29 @@ Dettagli dell'MVP in [MVP.md](MVP.md). - [ ] Test automatici e CI ## Fase 4 — Più motori (Claude Code, Codex, Antigravity) -Obiettivo: oltre a Claude Code, usare **OpenAI Codex CLI** e **Antigravity CLI** (`agy`) dalla stessa chat, scegliendo il motore da una nuova schermata **Impostazioni** della GUI. +Decisione, tabella dei comandi e rischi: [ADR-0002](adr/0002-motori-cli-claude-codex-antigravity.md). Qui solo i passi. Va fatta dopo (o insieme a) la Fase 5, che estrae il punto unico di avvio processo. -Flag delle tre CLI (da verificare con `--help` delle versioni installate: derivano da documentazione e ricerche, non da prove su questo PC, dove `codex` e `agy` non sono ancora installati): +- [ ] Verificare con `--help` i flag reali di `codex` e `agy` su Windows e Linux (ADR-0002) e portare l'ADR a "Accettata" +- [ ] Astrazione "motore" in C#: `RunClaude` diventa `RunEngine(engine, ...)` +- [ ] Rilevamento dei motori nel PATH; motori assenti disabilitati, messaggio chiaro +- [ ] Il JSON di ogni chat memorizza il motore; cambiare motore apre una nuova chat +- [ ] GUI: schermata Impostazioni (motore, modello, effort) persistita su disco; modelli ed effort in base al motore; motore della chat indicato nell'interfaccia +- [ ] Motore Codex: lettura dell'id di sessione dall'output per la ripresa +- [ ] Motore Antigravity: gestione del blocco con output rediretto (timeout, errore chiaro) +- [ ] Politica dei permessi: sola lettura di default, opzione esplicita per concedere di più +- [ ] CLI: opzione `--engine`, coerente con la GUI -| | Claude Code | Codex CLI | Antigravity CLI | -|---|---|---|---| -| Comando | `claude -p ` | `codex exec ` | `agy -p ` | -| Continuare | `--session-id ` poi `--resume ` | `codex exec resume ` | da verificare | -| Modello | `--model` | `-m` / `--model` | da verificare | -| Effort | `--effort` | via `--config` (da verificare) | non noto | +## Fase 5 — Multipiattaforma (Windows + Linux), GUI in solo C# +Decisione e motivazioni: [ADR-0001](adr/0001-gui-multipiattaforma-avalonia.md). Qui solo i passi. -**Architettura** -- [ ] Un'astrazione "motore" in C#: per ogni motore, eseguibile, argomenti per primo messaggio e per ripresa, elenco modelli, supporto effort, lettura dell'id di sessione. `RunClaude` diventa `RunEngine(engine, ...)`; la logica di avvio (cartella dell'exe, stdin chiuso) resta in un solo punto, eliminando la duplicazione CLI/GUI. -- [ ] Rilevamento dei motori installati (PATH) e messaggio chiaro se mancano; i motori assenti sono disabilitati nelle Impostazioni. -- [ ] Il file JSON di ogni chat memorizza il **motore**; riaprendo una chat si usa il suo motore. Il contesto di sessione non passa da un motore all'altro: cambiare motore apre una nuova chat. - -**GUI: Impostazioni** -- [ ] Schermata/pannello Impostazioni (icona nella barra laterale) con selettore del **motore** attivo. -- [ ] Il selettore Modello mostra solo i modelli del motore scelto; Effort compare solo se il motore lo supporta. -- [ ] Impostazioni persistite su disco (motore, modello, effort), così si ritrovano alla riapertura. Si lega al "config file" della Fase 3. -- [ ] Il motore della chat aperta è indicato nell'interfaccia (etichetta sull'intestazione o sulla voce in elenco). - -**Rischi e punti da verificare** -- [ ] **Codex**: l'id di sessione non lo sceglie l'app (lo genera Codex): va letto dall'output (probabilmente con l'output JSON) per poter riprendere la chat. -- [ ] **Antigravity**: segnalato un blocco di `agy -p` quando lanciato da sottoprocesso con output rediretto su Windows ([issue #318](https://github.com/google-antigravity/antigravity-cli/issues/318)); provare con un timeout (`--print-timeout`) e prevedere un messaggio d'errore. I flag vanno messi prima di `-p`. -- [ ] **Permessi**: Codex e Antigravity chiedono conferma per le azioni; in modalità non interattiva serve decidere una politica (sola lettura di default, opzione esplicita per concedere di più) invece di usare flag che saltano ogni controllo. -- [ ] Formato dell'output di ciascun motore (testo semplice o JSON) e normalizzazione prima del rendering markdown. -- [ ] Autenticazione: ogni CLI deve essere già autenticata; l'app non gestisce login. -- [ ] CLI (`LlmRun "prompt"`): opzione `--engine`, coerente con la GUI. +- [ ] Chiudere le verifiche dell'ADR-0001 con un prototipo e portarlo a "Accettata" +- [ ] Nome dell'app in un solo punto del codice (costante usata per titolo finestra, cartella dati; `AssemblyName` nel csproj), così la rinomina segue la checklist di docs/README.md +- [ ] Target `net10.0` (non più `net10.0-windows`); rimuovere WinForms e WebView2 +- [ ] Estrarre in un solo punto la logica di avvio di `claude` (cartella dell'exe, stdin chiuso), oggi duplicata tra `Program.cs` e `Gui.cs` +- [ ] Cronologia in `Environment.SpecialFolder.LocalApplicationData` (stesso formato JSON, chat esistenti ancora leggibili) +- [ ] GUI Avalonia: finestra, barra laterale delle chat, conversazione, composer con modello ed effort +- [ ] Rendering markdown in Avalonia (titoli, liste, tabelle, citazioni, codice), pulsante Copia, link nel browser di sistema +- [ ] Tema chiaro/scuro che segue il sistema, con i token di MVP.md → "Design (UI/UX)" +- [ ] Eliminare `ui.html` e il protocollo `WebMessage` +- [ ] Publish single-file self-contained `win-x64` e `linux-x64`, provati su entrambi i sistemi +- [ ] Aggiornare README.md e CLAUDE.md (comandi, architettura, vincoli) a migrazione conclusa diff --git a/docs/adr/0001-gui-multipiattaforma-avalonia.md b/docs/adr/0001-gui-multipiattaforma-avalonia.md new file mode 100644 index 0000000..7700fa2 --- /dev/null +++ b/docs/adr/0001-gui-multipiattaforma-avalonia.md @@ -0,0 +1,60 @@ +# ADR-0001 — GUI multipiattaforma in solo C# con Avalonia + +- **Stato**: Proposta +- **Data**: 2026-10-15 +- **Sostituisce**: la parte "GUI WinForms + WebView2 + `ui.html`" di [MVP.md](../MVP.md) (le sezioni interessate rimandano a questo ADR) + +## Contesto +L'app nasce per Windows: GUI WinForms con un solo WebView2 che carica `ui.html` (HTML/CSS/JS), CLI in `Program.cs`. L'obiettivo ora è avere **un eseguibile standalone per Windows e Linux** nel modo più semplice, **scritto interamente in C#, senza HTML**. + +Oggi non è possibile: WinForms e WebView2 sono solo Windows, il csproj punta a `net10.0-windows`, la cronologia usa `%LOCALAPPDATA%`. La CLI invece è già portabile. + +## Criteri di decisione +1. Un solo codice sorgente, compilabile per `win-x64` e `linux-x64`. +2. Standalone: publish single-file self-contained, senza runtime né librerie da installare oltre a quanto offre un normale desktop. +3. Solo C# (e XAML per il layout): niente HTML/CSS/JS. +4. Mantenere la GUI a finestra (chat con barra laterale, markdown, selettori modello/effort). +5. Riuso massimo della logica esistente (`claude -p`, sessioni, cronologia JSON, Markdig). + +## Opzioni considerate +| Opzione | Esito | Motivo | +|---|---|---| +| Mantenere WinForms + WebView2 | Scartata | Solo Windows, e richiede HTML. | +| Photino / WebView nativa | Scartata | Richiede HTML e, su Linux, webkit2gtk da installare: non standalone. | +| Server HTTP locale (Kestrel) + browser | Scartata | Portabile e semplice, ma la UI resta HTML e non è una finestra propria. | +| MAUI | Scartata | Nessun supporto desktop Linux ufficiale. | +| Terminal.Gui (TUI) | Alternativa | Puro C#, binario leggero, ma è un'interfaccia da terminale, non una finestra. | +| **Avalonia UI** | **Scelta** | GUI nativa, C# + XAML, render Skia, Windows e Linux, publish single-file self-contained. | + +## Decisione +La GUI viene riscritta con **Avalonia UI**: +- Layout in XAML, logica in C# con `ViewModel` e binding. `ui.html`, il protocollo `WebMessage` e le dipendenze WinForms/WebView2 spariscono. +- Rendering markdown con una libreria Avalonia basata su Markdig, al posto della conversione in HTML. I link `http(s)` si aprono nel browser di sistema. +- Un solo progetto con target `net10.0` (non più `net10.0-windows`). Due publish single-file self-contained: `win-x64` e `linux-x64`. +- La CLI (`Program.cs` con argomenti) resta com'è e funziona su entrambi i sistemi. +- La cronologia usa `Environment.SpecialFolder.LocalApplicationData` (`%LOCALAPPDATA%` su Windows, `~/.local/share` su Linux), sottocartella `/chats`. Il nome viene da un'unica costante (vedi [Nome del progetto](../README.md#nome-del-progetto)). +- La logica di avvio del processo (cartella dell'exe, stdin chiuso, `ArgumentList`) viene estratta in **un solo punto** condiviso da CLI e GUI, eliminando la duplicazione oggi segnalata in `CLAUDE.md`. Si lega all'astrazione "motore" di [ADR-0002](0002-motori-cli-claude-codex-antigravity.md). +- L'aspetto (token di colore, tipografia, accessibilità) resta definito in [MVP.md](../MVP.md) → "Design (UI/UX)", tradotto in risorse/stili Avalonia. Il tema segue il sistema (chiaro/scuro) tramite `ThemeVariant`. + +## Conseguenze +**Positive** +- Un solo eseguibile per sistema, senza dipendenze di runtime. +- Nessun HTML/JS da mantenere; il vincolo STA di WinForms/WebView2 non c'è più. +- Pulizia della duplicazione CLI/GUI. + +**Negative / costi** +- Riscrittura della sola parte GUI (`Gui.cs` + `ui.html`), più ampia di una soluzione basata sul browser. +- Eseguibile più pesante (include Avalonia/Skia). +- Perdita di alcune comodità del web: la resa del markdown (tabelle, blocchi di codice, pulsante Copia) dipende dalla libreria scelta e va riprodotta o adattata. +- Su Linux serve un desktop con X11 o XWayland e le librerie di base (fontconfig ecc.). + +## Da verificare (prima di passare a "Accettata") +- [ ] La libreria markdown per Avalonia scelta è compatibile con la versione di Avalonia usata e con `net10.0`, e rende tabelle, citazioni e blocchi di codice in modo accettabile. +- [ ] Il publish single-file `linux-x64` parte su una distribuzione pulita (nessuna libreria nativa mancante oltre a quelle di desktop). +- [ ] `claude` viene trovato nel PATH su Linux e la CLI mantiene exit code e codice 127. +- [ ] Il tema chiaro/scuro segue il sistema anche su Linux. +- [ ] Le chat esistenti restano leggibili (stesso formato JSON). Se il nome cambia cambia anche la cartella dati: prevedere la migrazione della vecchia cartella. + +## Riferimenti +- Piano di lavoro: [ROADMAP.md](../ROADMAP.md), Fase 5. +- Specifica funzionale della chat: [MVP.md](../MVP.md). diff --git a/docs/adr/0002-motori-cli-claude-codex-antigravity.md b/docs/adr/0002-motori-cli-claude-codex-antigravity.md new file mode 100644 index 0000000..7c6c028 --- /dev/null +++ b/docs/adr/0002-motori-cli-claude-codex-antigravity.md @@ -0,0 +1,47 @@ +# ADR-0002 — Più motori CLI (Claude Code, Codex, Antigravity) su Windows e Linux + +- **Stato**: Proposta +- **Data**: 2026-10-15 +- **Collegato a**: [ADR-0001](0001-gui-multipiattaforma-avalonia.md) (GUI e piattaforme) + +## Contesto +L'app lancia solo `claude -p`. Si vuole usare dalla stessa chat anche **OpenAI Codex CLI** e **Antigravity CLI** (`agy`), così come `claude` già installati da terminale dall'utente, su Windows e Linux. L'app non li installa e non gestisce login: sono programmi esterni, autenticati a parte. + +## Criteri di decisione +1. Aggiungere un motore costa una definizione dichiarativa, non nuova logica sparsa. +2. Funziona uguale su Windows e Linux: i motori si trovano nel PATH come da terminale. +3. Un solo punto di avvio processo (cartella dell'exe, stdin chiuso, `ArgumentList`), condiviso da CLI e GUI. +4. Nessuna scorciatoia che disattivi i controlli di permesso senza una scelta esplicita dell'utente. + +## Decisione +- **Astrazione "motore"** in C#. Ogni motore descrive: nome e eseguibile, argomenti per il primo messaggio e per la ripresa, elenco modelli, supporto effort, modo per ottenere l'id di sessione. `RunClaude` diventa `RunEngine(engine, …)`. +- **Rilevamento** dei motori cercando l'eseguibile nel PATH (su Windows anche le estensioni di PATHEXT). I motori assenti sono disabilitati nelle Impostazioni, con messaggio chiaro; la CLI esce con 127 come oggi. +- **Una chat = un motore**. Il file JSON della chat memorizza il motore; il contesto di sessione non passa da un motore all'altro, quindi cambiare motore apre una nuova chat. +- **Impostazioni** nella GUI: motore attivo, modello, effort (solo se supportato), persistite su disco accanto alla cronologia. La CLI ottiene `--engine`. +- **Permessi**: di default sola lettura / nessun bypass dei controlli; concedere di più è un'opzione esplicita. +- **Output**: ogni motore ha un parser che lo normalizza in testo markdown prima del rendering. + +## Comandi dei motori +Da verificare con `--help` delle versioni installate: derivano da documentazione e ricerche, non da prove su questo PC, dove `codex` e `agy` non sono installati. + +| | Claude Code | Codex CLI | Antigravity CLI | +|---|---|---|---| +| Comando | `claude -p ` | `codex exec ` | `agy -p ` | +| Continuare | `--session-id ` poi `--resume ` | `codex exec resume ` | da verificare | +| Modello | `--model` | `-m` / `--model` | da verificare | +| Effort | `--effort` | via `--config` (da verificare) | non noto | + +## Conseguenze +**Positive**: un solo flusso di avvio; nuovi motori aggiungibili con una definizione; elimina la duplicazione CLI/GUI. +**Negative**: i flag delle CLI cambiano nel tempo e vanno mantenuti; le sessioni non si trasferiscono tra motori; più superficie da testare su due sistemi operativi. + +## Rischi e da verificare +- [ ] **Codex**: l'id di sessione lo genera Codex e non l'app; va letto dall'output (probabilmente con output JSON) per riprendere la chat. +- [ ] **Antigravity**: segnalato un blocco di `agy -p` da sottoprocesso con output rediretto su Windows ([issue #318](https://github.com/google-antigravity/antigravity-cli/issues/318)). Provare con `--print-timeout` e prevedere un messaggio d'errore. I flag vanno messi prima di `-p`. Da provare anche su Linux. +- [ ] **Forma dell'eseguibile**: `claude` è un .exe nativo avviabile direttamente; Codex e Antigravity, se installati con un gestore di pacchetti (es. npm), su Windows possono essere script `.cmd` che richiedono `cmd.exe /c`. Su Linux sono normali eseguibili o script con shebang. Verificare e gestire per motore. +- [ ] Autenticazione: ogni CLI deve essere già autenticata; l'app mostra l'errore del motore senza gestire login. +- [ ] Formato dell'output (testo o JSON) e normalizzazione per ciascun motore. +- [ ] Mappatura effort e modelli per motore, con elenco modelli aggiornabile. + +## Riferimenti +- Passi: [ROADMAP.md](../ROADMAP.md), Fase 4. From 9c6a679e6a335d1c29938688b91cdd0bb8fb9e68 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Thu, 15 Oct 2026 11:18:46 +0200 Subject: [PATCH 02/17] Documentazione: GUI Avalonia implementata e progetto rinominato agent-wiki-ui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MVP.md descrive ora la GUI Avalonia al posto di WinForms/WebView2 e il protocollo pagina-C# sparisce. ADR-0001 passa ad Accettata con l'esito delle verifiche (renderer markdown proprio, perché Markdown.Avalonia è per Avalonia 11). Il nome ha un'unica sorgente in docs/README.md, con la checklist di rinomina e la migrazione della cartella dati. README e CLAUDE.md seguono la nuova struttura Core/Gui e i due publish single-file. Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 34 ++- Gui.cs | 211 -------------- README.md | 30 +- LlmRun.csproj => agent-wiki-ui.csproj | 0 docs/MVP.md | 47 ++- docs/README.md | 12 +- docs/ROADMAP.md | 23 +- .../adr/0001-gui-multipiattaforma-avalonia.md | 15 +- ui.html | 272 ------------------ 9 files changed, 80 insertions(+), 564 deletions(-) delete mode 100644 Gui.cs rename LlmRun.csproj => agent-wiki-ui.csproj (100%) delete mode 100644 ui.html diff --git a/CLAUDE.md b/CLAUDE.md index bd538ba..324c819 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,37 +4,43 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Progetto -LlmRun: eseguibile C# (.NET 10, Windows) che invoca Claude Code (`claude -p`) da terminale. Senza argomenti apre una GUI WinForms, con argomenti funziona da CLI. Non è un repo git. +agent-wiki-ui (già `LlmRun`; il nome ha un'unica sorgente, vedi [docs/README.md](docs/README.md#nome-del-progetto)): eseguibile C# (.NET 10, Windows e Linux) che invoca Claude Code (`claude -p`) da terminale. Senza argomenti apre una GUI di chat Avalonia, con argomenti funziona da CLI. ## Workflow doc-first (obbligatorio) - Specifica e criteri di accettazione: [docs/MVP.md](docs/MVP.md). Fasi e stato: [docs/ROADMAP.md](docs/ROADMAP.md). Indice dei doc e regole (SSOT, ADR): [docs/README.md](docs/README.md); una decisione architetturale si scrive solo nel suo ADR, gli altri doc la richiamano con un link. Uso: [README.md](README.md). - Ad ogni richiesta di modifica: aggiornare prima i doc interessati (MVP.md, ROADMAP.md con le spunte, README.md se cambia l'uso; un nuovo ADR in `docs/adr/` per ogni scelta architetturale), poi il codice, poi questo file se cambiano comandi, design o vincoli. Nessuna modifica deve lasciare i doc disallineati. -> In corso: migrazione a GUI Avalonia multipiattaforma ([ADR-0001](docs/adr/0001-gui-multipiattaforma-avalonia.md), ROADMAP Fase 5). Finché non è conclusa, le sezioni sotto descrivono lo stato attuale (WinForms + WebView2). - ## Comandi -- Build: `dotnet build` (LlmRun.csproj, net10.0-windows, WinForms) +- Build: `dotnet build` (agent-wiki-ui.csproj, `net10.0`, Avalonia) - GUI: `dotnet run`; CLI: `dotnet run -- "prompt"` -- Publish single-file: `dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true` -- Test: non esistono ancora. +- Publish single-file (uno per sistema): `dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true` e `-r linux-x64` +- Test: non esistono ancora. Per verificare la GUI senza schermo si può usare `Avalonia.Headless` con `CaptureRenderedFrame()` in un progetto di prova fuori dal repo. ## Architettura -- [Program.cs](Program.cs): top-level statements. Senza argomenti avvia `Gui`; con argomenti è la CLI (`claude -p ` con `ArgumentList`, stdout/stderr ereditati, exit code propagato, 127 se `claude` non è nel PATH). -- [Gui.cs](Gui.cs): host WinForms sottile. Un solo WebView2 che carica [ui.html](ui.html) (embedded resource; `{{MODELS}}` è sostituito con le opzioni dei modelli). Pagina e C# dialogano con `WebMessage` JSON (protocollo in `docs/MVP.md`). Per ogni `send` C# lancia `claude -p` con stdout/stderr rediretti, converte la risposta da markdown a HTML con Markdig (`DisableHtml`) e la rimanda alla pagina. Contesto: `--session-id ` al primo messaggio, `--resume ` poi. Cronologia: un JSON per chat in `%LOCALAPPDATA%\LlmRun\chats\.json` (il nome file è l'id di sessione, validato come Guid prima dell'uso), salvato appena l'utente invia e di nuovo alla risposta; all'avvio (primo `list` dalla pagina) viene riaperta la chat più recente. `--model`/`--effort` omessi se "Predefinito". Lista modelli hardcoded in `Gui.Models`, da aggiornare quando escono nuovi modelli. -- [ui.html](ui.html): tutta la UI (HTML+CSS+JS, nessuna libreria). Token di colore/tipografia come variabili CSS in cima; specifiche in `docs/MVP.md` → "Design (UI/UX)". Cambiando l'aspetto aggiornare quella sezione. -- Dipendenze NuGet (solo GUI): Markdig, Microsoft.Web.WebView2. +Decisioni e motivazioni negli ADR ([docs/adr/](docs/adr/)); qui solo la mappa del codice. + +- [Program.cs](Program.cs): top-level statements. Con argomenti è la CLI (`Claude.RunInherited`); senza avvia Avalonia (`App`). +- [Core/Claude.cs](Core/Claude.cs): **unico punto di avvio di `claude`** (cartella dell'exe, stdin chiuso, `ArgumentList`, `--session-id`/`--resume`, `--model`/`--effort` omessi se vuoti). Contiene anche l'elenco modelli `Catalog.Models`, da aggiornare quando escono nuovi modelli. +- [Core/ChatStore.cs](Core/ChatStore.cs): cronologia, un JSON per chat in `//chats/.json` (nome file = id di sessione, validato come Guid prima dell'uso). +- [Core/AppInfo.cs](Core/AppInfo.cs): nome dell'app, letto dall'`AssemblyName` del csproj (unica sorgente). +- [Gui/MainViewModel.cs](Gui/MainViewModel.cs): stato e logica della chat (invio, apertura/eliminazione, salvataggio subito all'invio e di nuovo alla risposta, riapertura dell'ultima chat all'avvio). +- [Gui/MainWindow.axaml](Gui/MainWindow.axaml) (+ `.cs`): layout XAML con binding compilati. [Gui/App.axaml](Gui/App.axaml): token di colore come risorse di tema chiaro/scuro e stili; specifiche in `docs/MVP.md` → "Design (UI/UX)". Cambiando l'aspetto aggiornare quella sezione. +- [Gui/MarkdownView.cs](Gui/MarkdownView.cs): renderer markdown proprio (albero Markdig → controlli Avalonia, `DisableHtml`). +- Dipendenze NuGet: Avalonia (+ Desktop, Themes.Fluent), Markdig. ## Vincoli non ovvi - La working directory di `claude` deve essere la cartella dell'exe: usare `AppContext.BaseDirectory`, mai `Environment.CurrentDirectory` né `Assembly.Location` (vuoto in single-file). - Lo stdin di `claude` va redirezionato e chiuso subito, altrimenti aspetta 3s. -- `claude` è un .exe nativo (`~/.local/bin`), avviabile direttamente. Se fosse `claude.cmd` serve `cmd.exe /c`. -- La logica di avvio processo è duplicata tra CLI (`Program.cs`) e GUI (`Gui.cs`): se cambia (cartella di lavoro, stdin, flag), modificarla in entrambi i punti. -- Nella GUI i link `http(s)` non devono navigare nella finestra: si aprono nel browser di sistema (`NavigationStarting`/`NewWindowRequested`). -- La GUI deve girare su un thread STA (creato a mano in `Program.cs`, perché i top-level statements non hanno `[STAThread]`): senza, WebView2 fallisce con `RPC_E_CHANGED_MODE`. +- `claude` è un eseguibile nativo (`~/.local/bin`), avviabile direttamente. Se fosse `claude.cmd` (Windows) serve `cmd.exe /c`. +- Il nome dell'app non va scritto a mano nel codice: usare `AppInfo.Name`. Cambiare nome cambia la cartella dati (vedi checklist in docs/README.md; `ChatStore` migra le cartelle dei nomi precedenti). +- Avalonia 12: la clipboard si usa con `using Avalonia.Input.Platform;` (`SetTextAsync` è un'estensione); `TextBox.Watermark` è obsoleto, usare `PlaceholderText`. +- Nel renderer markdown i colori si risolvono al momento del rendering (`TryFindResource` con `ActualThemeVariant`) e la vista si ricostruisce al cambio di tema. +- I link nel markdown si aprono con hit-test sul testo (`TextLayout.HitTestPoint`) solo se `http(s)` e se non c'è una selezione in corso. +- Il publish single-file richiede `IncludeNativeLibrariesForSelfExtract` (nel csproj): Skia e HarfBuzz sono librerie native. ## Skill del repo diff --git a/Gui.cs b/Gui.cs deleted file mode 100644 index 045f399..0000000 --- a/Gui.cs +++ /dev/null @@ -1,211 +0,0 @@ -using System.Diagnostics; -using System.Net; -using System.Reflection; -using System.Text.Json; -using System.Windows.Forms; -using Markdig; -using Microsoft.Web.WebView2.Core; -using Microsoft.Web.WebView2.WinForms; - -// Host sottile: l'interfaccia vive in ui.html (embedded). Protocollo messaggi: docs/MVP.md. -class Gui : Form -{ - record Model(string Label, string Id); - record Msg(string Role, string Text); // Role: user | ai | err - record Stored(string Id, string Title, DateTime Updated, bool Started, List Messages); - static readonly Model[] Models = - [ - new("Fable 5.1", "claude-fable-5-1"), - new("Opus 5.5", "claude-opus-5-5"), - new("Sonnet 5.5", "claude-sonnet-5-5"), - new("Haiku 5.5", "claude-haiku-5-5"), - ]; - - static readonly MarkdownPipeline Md = new MarkdownPipelineBuilder().UseAdvancedExtensions().DisableHtml().Build(); - - readonly WebView2 view = new() - { - Dock = DockStyle.Fill, - // evita il lampo bianco all'avvio in tema scuro (stesso --bg di ui.html) - DefaultBackgroundColor = Application.IsDarkModeEnabled ? Color.FromArgb(0x0F, 0x17, 0x2A) : Color.White, - }; - - static readonly string ChatsDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "LlmRun", "chats"); - - string sessionId = Guid.NewGuid().ToString(); - bool started; // true dopo il primo messaggio riuscito: da lì in poi --resume - bool busy; - bool restored; // la chat più recente si riapre solo al primo avvio - List messages = []; - - public Gui() - { - Text = "LlmRun"; - var area = Screen.PrimaryScreen!.WorkingArea; - StartPosition = FormStartPosition.CenterScreen; - // proporzioni 16:10 orizzontali: 75% della larghezza utile (max 1440), altezza max 90% - var w = Math.Min(1440, area.Width * 3 / 4); - var h = Math.Min(w * 10 / 16, area.Height * 9 / 10); - Size = new Size(h * 16 / 10, h); - MinimumSize = new Size(900, 600); - Controls.Add(view); - - Load += async (_, _) => - { - var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "LlmRun"); - await view.EnsureCoreWebView2Async(await CoreWebView2Environment.CreateAsync(null, dir)); - var web = view.CoreWebView2; - web.Settings.AreDevToolsEnabled = Debugger.IsAttached; - web.NavigationStarting += (_, e) => { if (IsWeb(e.Uri)) { e.Cancel = true; Open(e.Uri); } }; - web.NewWindowRequested += (_, e) => { e.Handled = true; if (IsWeb(e.Uri)) Open(e.Uri); }; - web.WebMessageReceived += (_, e) => OnMessage(e.WebMessageAsJson); - view.NavigateToString(LoadPage()); - }; - } - - static bool IsWeb(string uri) => uri.StartsWith("http", StringComparison.OrdinalIgnoreCase); - static void Open(string uri) => Process.Start(new ProcessStartInfo(uri) { UseShellExecute = true }); - - static string LoadPage() - { - var asm = Assembly.GetExecutingAssembly(); - using var s = asm.GetManifestResourceStream(asm.GetManifestResourceNames().First(n => n.EndsWith("ui.html")))!; - var html = new StreamReader(s).ReadToEnd(); - var options = string.Concat(Models.Select(m => $"")); - return html.Replace("{{MODELS}}", "" + options); - } - - void Post(object message) => view.CoreWebView2.PostWebMessageAsJson(JsonSerializer.Serialize(message)); - - void OnMessage(string json) - { - using var doc = JsonDocument.Parse(json); - var r = doc.RootElement; - switch (r.GetProperty("type").GetString()) - { - case "reset" when !busy: - NewSession(); PostChats(); - break; - case "list": - if (!restored) { restored = true; if (LatestId() is { } last) { OpenChat(last); break; } } - PostChats(); - break; - case "open" when !busy: - OpenChat(r.GetProperty("id").GetString()!); - break; - case "delete" when !busy: - Delete(r.GetProperty("id").GetString()!); - break; - case "send" when !busy: - _ = HandleSend(r.GetProperty("text").GetString()!, r.GetProperty("model").GetString()!, r.GetProperty("effort").GetString()!); - break; - } - } - - async Task HandleSend(string text, string model, string effort) - { - busy = true; - messages.Add(new("user", text)); - try { Save(); PostChats(); } catch (IOException) { } // il messaggio non va perso se l'app si chiude durante l'attesa - try - { - var (code, stdout, stderr) = await RunClaude(text, model, effort); - if (code == 0) { started = true; messages.Add(new("ai", stdout)); Post(new { type = "ai", html = Markdown.ToHtml(stdout, Md), md = stdout }); } - else Fail($"claude è uscito con codice {code}\n{stderr}".Trim()); - } - catch (System.ComponentModel.Win32Exception) { Fail("'claude' non trovato nel PATH."); } - catch (Exception ex) { Fail(ex.Message); } - finally - { - busy = false; - try { Save(); } catch (IOException) { } // la chat resta a schermo anche se il salvataggio fallisce - PostChats(); - Post(new { type = "done" }); - } - } - - void Fail(string text) { messages.Add(new("err", text)); Post(new { type = "err", text }); } - - void NewSession() { sessionId = Guid.NewGuid().ToString(); started = false; messages = []; } - - // L'id arriva dalla pagina e diventa un nome file: accettare solo Guid. - static string? PathFor(string id) => Guid.TryParse(id, out var g) ? Path.Combine(ChatsDir, g.ToString() + ".json") : null; - - void Save() - { - if (messages.Count == 0) return; - Directory.CreateDirectory(ChatsDir); - var first = messages.First(m => m.Role == "user").Text.ReplaceLineEndings(" ").Trim(); - var title = first.Length > 50 ? first[..50] + "…" : first; - File.WriteAllText(PathFor(sessionId)!, JsonSerializer.Serialize(new Stored(sessionId, title, DateTime.Now, started, messages))); - } - - static Stored? Read(string file) - { - try { return JsonSerializer.Deserialize(File.ReadAllText(file)); } - catch (Exception e) when (e is IOException or JsonException) { return null; } // file corrotto: ignorato - } - - static string? LatestId() => Directory.Exists(ChatsDir) - ? Directory.EnumerateFiles(ChatsDir, "*.json").Select(Read).OfType().OrderByDescending(c => c.Updated).FirstOrDefault()?.Id - : null; - - void PostChats() - { - var items = Directory.Exists(ChatsDir) - ? Directory.EnumerateFiles(ChatsDir, "*.json").Select(Read).OfType() - .OrderByDescending(c => c.Updated).Select(c => new { id = c.Id, title = c.Title, updated = c.Updated }).ToArray() - : []; - Post(new { type = "chats", current = sessionId, items }); - } - - void OpenChat(string id) - { - var file = PathFor(id); - var chat = file is not null && File.Exists(file) ? Read(file) : null; - if (chat is null) { PostChats(); return; } - sessionId = chat.Id; started = chat.Started; messages = chat.Messages; - Post(new - { - type = "load", - messages = messages.Select(m => new { role = m.Role, text = m.Text, html = m.Role == "ai" ? Markdown.ToHtml(m.Text, Md) : null }).ToArray(), - }); - PostChats(); - } - - void Delete(string id) - { - var file = PathFor(id); - if (file is not null && File.Exists(file)) File.Delete(file); - if (id == sessionId) - { - NewSession(); - Post(new { type = "load", messages = Array.Empty() }); - } - PostChats(); - } - - async Task<(int Code, string Out, string Err)> RunClaude(string prompt, string model, string effort) - { - var psi = new ProcessStartInfo("claude") - { - WorkingDirectory = AppContext.BaseDirectory, - UseShellExecute = false, - RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, - CreateNoWindow = true, - }; - psi.ArgumentList.Add("-p"); - psi.ArgumentList.Add(prompt); - psi.ArgumentList.Add(started ? "--resume" : "--session-id"); - psi.ArgumentList.Add(sessionId); - foreach (var (flag, value) in new[] { ("--model", model), ("--effort", effort) }) - if (value != "") { psi.ArgumentList.Add(flag); psi.ArgumentList.Add(value); } - - using var p = Process.Start(psi)!; - p.StandardInput.Close(); // evita l'attesa di stdin di claude - var o = p.StandardOutput.ReadToEndAsync(); - var e = p.StandardError.ReadToEndAsync(); - await p.WaitForExitAsync(); - return (p.ExitCode, (await o).Trim(), (await e).Trim()); - } -} diff --git a/README.md b/README.md index 67d60a2..85fc115 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,33 @@ -# LlmRun +# agent-wiki-ui -Eseguibile C# (.NET 10, Windows) che lancia Claude Code (`claude -p`) nella cartella in cui risiede l'eseguibile. +Eseguibile C# (.NET 10, Windows e Linux) che lancia Claude Code (`claude -p`) nella cartella in cui risiede l'eseguibile. Senza argomenti apre una GUI di chat (Avalonia), con argomenti funziona da riga di comando. ## Requisiti -- .NET SDK 10 -- `claude` (Claude Code) nel PATH -- Runtime WebView2 (incluso in Windows 11) per la GUI +- `claude` (Claude Code) installato e nel PATH, già autenticato +- Per sviluppare: .NET SDK 10 +- Per usare l'eseguibile pubblicato: nessun runtime (è self-contained). Su Linux serve un desktop con X11 o XWayland per la GUI. ## Uso -GUI (nessun argomento): finestra di chat in stile claude.ai, con la conversazione in markdown in alto e il campo messaggio in basso (`Invio` invia, `Maiusc+Invio` va a capo, "Nuova chat" azzera il contesto). +GUI (nessun argomento): finestra di chat in stile claude.ai, con la conversazione in markdown in alto e il campo messaggio in basso (`Invio` invia, `Maiusc+Invio` va a capo, "Nuova chat" azzera il contesto). La barra in alto del composer permette di scegliere **modello** ed **effort** di ragionamento ("Predefinito" = nessun flag, vale la config di `claude`). Le chat si salvano su disco e l'ultima si riapre all'avvio. ``` dotnet run ``` -Nella GUI, la barra in alto permette di scegliere **modello** (con versione) ed **effort** di ragionamento ("predefinito" = nessun flag, vale la config di `claude`). - -CLI (con argomenti): il prompt viene stampato su terminale, l'exit code è quello di `claude`. +CLI (con argomenti): il prompt viene stampato su terminale, l'exit code è quello di `claude` (127 se `claude` non è nel PATH). ``` dotnet run -- "spiega questo progetto" ``` -Eseguibile già compilato: -``` -dotnet build -bin\Debug\net10.0-windows\LlmRun.exe -``` - ## Publish single-file +Un eseguibile per sistema, senza dipendenze: ``` dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true +dotnet publish -c Release -r linux-x64 --self-contained -p:PublishSingleFile=true ``` +Il risultato è in `bin/Release/net10.0//publish/` (`agent-wiki-ui.exe` o `agent-wiki-ui`, circa 48 MB). Al primo avvio le librerie native vengono estratte in una cartella temporanea. -> **Piattaforme**: oggi solo Windows. È pianificato un eseguibile standalone per Windows e Linux con GUI in solo C# (Avalonia): vedi [ADR-0001](docs/adr/0001-gui-multipiattaforma-avalonia.md). La CLI è già portabile, la GUI attuale no. Pianificato anche l'uso di Codex CLI e Antigravity CLI già installati da terminale, oltre a Claude Code: [ADR-0002](docs/adr/0002-motori-cli-claude-codex-antigravity.md). +## Piattaforme +Windows e Linux con lo stesso codice ([ADR-0001](docs/adr/0001-gui-multipiattaforma-avalonia.md)). Pianificato l'uso di Codex CLI e Antigravity CLI già installati da terminale, oltre a Claude Code: [ADR-0002](docs/adr/0002-motori-cli-claude-codex-antigravity.md). -Documentazione: [docs/MVP.md](docs/MVP.md), [docs/ROADMAP.md](docs/ROADMAP.md), [indice e decisioni architetturali](docs/README.md). +Documentazione: [docs/README.md](docs/README.md) (indice, regole, decisioni), [docs/MVP.md](docs/MVP.md), [docs/ROADMAP.md](docs/ROADMAP.md). diff --git a/LlmRun.csproj b/agent-wiki-ui.csproj similarity index 100% rename from LlmRun.csproj rename to agent-wiki-ui.csproj diff --git a/docs/MVP.md b/docs/MVP.md index 2e0dcfb..ab2088e 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -17,45 +17,41 @@ Tutti gli argomenti sono uniti in un unico prompt. 5. Se `claude` non è trovato: messaggio chiaro ed exit code 127. ## GUI (chat) -> **Stato**: questa sezione descrive la GUI attuale (WinForms + WebView2 + `ui.html`, solo Windows). La destinazione è una GUI Avalonia in solo C# per Windows e Linux: vedi [ADR-0001](adr/0001-gui-multipiattaforma-avalonia.md) e [ROADMAP.md](ROADMAP.md), Fase 5. Il comportamento funzionale (chat, sessioni, cronologia, selettori) resta valido; cambiano tecnologia, protocollo pagina↔C# e percorso della cronologia. +Senza argomenti si apre una finestra Avalonia (C# + XAML, nessun HTML) in stile claude.ai/ChatGPT, identica su Windows e Linux. Con argomenti resta la CLI. Decisione e motivazioni: [ADR-0001](adr/0001-gui-multipiattaforma-avalonia.md). -Senza argomenti si apre una finestra WinForms che contiene solo un WebView2: l'intera interfaccia (header, conversazione, composer) è HTML/CSS in `ui.html`, in stile claude.ai/ChatGPT. Con argomenti resta la CLI. - -**Struttura della pagina** -- **Barra laterale** (sinistra, comprimibile): logo e nome, pulsante **Nuova chat**, elenco delle chat salvate (titolo = inizio del primo messaggio, data/ora dell'ultimo scambio; la chat aperta è evidenziata). Un clic su una chat la riapre; la ✕ (con conferma) la elimina. Elenco vuoto: "Nessuna chat salvata". -- **Conversazione**: messaggi alternati utente/Claude, aggiunti al DOM uno alla volta (nessun ricaricamento, nessuno sfarfallio). Le risposte sono renderizzate in markdown (Markdig, lato C#): titoli, liste, tabelle, citazioni, blocchi di codice. Sulle risposte il pulsante **Copia** copia il markdown; sui blocchi di codice copia il codice. +**Struttura della finestra** +- **Barra laterale** (sinistra, comprimibile con il pulsante a tre linee): logo e nome dell'app, pulsante **Nuova chat**, elenco delle chat salvate (titolo = inizio del primo messaggio, data/ora dell'ultimo scambio; la chat aperta è evidenziata). Un clic su una chat la riapre; la ✕ (con conferma) la elimina. Elenco vuoto: "Nessuna chat salvata". +- **Conversazione**: messaggi alternati utente/Claude. Le risposte sono renderizzate in markdown: titoli, liste, tabelle, citazioni, blocchi di codice, grassetto/corsivo/barrato, codice inline, link. Il rendering è un renderer proprio ([MarkdownView.cs](../Gui/MarkdownView.cs)) che trasforma l'albero di Markdig in controlli Avalonia; l'HTML grezzo è disabilitato. Il testo è selezionabile. Sulle risposte il pulsante **Copia** copia il markdown; sui blocchi di codice copia il codice. - **Schermata iniziale** (chat vuota): titolo, sottotitolo e quattro suggerimenti cliccabili che precompilano il campo. -- **Composer** (in basso, scheda arrotondata): textarea che cresce fino a ~8 righe, selettori **Modello** (Predefinito, Fable 5.1, Opus 5.5, Sonnet 5.5, Haiku 5.5, "Altro ID…" con campo libero) ed **Effort** (Predefinito, low, medium, high, xhigh, max), pulsante Invia circolare con icona. `Invio` invia, `Maiusc+Invio` va a capo. Hint sulla tastiera sotto la scheda. +- **Composer** (in basso, scheda arrotondata): campo di testo che cresce fino a ~8 righe, selettori **Modello** (Predefinito, Fable 5.1, Opus 5.5, Sonnet 5.5, Haiku 5.5, "Altro ID…" con campo libero) ed **Effort** (Predefinito, low, medium, high, xhigh, max), pulsante Invia circolare con icona. `Invio` invia, `Maiusc+Invio` va a capo. Hint sulla tastiera sotto la scheda. - Passati come `--model` / `--effort`; con "Predefinito" il flag viene omesso. Solo GUI. - **Contesto**: `claude -p --session-id ` al primo messaggio, `--resume ` ai successivi. "Nuova chat" svuota la vista e genera un nuovo guid. -- **Cronologia**: dopo ogni scambio la chat è salvata in `\\chats\.json` (oggi `%LOCALAPPDATA%`; vedi [Nome del progetto](README.md#nome-del-progetto)) (guid = id di sessione di `claude`, titolo, data, flag `started`, messaggi con ruolo e testo). Riaprendo una chat si rimostra la conversazione e si prosegue con `--resume `, quindi `claude` ricorda il contesto. Il messaggio dell'utente viene salvato subito, prima della risposta (così non si perde se l'app si chiude mentre Claude risponde); la risposta si aggiunge appena arriva. Una chat nuova e vuota non viene salvata. **All'avvio l'app riapre automaticamente l'ultima conversazione** (la più recente); se non ce ne sono parte con la schermata iniziale. "Nuova chat" resta sempre disponibile. Eliminare una chat cancella solo il file JSON, non la sessione di `claude`. Non si può aprire/eliminare una chat mentre Claude sta rispondendo. +- **Cronologia**: dopo ogni scambio la chat è salvata in `//chats/.json`, dove la cartella dati è `Environment.SpecialFolder.LocalApplicationData` (`%LOCALAPPDATA%` su Windows, `~/.local/share` su Linux; vedi [Nome del progetto](README.md#nome-del-progetto)). Il file contiene guid (= id di sessione di `claude`), titolo, data, flag `started`, messaggi con ruolo e testo. Riaprendo una chat si rimostra la conversazione e si prosegue con `--resume `, quindi `claude` ricorda il contesto. Il messaggio dell'utente viene salvato subito, prima della risposta (così non si perde se l'app si chiude mentre Claude risponde); la risposta si aggiunge appena arriva. Una chat nuova e vuota non viene salvata. **All'avvio l'app riapre automaticamente l'ultima conversazione** (la più recente); se non ce ne sono parte con la schermata iniziale. "Nuova chat" resta sempre disponibile. Eliminare una chat cancella solo il file JSON, non la sessione di `claude`. Non si può aprire/eliminare una chat mentre Claude sta rispondendo. - **Limite noto**: la sessione di `claude` è legata alla cartella in cui gira (quella dell'exe): se si sposta l'exe, le vecchie chat si leggono ancora ma `--resume` non ritrova il contesto. -- La risposta compare a fine generazione (nessuno streaming); durante l'attesa un indicatore a puntini animati e Invia disabilitato. Se `claude` esce con errore, lo stderr appare come messaggio di errore. -- I link si aprono nel browser di sistema. L'HTML grezzo nel markdown è disabilitato. -- Finestra orizzontale con proporzioni 16:10: larga il 75% dell'area utile dello schermo (max 1440px), altezza di conseguenza (max 90% dell'area utile), centrata; minimo 900x600. Su un monitor Full HD 1920x1080 risulta circa 1440x900. - -**Protocollo pagina ↔ C#** (`WebMessage` JSON). Pagina→C#: `{type:"send", text, model, effort}`, `{type:"reset"}`, `{type:"list"}`, `{type:"open", id}`, `{type:"delete", id}`. C#→pagina: `{type:"ai", html, md}`, `{type:"err", text}`, `{type:"done"}`, `{type:"chats", current, items:[{id,title,updated}]}`, `{type:"load", messages:[{role,text,html?}]}` (sostituisce la conversazione mostrata). +- La risposta compare a fine generazione (nessuno streaming); durante l'attesa un indicatore di avanzamento e Invia disabilitato. Se `claude` esce con errore, lo stderr appare come messaggio di errore. +- I link `http(s)` si aprono nel browser di sistema. +- Finestra orizzontale con proporzioni 16:10: larga il 75% dell'area utile dello schermo (max 1440 px logici), altezza di conseguenza (max 90% dell'area utile), centrata; minimo 900x600. Su un monitor Full HD 1920x1080 risulta circa 1440x900. ### Design (UI/UX) -Sorgente di verità per l'aspetto, derivato dalla skill `ui-ux-pro-max` (stile "strumento per sviluppatori": slate + accento verde). -- **Tema**: segue Windows (chiaro/scuro) via `prefers-color-scheme`; la barra del titolo segue `Application.SetColorMode(SystemColorMode.System)`; lo sfondo del WebView2 è impostato per evitare il lampo bianco all'avvio. +Sorgente di verità per l'aspetto, derivato dalla skill `ui-ux-pro-max` (stile "strumento per sviluppatori": slate + accento verde). I token vivono come risorse di tema in [Gui/App.axaml](../Gui/App.axaml). +- **Tema**: segue il sistema (chiaro/scuro) tramite `RequestedThemeVariant="Default"`. - **Token** (chiaro / scuro): sfondo `#FFFFFF` / `#0F172A`, superficie composer `#FFFFFF` / `#1E293B`, testo `#0F172A` / `#F8FAFC`, testo secondario `#475569` / `#94A3B8`, bordi `#CBD5E1` / `#334155`, accento `#15803D` / `#22C55E`, errore `#B91C1C` / `#F87171`. Contrasto testo ≥ 4.5:1. -- **Tipografia**: Segoe UI Variable (corpo 15.5px, interlinea 1.65), Cascadia Code/Consolas per il codice. Nessun font web (funziona offline). -- **Forma e movimento**: raggi 10–20px, ombra leggera sul composer, transizioni 150ms, comparsa dei messaggi 180ms; con `prefers-reduced-motion` tutto fermo. -- **Barra laterale**: sfondo `--muted`, 272px; voce attiva con barra d'accento a sinistra; ✕ visibile solo al passaggio/focus. -- **Accessibilità**: focus sempre visibile, `aria-label` sui pulsanti a sola icona, `role=log`/`aria-live` sulla conversazione, `role=status` sull'attesa, target ≥ 36px, nessuna emoji come icona. -- **Non incluso**: evidenziazione sintattica del codice (richiederebbe una libreria da incorporare). +- **Tipografia**: font di sistema (Segoe UI Variable/Segoe UI su Windows; Inter, Ubuntu, Cantarell, Noto Sans, DejaVu Sans su Linux; corpo 15.5, titoli fino a 24), Cascadia Code/Consolas/DejaVu Sans Mono per il codice. Nessun font incorporato (funziona offline). +- **Forma**: raggi 10–20, bordi sottili, pulsante d'invio circolare. +- **Barra laterale**: sfondo `Muted`, 272 px; voce attiva con barra d'accento a sinistra; ✕ per eliminare. +- **Accessibilità**: focus visibile (il composer prende il bordo d'accento), `AutomationProperties.Name` sui pulsanti a sola icona e sul campo messaggio, target ≥ 30 px, icone vettoriali (nessuna emoji). +- **Non incluso**: evidenziazione sintattica del codice. ## Scelte tecniche -Le scelte architetturali sono negli [ADR](README.md#indice-adr). Quanto segue descrive lo stato attuale. - -- .NET 10 (WinForms). `Program.cs` (CLI + avvio) e `Gui.cs` (chat). Dipendenze NuGet solo per la GUI: `Markdig` (markdown→HTML) e `Microsoft.Web.WebView2` (richiede il runtime WebView2, già presente su Windows 11). +Le scelte architetturali sono negli [ADR](README.md#indice-adr). Sintesi: +- .NET 10, un solo progetto `net10.0` per Windows e Linux. Avalonia (GUI), Markdig (parsing markdown). Il codice è diviso in `Core/` (avvio di `claude`, cronologia, nome app) e `Gui/` (finestra, ViewModel, renderer markdown); `Program.cs` sceglie CLI o GUI. - `ProcessStartInfo.ArgumentList` per passare il prompt (niente problemi di quoting). -- `claude` è un .exe nativo (`~/.local/bin`): si avvia direttamente, senza `cmd /c`. Lo stdin è chiuso subito per evitare l'attesa di 3s di claude. +- `claude` è un eseguibile nativo (`~/.local/bin`): si avvia direttamente, senza `cmd /c`. Lo stdin è chiuso subito per evitare l'attesa di 3s di claude. - Working dir: `AppContext.BaseDirectory`, non la cwd del chiamante. +- L'avvio di `claude` è in un solo punto ([Core/Claude.cs](../Core/Claude.cs)), usato da CLI e GUI. ## Fuori scope (MVP) -Streaming token-per-token nella GUI, annullamento di una risposta in corso, rinomina/ricerca delle chat, config file, altri motori oltre a Claude Code (Codex, Antigravity: vedi [ADR-0002](adr/0002-motori-cli-claude-codex-antigravity.md)), logging su file, test automatici, supporto macOS. Il supporto Linux è pianificato: [ADR-0001](adr/0001-gui-multipiattaforma-avalonia.md), Fase 5. +Streaming token-per-token nella GUI, annullamento di una risposta in corso, rinomina/ricerca delle chat, config file, altri motori oltre a Claude Code (Codex, Antigravity: vedi [ADR-0002](adr/0002-motori-cli-claude-codex-antigravity.md)), logging su file, test automatici, supporto macOS. ## Criteri di accettazione - [ ] Eseguito da un'altra cartella, `claude` parte nella cartella dell'exe (verificabile chiedendo "pwd"). @@ -64,6 +60,5 @@ Streaming token-per-token nella GUI, annullamento di una risposta in corso, rino - [ ] Un prompt con virgolette e spazi arriva intatto. ## Domande aperte -- Nome definitivo del progetto (oggi provvisorio: vedi [Nome del progetto](README.md#nome-del-progetto)). - Permessi: serve `--allowedTools` o `--permission-mode` per l'uso non interattivo? - Formato output: testo semplice o `--output-format stream-json`? diff --git a/docs/README.md b/docs/README.md index b6d7c8e..efd2d1f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,12 +11,12 @@ Indice e regole. Ogni informazione vive in **un solo documento**; gli altri la r | [../CLAUDE.md](../CLAUDE.md) | Guida per Claude Code: comandi, architettura del codice, vincoli | ## Nome del progetto -Il nome è **provvisorio** (oggi `LlmRun`) e non è ancora deciso. Questa sezione è l'unica sorgente: gli altri doc scrivono "l'app" o ``. +Il nome è **`agent-wiki-ui`** (prima `LlmRun`). Questa sezione è l'unica sorgente: gli altri doc scrivono "l'app" o ``. -Se il nome cambia, aggiornare solo questi punti: -- [ ] `AssemblyName`/nome del csproj e dell'eseguibile (`.exe`, ``) -- [ ] Costante del nome nel codice (titolo finestra, intestazione della barra laterale, cartella dati) -- [ ] **Cartella dati** `//chats`: cambiando nome le chat salvate non si trovano più; migrare o leggere anche la vecchia cartella +Se il nome cambia di nuovo, aggiornare solo questi punti: +- [ ] `AssemblyName` in [../agent-wiki-ui.csproj](../agent-wiki-ui.csproj) (e il nome del file csproj): da lì il codice legge il nome (`Core/AppInfo.cs`) per titolo finestra, intestazione della barra laterale e cartella dati; l'eseguibile diventa `.exe` / `` +- [ ] `RootNamespace` e namespace C# (`AgentWikiUi.*`, anche in `x:Class` e `xmlns` dei file `.axaml`): il nome del namespace non può avere trattini +- [ ] **Cartella dati** `//chats`: cambiando nome le chat salvate non si trovano più. `ChatStore` sposta una vecchia cartella (`LlmRun`) in quella nuova al primo avvio; per un nuovo cambio aggiungere il nome precedente all'elenco `LegacyNames` - [ ] Titolo di [../README.md](../README.md) e riga iniziale di [../CLAUDE.md](../CLAUDE.md) (nome del csproj nei comandi) ## Flusso (doc-first, doc-as-code) @@ -33,5 +33,5 @@ Se il nome cambia, aggiornare solo questi punti: ## Indice ADR | ADR | Titolo | Stato | |---|---|---| -| [0001](adr/0001-gui-multipiattaforma-avalonia.md) | GUI multipiattaforma in solo C# con Avalonia | Proposta | +| [0001](adr/0001-gui-multipiattaforma-avalonia.md) | GUI multipiattaforma in solo C# con Avalonia | Accettata | | [0002](adr/0002-motori-cli-claude-codex-antigravity.md) | Più motori CLI (Claude Code, Codex, Antigravity) su Windows e Linux | Proposta | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3fea131..5da4487 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -41,14 +41,15 @@ Decisione, tabella dei comandi e rischi: [ADR-0002](adr/0002-motori-cli-claude-c ## Fase 5 — Multipiattaforma (Windows + Linux), GUI in solo C# Decisione e motivazioni: [ADR-0001](adr/0001-gui-multipiattaforma-avalonia.md). Qui solo i passi. -- [ ] Chiudere le verifiche dell'ADR-0001 con un prototipo e portarlo a "Accettata" -- [ ] Nome dell'app in un solo punto del codice (costante usata per titolo finestra, cartella dati; `AssemblyName` nel csproj), così la rinomina segue la checklist di docs/README.md -- [ ] Target `net10.0` (non più `net10.0-windows`); rimuovere WinForms e WebView2 -- [ ] Estrarre in un solo punto la logica di avvio di `claude` (cartella dell'exe, stdin chiuso), oggi duplicata tra `Program.cs` e `Gui.cs` -- [ ] Cronologia in `Environment.SpecialFolder.LocalApplicationData` (stesso formato JSON, chat esistenti ancora leggibili) -- [ ] GUI Avalonia: finestra, barra laterale delle chat, conversazione, composer con modello ed effort -- [ ] Rendering markdown in Avalonia (titoli, liste, tabelle, citazioni, codice), pulsante Copia, link nel browser di sistema -- [ ] Tema chiaro/scuro che segue il sistema, con i token di MVP.md → "Design (UI/UX)" -- [ ] Eliminare `ui.html` e il protocollo `WebMessage` -- [ ] Publish single-file self-contained `win-x64` e `linux-x64`, provati su entrambi i sistemi -- [ ] Aggiornare README.md e CLAUDE.md (comandi, architettura, vincoli) a migrazione conclusa +- [x] Chiudere le verifiche dell'ADR-0001 con un prototipo e portarlo a "Accettata" (restano due verifiche indicate nell'ADR) +- [x] Nome dell'app in un solo punto: `AssemblyName` nel csproj, letto da `Core/AppInfo.cs`; la rinomina segue la checklist di docs/README.md +- [x] Rinomina in `agent-wiki-ui` (csproj, namespace) e migrazione automatica della cartella dati `LlmRun` → `agent-wiki-ui` +- [x] Target `net10.0` (non più `net10.0-windows`); rimossi WinForms e WebView2 +- [x] Estrarre in un solo punto la logica di avvio di `claude` (`Core/Claude.cs`) (cartella dell'exe, stdin chiuso), oggi duplicata tra `Program.cs` e `Gui.cs` +- [x] Cronologia in `Environment.SpecialFolder.LocalApplicationData` (stesso formato JSON, chat esistenti ancora leggibili) +- [x] GUI Avalonia: finestra, barra laterale delle chat, conversazione, composer con modello ed effort +- [x] Rendering markdown in Avalonia (titoli, liste, tabelle, citazioni, codice), pulsante Copia, link nel browser di sistema +- [x] Tema chiaro/scuro che segue il sistema, con i token di MVP.md → "Design (UI/UX)" (da provare su un desktop Linux reale) +- [x] Eliminare `ui.html` e il protocollo `WebMessage` +- [ ] Publish single-file self-contained `win-x64` e `linux-x64`: compilano e partono (Linux: CLI e GUI su WSLg; Windows: solo CLI, via WSL); da provare la GUI su Windows nativo e su una distribuzione Linux pulita +- [x] Aggiornare README.md e CLAUDE.md (comandi, architettura, vincoli) a migrazione conclusa diff --git a/docs/adr/0001-gui-multipiattaforma-avalonia.md b/docs/adr/0001-gui-multipiattaforma-avalonia.md index 7700fa2..73db61d 100644 --- a/docs/adr/0001-gui-multipiattaforma-avalonia.md +++ b/docs/adr/0001-gui-multipiattaforma-avalonia.md @@ -1,6 +1,6 @@ # ADR-0001 — GUI multipiattaforma in solo C# con Avalonia -- **Stato**: Proposta +- **Stato**: Accettata (implementata; restano due verifiche, vedi sotto) - **Data**: 2026-10-15 - **Sostituisce**: la parte "GUI WinForms + WebView2 + `ui.html`" di [MVP.md](../MVP.md) (le sezioni interessate rimandano a questo ADR) @@ -29,7 +29,7 @@ Oggi non è possibile: WinForms e WebView2 sono solo Windows, il csproj punta a ## Decisione La GUI viene riscritta con **Avalonia UI**: - Layout in XAML, logica in C# con `ViewModel` e binding. `ui.html`, il protocollo `WebMessage` e le dipendenze WinForms/WebView2 spariscono. -- Rendering markdown con una libreria Avalonia basata su Markdig, al posto della conversione in HTML. I link `http(s)` si aprono nel browser di sistema. +- Rendering markdown con un **renderer proprio** che trasforma l'albero di Markdig in controlli Avalonia (`Gui/MarkdownView.cs`), al posto della conversione in HTML. Si è scelto di non usare `Markdown.Avalonia` perché l'ultima versione pubblicata (11.0.x) è per Avalonia 11, mentre si usa Avalonia 12. I link `http(s)` si aprono nel browser di sistema. - Un solo progetto con target `net10.0` (non più `net10.0-windows`). Due publish single-file self-contained: `win-x64` e `linux-x64`. - La CLI (`Program.cs` con argomenti) resta com'è e funziona su entrambi i sistemi. - La cronologia usa `Environment.SpecialFolder.LocalApplicationData` (`%LOCALAPPDATA%` su Windows, `~/.local/share` su Linux), sottocartella `/chats`. Il nome viene da un'unica costante (vedi [Nome del progetto](../README.md#nome-del-progetto)). @@ -49,11 +49,12 @@ La GUI viene riscritta con **Avalonia UI**: - Su Linux serve un desktop con X11 o XWayland e le librerie di base (fontconfig ecc.). ## Da verificare (prima di passare a "Accettata") -- [ ] La libreria markdown per Avalonia scelta è compatibile con la versione di Avalonia usata e con `net10.0`, e rende tabelle, citazioni e blocchi di codice in modo accettabile. -- [ ] Il publish single-file `linux-x64` parte su una distribuzione pulita (nessuna libreria nativa mancante oltre a quelle di desktop). -- [ ] `claude` viene trovato nel PATH su Linux e la CLI mantiene exit code e codice 127. -- [ ] Il tema chiaro/scuro segue il sistema anche su Linux. -- [ ] Le chat esistenti restano leggibili (stesso formato JSON). Se il nome cambia cambia anche la cartella dati: prevedere la migrazione della vecchia cartella. +- [x] Rendering markdown: tabelle, citazioni, liste, codice, link ed enfasi verificati con uno screenshot headless in tema chiaro e scuro (libreria `Markdown.Avalonia` scartata, vedi Decisione). +- [x] Il publish single-file `linux-x64` (48 MB con compressione) parte; verificato su WSL2 con WSLg. **Da verificare** su una distribuzione desktop pulita. +- [x] Il publish single-file `win-x64` si compila e la CLI parte (provata da WSL tramite interoperabilità). **Da verificare** la GUI su Windows nativo. +- [x] `claude` viene trovato nel PATH su Linux e la CLI mantiene exit code e codice 127 (provato con un `claude` finto). +- [ ] Il tema chiaro/scuro segue il sistema anche su Linux (impostato con `RequestedThemeVariant="Default"`; non verificato su un desktop reale). +- [x] Le chat esistenti restano leggibili (stesso formato JSON). Se il nome cambia cambia anche la cartella dati: la vecchia cartella viene migrata (fatto per `LlmRun` → `agent-wiki-ui`). ## Riferimenti - Piano di lavoro: [ROADMAP.md](../ROADMAP.md), Fase 5. diff --git a/ui.html b/ui.html deleted file mode 100644 index cf3ae99..0000000 --- a/ui.html +++ /dev/null @@ -1,272 +0,0 @@ - - - - - -LlmRun - - - - -
- - -
-
- -

Come posso aiutarti?

-

Scrivi un messaggio oppure parti da uno di questi spunti.

-
- - - - -
-
- -
- -
-
- -
- - - - -
-
-
Invio per inviare · Maiusc+Invio per andare a capo
-
-
- - - - From 941d02110d22d7359459b53ce3d4ff3f18204246 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Thu, 15 Oct 2026 11:18:46 +0200 Subject: [PATCH 03/17] GUI Avalonia multipiattaforma in solo C# e rinomina in agent-wiki-ui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sostituisce WinForms, WebView2 e ui.html con una GUI Avalonia (XAML, ViewModel, tema chiaro/scuro) e un renderer markdown che trasforma l'albero di Markdig in controlli. Un solo progetto net10.0 per Windows e Linux, con publish single-file compresso. L'avvio di claude è in un solo punto (Core/Claude.cs) condiviso da CLI e GUI. Il nome dell'app viene dall'AssemblyName; ChatStore migra la cartella dati LlmRun alla nuova. Co-Authored-By: Claude Sonnet 5.5 --- Core/AppInfo.cs | 7 ++ Core/ChatStore.cs | 66 +++++++++++++ Core/Claude.cs | 77 +++++++++++++++ Gui/App.axaml | 123 ++++++++++++++++++++++++ Gui/App.axaml.cs | 17 ++++ Gui/ConfirmDialog.cs | 31 ++++++ Gui/MainViewModel.cs | 180 +++++++++++++++++++++++++++++++++++ Gui/MainWindow.axaml | 167 ++++++++++++++++++++++++++++++++ Gui/MainWindow.axaml.cs | 48 ++++++++++ Gui/MarkdownView.cs | 204 ++++++++++++++++++++++++++++++++++++++++ Gui/ObservableObject.cs | 28 ++++++ Program.cs | 48 ++-------- agent-wiki-ui.csproj | 20 ++-- 13 files changed, 966 insertions(+), 50 deletions(-) create mode 100644 Core/AppInfo.cs create mode 100644 Core/ChatStore.cs create mode 100644 Core/Claude.cs create mode 100644 Gui/App.axaml create mode 100644 Gui/App.axaml.cs create mode 100644 Gui/ConfirmDialog.cs create mode 100644 Gui/MainViewModel.cs create mode 100644 Gui/MainWindow.axaml create mode 100644 Gui/MainWindow.axaml.cs create mode 100644 Gui/MarkdownView.cs create mode 100644 Gui/ObservableObject.cs diff --git a/Core/AppInfo.cs b/Core/AppInfo.cs new file mode 100644 index 0000000..9810aca --- /dev/null +++ b/Core/AppInfo.cs @@ -0,0 +1,7 @@ +namespace AgentWikiUi.Core; + +static class AppInfo +{ + // Letto dall'AssemblyName del csproj: rinominare l'app = cambiare solo quello. + public static readonly string Name = typeof(AppInfo).Assembly.GetName().Name!; +} diff --git a/Core/ChatStore.cs b/Core/ChatStore.cs new file mode 100644 index 0000000..2908c15 --- /dev/null +++ b/Core/ChatStore.cs @@ -0,0 +1,66 @@ +using System.Text.Json; + +namespace AgentWikiUi.Core; + +record Msg(string Role, string Text); // Role: user | ai | err +record Stored(string Id, string Title, DateTime Updated, bool Started, List Messages); + +// Cronologia: un JSON per chat, il nome file è l'id di sessione di `claude`. +static class ChatStore +{ + // Nomi precedenti dell'app: le loro chat vengono spostate nella cartella attuale al primo avvio. + static readonly string[] LegacyNames = ["LlmRun"]; + + static readonly string Dir = Prepare(); + + static string Prepare() + { + var data = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var dir = Path.Combine(data, AppInfo.Name, "chats"); + if (Directory.Exists(dir)) return dir; + foreach (var name in LegacyNames) + { + var old = Path.Combine(data, name, "chats"); + if (!Directory.Exists(old)) continue; + try { Directory.CreateDirectory(Path.GetDirectoryName(dir)!); Directory.Move(old, dir); } + catch (IOException) { } // se non si riesce a spostare, si riparte da una cartella vuota + break; + } + return dir; + } + + // L'id può arrivare dalla UI e diventa un nome file: accettare solo Guid. + static string? PathFor(string id) => Guid.TryParse(id, out var g) ? Path.Combine(Dir, g.ToString() + ".json") : null; + + public static void Save(string id, bool started, List messages) + { + if (messages.Count == 0) return; + Directory.CreateDirectory(Dir); + var first = messages.First(m => m.Role == "user").Text.ReplaceLineEndings(" ").Trim(); + var title = first.Length > 50 ? first[..50] + "…" : first; + File.WriteAllText(PathFor(id)!, JsonSerializer.Serialize(new Stored(id, title, DateTime.Now, started, messages))); + } + + public static Stored? Load(string id) + { + var file = PathFor(id); + return file is not null && File.Exists(file) ? Read(file) : null; + } + + // Dalla più recente; i file corrotti sono ignorati. + public static List List() => Directory.Exists(Dir) + ? Directory.EnumerateFiles(Dir, "*.json").Select(Read).OfType().OrderByDescending(c => c.Updated).ToList() + : []; + + public static void Delete(string id) + { + var file = PathFor(id); + if (file is not null && File.Exists(file)) File.Delete(file); + } + + static Stored? Read(string file) + { + try { return JsonSerializer.Deserialize(File.ReadAllText(file)); } + catch (Exception e) when (e is IOException or JsonException) { return null; } + } +} diff --git a/Core/Claude.cs b/Core/Claude.cs new file mode 100644 index 0000000..51f93dc --- /dev/null +++ b/Core/Claude.cs @@ -0,0 +1,77 @@ +using System.Diagnostics; + +namespace AgentWikiUi.Core; + +record ModelOption(string Label, string Id); + +static class Catalog +{ + // Da aggiornare quando escono nuovi modelli. + public static readonly ModelOption[] Models = + [ + new("Fable 5.1", "claude-fable-5-1"), + new("Opus 5.5", "claude-opus-5-5"), + new("Sonnet 5.5", "claude-sonnet-5-5"), + new("Haiku 5.5", "claude-haiku-5-5"), + ]; + + public static readonly string[] Efforts = ["low", "medium", "high", "xhigh", "max"]; +} + +record RunResult(int Code, string Out, string Err); + +// Unico punto di avvio di `claude`, condiviso da CLI e GUI. +static class Claude +{ + // sessionId null = nessun contesto (CLI). Model/effort vuoti = flag omessi. + public static ProcessStartInfo CreateStart(string prompt, bool redirectOutput, string? sessionId = null, bool resume = false, string model = "", string effort = "") + { + var psi = new ProcessStartInfo("claude") + { + WorkingDirectory = AppContext.BaseDirectory, // mai la cwd del chiamante né Assembly.Location (vuoto in single-file) + UseShellExecute = false, + RedirectStandardInput = true, // chiuso subito dal chiamante: evita l'attesa di stdin di claude + RedirectStandardOutput = redirectOutput, + RedirectStandardError = redirectOutput, + CreateNoWindow = true, + }; + psi.ArgumentList.Add("-p"); + psi.ArgumentList.Add(prompt); + if (sessionId is not null) + { + psi.ArgumentList.Add(resume ? "--resume" : "--session-id"); + psi.ArgumentList.Add(sessionId); + } + foreach (var (flag, value) in new[] { ("--model", model), ("--effort", effort) }) + if (value != "") { psi.ArgumentList.Add(flag); psi.ArgumentList.Add(value); } + return psi; + } + + // CLI: stdout/stderr ereditati (streaming), exit code propagato, 127 se `claude` manca. + public static int RunInherited(string prompt) + { + try + { + using var p = Process.Start(CreateStart(prompt, redirectOutput: false))!; + p.StandardInput.Close(); + p.WaitForExit(); + return p.ExitCode; + } + catch (System.ComponentModel.Win32Exception) + { + Console.Error.WriteLine("'claude' non trovato nel PATH."); + return 127; + } + } + + // GUI: output catturato. Win32Exception se `claude` non è nel PATH. + public static async Task RunCapturedAsync(string prompt, string sessionId, bool resume, string model, string effort) + { + using var p = Process.Start(CreateStart(prompt, redirectOutput: true, sessionId, resume, model, effort))!; + p.StandardInput.Close(); + var o = p.StandardOutput.ReadToEndAsync(); + var e = p.StandardError.ReadToEndAsync(); + await p.WaitForExitAsync(); + return new(p.ExitCode, (await o).Trim(), (await e).Trim()); + } +} diff --git a/Gui/App.axaml b/Gui/App.axaml new file mode 100644 index 0000000..cd21281 --- /dev/null +++ b/Gui/App.axaml @@ -0,0 +1,123 @@ + + + + + + + #FFFFFF + #FFFFFF + #0F172A + #F1F5F9 + #475569 + #CBD5E1 + #15803D + #FFFFFF + #DCFCE7 + #B91C1C + + + #0F172A + #1E293B + #F8FAFC + #1E293B + #94A3B8 + #334155 + #22C55E + #0F172A + #14301F + #F87171 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Gui/App.axaml.cs b/Gui/App.axaml.cs new file mode 100644 index 0000000..4c61ed0 --- /dev/null +++ b/Gui/App.axaml.cs @@ -0,0 +1,17 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; + +namespace AgentWikiUi.Gui; + +class App : Application +{ + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + desktop.MainWindow = new MainWindow(); + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/Gui/ConfirmDialog.cs b/Gui/ConfirmDialog.cs new file mode 100644 index 0000000..7ad40ac --- /dev/null +++ b/Gui/ConfirmDialog.cs @@ -0,0 +1,31 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; + +namespace AgentWikiUi.Gui; + +static class ConfirmDialog +{ + public static Task Ask(Window owner, string question) + { + var yes = new Button { Content = "Elimina", MinWidth = 90 }; + var no = new Button { Content = "Annulla", MinWidth = 90 }; + var dlg = new Window + { + Title = question, Width = 340, SizeToContent = SizeToContent.Height, CanResize = false, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + Content = new StackPanel + { + Margin = new(20), Spacing = 18, + Children = + { + new TextBlock { Text = question, TextWrapping = Avalonia.Media.TextWrapping.Wrap }, + new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Right, Children = { no, yes } }, + }, + }, + }; + yes.Click += (_, _) => dlg.Close(true); + no.Click += (_, _) => dlg.Close(false); + return dlg.ShowDialog(owner); + } +} diff --git a/Gui/MainViewModel.cs b/Gui/MainViewModel.cs new file mode 100644 index 0000000..fdf69ad --- /dev/null +++ b/Gui/MainViewModel.cs @@ -0,0 +1,180 @@ +using System.Collections.ObjectModel; +using System.Windows.Input; +using AgentWikiUi.Core; + +namespace AgentWikiUi.Gui; + +class MessageVm(string role, string text) +{ + public string Role { get; } = role; + public string Text { get; } = text; + public bool IsUser => Role == "user"; + public bool IsAi => Role == "ai"; + public bool IsErr => Role == "err"; +} + +class ChatItem(string id, string title, DateTime updated, bool active) +{ + public string Id { get; } = id; + public string Title { get; } = title; + public string When { get; } = updated.ToString("d MMM HH:mm"); + public bool IsActive { get; } = active; +} + +record Suggestion(string Title, string Subtitle, string Prompt); + +class MainViewModel : ObservableObject +{ + static readonly ModelOption Default = new("Predefinito", ""); + static readonly ModelOption Custom = new("Altro ID…", "__custom"); + + public ObservableCollection Messages { get; } = []; + public ObservableCollection Chats { get; } = []; + public ModelOption[] ModelChoices { get; } = [Default, .. Catalog.Models, Custom]; + public string[] EffortChoices { get; } = ["Predefinito", .. Catalog.Efforts]; + + public Suggestion[] Suggestions { get; } = + [ + new("Spiega il progetto", "Panoramica e struttura", "Spiega cosa fa questo progetto e come è organizzato."), + new("Scrivi uno script", "PowerShell, Python, C#…", "Scrivi uno script PowerShell che "), + new("Rivedi del codice", "Bug, leggibilità, prestazioni", "Rivedi questo codice e suggerisci miglioramenti:\n\n"), + new("Riassumi un testo", "Punti chiave in breve", "Riassumi in modo chiaro questo testo:\n\n"), + ]; + + public ICommand NewChatCommand => newChat; + public ICommand CopyCommand { get; } + public ICommand SendCommand { get; } + public ICommand OpenCommand { get; } + public ICommand DeleteCommand { get; } + public ICommand SuggestCommand { get; } + public ICommand ToggleSidebarCommand { get; } + + /// Conferma di eliminazione, fornita dalla finestra. + /// Copia negli appunti, fornita dalla finestra. + public Func CopyText { get; set; } = _ => Task.CompletedTask; + public Func> ConfirmDelete { get; set; } = () => Task.FromResult(true); + /// Chiesto alla finestra di rimettere il fuoco sul campo messaggio. + public event Action? FocusInput; + + string sessionId = Guid.NewGuid().ToString(); + bool started; // true dopo il primo messaggio riuscito: da lì in poi --resume + List history = []; + + string input = ""; + bool busy, sidebarOpen = true; + ModelOption selectedModel = Default; + string customModel = ""; + string selectedEffort = "Predefinito"; + readonly RelayCommand send, newChat; + + public MainViewModel() + { + send = new(_ => _ = SendAsync(), () => CanSend); + SendCommand = send; + newChat = new(_ => NewChat(), () => !busy); + CopyCommand = new RelayCommand(t => { if (t is not null) _ = CopyText(t); }); + OpenCommand = new RelayCommand(c => { if (c is not null) OpenChat(c.Id); }); + DeleteCommand = new RelayCommand(c => { if (c is not null) _ = DeleteAsync(c); }); + SuggestCommand = new RelayCommand(s => { if (s is not null) { Input = s.Prompt; FocusInput?.Invoke(); } }); + ToggleSidebarCommand = new RelayCommand(_ => SidebarOpen = !SidebarOpen); + Messages.CollectionChanged += (_, _) => { Raise(nameof(HasMessages)); Raise(nameof(ShowEmpty)); }; + + // all'avvio riapre la chat più recente + if (ChatStore.List().FirstOrDefault() is { } last) OpenChat(last.Id); else RefreshChats(); + } + + public bool HasMessages => Messages.Count > 0; + public bool ShowEmpty => Messages.Count == 0; + public bool CanSend => !busy && !string.IsNullOrWhiteSpace(input); + + public string Input + { + get => input; + set { if (Set(ref input, value)) { Raise(nameof(CanSend)); send.Refresh(); } } + } + + public bool IsBusy + { + get => busy; + private set { if (Set(ref busy, value)) { Raise(nameof(CanSend)); send.Refresh(); newChat.Refresh(); } } + } + + public bool SidebarOpen { get => sidebarOpen; set => Set(ref sidebarOpen, value); } + public ModelOption SelectedModel + { + get => selectedModel; + set { if (Set(ref selectedModel, value)) Raise(nameof(IsCustomModel)); } + } + public bool IsCustomModel => selectedModel == Custom; + public string CustomModel { get => customModel; set => Set(ref customModel, value); } + public string SelectedEffort { get => selectedEffort; set => Set(ref selectedEffort, value); } + + void RefreshChats() + { + Chats.Clear(); + foreach (var c in ChatStore.List()) Chats.Add(new(c.Id, c.Title, c.Updated, c.Id == sessionId)); + } + + void Show(List msgs) + { + Messages.Clear(); + foreach (var m in msgs) Messages.Add(new(m.Role, m.Text)); + } + + void NewChat() + { + sessionId = Guid.NewGuid().ToString(); started = false; history = []; + Show(history); RefreshChats(); FocusInput?.Invoke(); + } + + void OpenChat(string id) + { + if (busy) return; + if (ChatStore.Load(id) is not { } chat) { RefreshChats(); return; } + sessionId = chat.Id; started = chat.Started; history = chat.Messages; + Show(history); RefreshChats(); + } + + async Task DeleteAsync(ChatItem c) + { + if (busy || !await ConfirmDelete()) return; + ChatStore.Delete(c.Id); + if (c.Id == sessionId) NewChat(); else RefreshChats(); + } + + async Task SendAsync() + { + if (!CanSend) return; + var text = input.Trim(); + var model = IsCustomModel ? customModel.Trim() : selectedModel.Id; + var effort = selectedEffort == "Predefinito" ? "" : selectedEffort; + + Input = ""; IsBusy = true; + Add("user", text); + Persist(); // il messaggio non va perso se l'app si chiude durante l'attesa + RefreshChats(); + try + { + var r = await Claude.RunCapturedAsync(text, sessionId, started, model, effort); + if (r.Code == 0) { started = true; Add("ai", r.Out); } + else Add("err", $"claude è uscito con codice {r.Code}\n{r.Err}".Trim()); + } + catch (System.ComponentModel.Win32Exception) { Add("err", "'claude' non trovato nel PATH."); } + catch (Exception ex) { Add("err", ex.Message); } + finally + { + IsBusy = false; + Persist(); + RefreshChats(); + FocusInput?.Invoke(); + } + } + + void Add(string role, string text) { history.Add(new(role, text)); Messages.Add(new(role, text)); } + + void Persist() + { + try { ChatStore.Save(sessionId, started, history); } + catch (IOException) { } // la chat resta a schermo anche se il salvataggio fallisce + } +} diff --git a/Gui/MainWindow.axaml b/Gui/MainWindow.axaml new file mode 100644 index 0000000..3786513 --- /dev/null +++ b/Gui/MainWindow.axaml @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Gui/MainWindow.axaml.cs b/Gui/MainWindow.axaml.cs new file mode 100644 index 0000000..ae0f0b6 --- /dev/null +++ b/Gui/MainWindow.axaml.cs @@ -0,0 +1,48 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Interactivity; +using Avalonia.Threading; +using AgentWikiUi.Core; + +namespace AgentWikiUi.Gui; + +partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + var vm = new MainViewModel(); + DataContext = vm; + + // proporzioni 16:10 orizzontali: 75% della larghezza utile (max 1440), altezza max 90% + var screen = Screens.Primary; + if (screen is not null) + { + var area = screen.WorkingArea; + double w = Math.Min(1440, area.Width / screen.Scaling * 3 / 4); + var h = Math.Min(w * 10 / 16, area.Height / screen.Scaling * 9 / 10); + Width = h * 16 / 10; Height = h; + } + else { Width = 1280; Height = 800; } + WindowStartupLocation = WindowStartupLocation.CenterScreen; + + vm.CopyText = async text => { if (Clipboard is { } cb) await cb.SetTextAsync(text); }; + vm.ConfirmDelete = () => ConfirmDialog.Ask(this, "Eliminare questa chat?"); + vm.FocusInput += () => Dispatcher.UIThread.Post(() => InputBox.Focus()); + vm.Messages.CollectionChanged += (_, _) => Dispatcher.UIThread.Post(() => Scroll.ScrollToEnd(), DispatcherPriority.Background); + + // Invio invia, Maiusc+Invio va a capo (tunnel: prima che il TextBox inserisca l'a capo) + InputBox.AddHandler(KeyDownEvent, (_, e) => + { + if (e.Key == Key.Enter && !e.KeyModifiers.HasFlag(KeyModifiers.Shift)) + { + e.Handled = true; + if (vm.SendCommand.CanExecute(null)) vm.SendCommand.Execute(null); + } + }, RoutingStrategies.Tunnel); + + Opened += (_, _) => InputBox.Focus(); + } +} diff --git a/Gui/MarkdownView.cs b/Gui/MarkdownView.cs new file mode 100644 index 0000000..5e12cc8 --- /dev/null +++ b/Gui/MarkdownView.cs @@ -0,0 +1,204 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Layout; +using Avalonia.Media; +using Markdig; +using Markdig.Extensions.Tables; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; + +namespace AgentWikiUi.Gui; + +/// Mostra markdown come controlli Avalonia (nessun HTML): il testo è Markdig, il rendering è qui. +class MarkdownView : Decorator +{ + public static readonly StyledProperty SourceProperty = AvaloniaProperty.Register(nameof(Source)); + static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().DisableHtml().Build(); + static readonly FontFamily Mono = new("Cascadia Code, Consolas, DejaVu Sans Mono, Liberation Mono, monospace"); + + public string? Source { get => GetValue(SourceProperty); set => SetValue(SourceProperty, value); } + + public MarkdownView() + { + // i colori si risolvono in fase di rendering: al cambio di tema si ricostruisce + ActualThemeVariantChanged += (_, _) => Rebuild(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == SourceProperty) Rebuild(); + } + + void Rebuild() => Child = string.IsNullOrEmpty(Source) ? null : RenderBlocks(Markdig.Markdown.Parse(Source, Pipeline), 10); + + IBrush Brush(string key) => this.TryFindResource(key, ActualThemeVariant, out var v) && v is IBrush b ? b : Brushes.Gray; + + Control RenderBlocks(IEnumerable blocks, double spacing) + { + var panel = new StackPanel { Spacing = spacing }; + foreach (var b in blocks) panel.Children.Add(RenderBlock(b)); + return panel; + } + + Control RenderBlock(Block block) => block switch + { + HeadingBlock h => Text(h.Inline, bold: true, size: h.Level switch { 1 => 24, 2 => 20, 3 => 18, 4 => 16, _ => 15.5 }), + ParagraphBlock p => Text(p.Inline), + FencedCodeBlock or CodeBlock => Code(string.Join('\n', ((LeafBlock)block).Lines.Lines.Take(((LeafBlock)block).Lines.Count).Select(l => l.ToString()))), + QuoteBlock q => new Border { BorderBrush = Brush("Border"), BorderThickness = new(3, 0, 0, 0), Padding = new(12, 0, 0, 0), Child = RenderBlocks(q, 8) }, + ListBlock l => List(l), + Table t => TableOf(t), + ThematicBreakBlock => new Border { Height = 1, Background = Brush("Border"), Margin = new(0, 6) }, + LeafBlock leaf => Text(leaf.Inline), + ContainerBlock c => RenderBlocks(c, 8), + _ => new Panel(), + }; + + Control List(ListBlock list) + { + var panel = new StackPanel { Spacing = 4 }; + var n = int.TryParse(list.OrderedStart, out var start) ? start : 1; + foreach (var item in list.OfType()) + { + var row = new Grid { ColumnDefinitions = new("Auto,*") }; + var marker = new TextBlock { Text = list.IsOrdered ? $"{n++}." : "•", Foreground = Brush("FgMuted"), Margin = new(0, 0, 8, 0), MinWidth = 18, TextAlignment = TextAlignment.Right }; + var body = RenderBlocks(item, 4); + Grid.SetColumn(body, 1); + row.Children.Add(marker); + row.Children.Add(body); + panel.Children.Add(row); + } + return panel; + } + + Control Code(string code) + { + var text = new SelectableTextBlock { Text = code, FontFamily = Mono, FontSize = 13.5, Padding = new(14, 12, 64, 12) }; + var copy = new Button { Content = "Copia", Classes = { "mini" }, HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Top, Margin = new(0, 6, 6, 0) }; + copy.Click += async (_, _) => + { + if (TopLevel.GetTopLevel(this)?.Clipboard is { } cb) await cb.SetTextAsync(code); + copy.Content = "Copiato"; + await Task.Delay(1400); + copy.Content = "Copia"; + }; + return new Border + { + Background = Brush("Muted"), BorderBrush = Brush("Border"), BorderThickness = new(1), CornerRadius = new(10), + Child = new Grid { Children = { new ScrollViewer { HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto, Content = text }, copy } }, + }; + } + + Control TableOf(Table table) + { + var grid = new Grid(); + var rows = table.OfType().ToList(); + var cols = rows.Max(r => r.Count); + for (var c = 0; c < cols; c++) grid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star)); + for (var r = 0; r < rows.Count; r++) + { + grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); + var cells = rows[r].OfType().ToList(); + for (var c = 0; c < cells.Count; c++) + { + var cell = RenderBlocks(cells[c], 4); + if (cell is StackPanel { Children: [SelectableTextBlock tb] }) // intestazione in grassetto + if (rows[r].IsHeader) tb.FontWeight = FontWeight.SemiBold; + var align = c < table.ColumnDefinitions.Count ? table.ColumnDefinitions[c].Alignment : null; + cell.HorizontalAlignment = align switch { TableColumnAlign.Center => HorizontalAlignment.Center, TableColumnAlign.Right => HorizontalAlignment.Right, _ => HorizontalAlignment.Left }; + var box = new Border + { + BorderBrush = Brush("Border"), BorderThickness = new(0.5), Padding = new(10, 6), + Background = rows[r].IsHeader ? Brush("Muted") : null, Child = cell, + }; + Grid.SetRow(box, r); Grid.SetColumn(box, c); + grid.Children.Add(box); + } + } + return new Border { BorderBrush = Brush("Border"), BorderThickness = new(0.5), CornerRadius = new(6), ClipToBounds = true, Child = grid }; + } + + // Testo con inline; i link sono Run colorati e il clic si risolve con l'hit-test sul testo. + SelectableTextBlock Text(ContainerInline? inline, bool bold = false, double size = 0) + { + var tb = new SelectableTextBlock { TextWrapping = TextWrapping.Wrap }; + if (size > 0) tb.FontSize = size; + if (bold) tb.FontWeight = FontWeight.SemiBold; + var links = new List<(int Start, int End, string Url)>(); + var pos = 0; + AddInlines(tb.Inlines!, inline, new(), links, ref pos); + if (links.Count > 0) WireLinks(tb, links); + return tb; + } + + record struct Style(bool Bold, bool Italic, bool Strike, bool Link); + + void AddInlines(InlineCollection target, ContainerInline? container, Style st, List<(int, int, string)> links, ref int pos) + { + if (container is null) return; + foreach (var node in container) + { + switch (node) + { + case LiteralInline lit: Add(target, lit.Content.ToString(), st, ref pos); break; + case LineBreakInline br: Add(target, br.IsHard ? "\n" : " ", st, ref pos); break; + case CodeInline code: + target.Add(new Run(code.Content) { FontFamily = Mono, FontSize = 13.5, Background = Brush("Muted") }); + pos += code.Content.Length; + break; + case EmphasisInline em: + var inner = em.DelimiterChar == '~' ? st with { Strike = true } + : em.DelimiterCount >= 2 ? st with { Bold = true } : st with { Italic = true }; + AddInlines(target, em, inner, links, ref pos); + break; + case LinkInline { IsImage: true } img: + Add(target, "[" + string.Concat(img.OfType().Select(l => l.Content.ToString())) + "]", st, ref pos); + break; + case LinkInline link: + var start = pos; + AddInlines(target, link, st with { Link = true }, links, ref pos); + if (link.Url is { } url) links.Add((start, pos, url)); + break; + case AutolinkInline auto: + var s = pos; + Add(target, auto.Url, st with { Link = true }, ref pos); + links.Add((s, pos, auto.Url)); + break; + case ContainerInline c: AddInlines(target, c, st, links, ref pos); break; + default: break; + } + } + } + + void Add(InlineCollection target, string text, Style st, ref int pos) + { + var run = new Run(text); + if (st.Bold) run.FontWeight = FontWeight.Bold; + if (st.Italic) run.FontStyle = FontStyle.Italic; + if (st.Strike) run.TextDecorations = TextDecorations.Strikethrough; + if (st.Link) { run.Foreground = Brush("Accent"); run.TextDecorations = TextDecorations.Underline; } + target.Add(run); + pos += text.Length; + } + + void WireLinks(SelectableTextBlock tb, List<(int Start, int End, string Url)> links) + { + string? UrlAt(Point p) + { + var i = tb.TextLayout.HitTestPoint(p).TextPosition; + foreach (var (s, e, url) in links) if (i >= s && i < e) return url; + return null; + } + tb.PointerMoved += (_, e) => tb.Cursor = UrlAt(e.GetPosition(tb)) is null ? null : new Cursor(StandardCursorType.Hand); + tb.PointerReleased += async (_, e) => + { + if (tb.SelectionStart != tb.SelectionEnd) return; // era una selezione, non un clic + if (UrlAt(e.GetPosition(tb)) is { } url && Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https") + if (TopLevel.GetTopLevel(this)?.Launcher is { } l) await l.LaunchUriAsync(uri); + }; + } +} diff --git a/Gui/ObservableObject.cs b/Gui/ObservableObject.cs new file mode 100644 index 0000000..2f45502 --- /dev/null +++ b/Gui/ObservableObject.cs @@ -0,0 +1,28 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows.Input; + +namespace AgentWikiUi.Gui; + +class ObservableObject : INotifyPropertyChanged +{ + public event PropertyChangedEventHandler? PropertyChanged; + + protected bool Set(ref T field, T value, [CallerMemberName] string? name = null) + { + if (EqualityComparer.Default.Equals(field, value)) return false; + field = value; + Raise(name); + return true; + } + + protected void Raise([CallerMemberName] string? name = null) => PropertyChanged?.Invoke(this, new(name)); +} + +class RelayCommand(Action run, Func? canRun = null) : ICommand +{ + public event EventHandler? CanExecuteChanged; + public bool CanExecute(object? parameter) => canRun?.Invoke() ?? true; + public void Execute(object? parameter) => run(parameter is T t ? t : default); + public void Refresh() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); +} diff --git a/Program.cs b/Program.cs index 44936f9..ab5c1f3 100644 --- a/Program.cs +++ b/Program.cs @@ -1,44 +1,8 @@ -using System.Diagnostics; -using System.Windows.Forms; +using Avalonia; +using AgentWikiUi.Core; +using AgentWikiUi.Gui; -if (args.Length == 0) -{ - // WebView2 richiede un thread STA, che i top-level statements non danno - var ui = new Thread(() => - { - ApplicationConfiguration.Initialize(); - Application.SetColorMode(SystemColorMode.System); - Application.Run(new Gui()); - }); - ui.SetApartmentState(ApartmentState.STA); - ui.Start(); - ui.Join(); - return 0; -} +// Con argomenti: CLI (tutti gli argomenti uniti in un prompt). Senza: GUI. +if (args.Length > 0) return Claude.RunInherited(string.Join(' ', args)); -var psi = NewClaude(string.Join(' ', args)); -try -{ - using var p = Process.Start(psi)!; // stdout/stderr ereditati: streaming in tempo reale - p.StandardInput.Close(); - p.WaitForExit(); - return p.ExitCode; -} -catch (System.ComponentModel.Win32Exception) -{ - Console.Error.WriteLine("'claude' non trovato nel PATH."); - return 127; -} - -static ProcessStartInfo NewClaude(string prompt) -{ - var psi = new ProcessStartInfo("claude") - { - WorkingDirectory = AppContext.BaseDirectory, - UseShellExecute = false, - RedirectStandardInput = true, // evita l'attesa di stdin di claude - }; - psi.ArgumentList.Add("-p"); - psi.ArgumentList.Add(prompt); - return psi; -} +return AppBuilder.Configure().UsePlatformDetect().StartWithClassicDesktopLifetime(args); diff --git a/agent-wiki-ui.csproj b/agent-wiki-ui.csproj index d301037..99361c7 100644 --- a/agent-wiki-ui.csproj +++ b/agent-wiki-ui.csproj @@ -2,20 +2,24 @@ Exe - net10.0-windows - true + net10.0 + + agent-wiki-ui + AgentWikiUi enable enable - $(NoWarn);WFO5001 + true + + true + true + none + + + - - - - - From 84fefda21928d1da2dae219eff45c661b3c175d3 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Thu, 15 Oct 2026 11:22:08 +0200 Subject: [PATCH 04/17] Documentazione: strategia di test e regola "nuova funzione = nuovi test" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aggiunge ADR-0003 (xUnit, Avalonia.Headless, coverlet, FakeClaude come doppio di claude, struttura src/tests) e docs/TESTING.md con la regola, le soglie di copertura e la matrice requisito-test. Ogni criterio di accettazione dell'MVP è collegato alle righe della matrice. La roadmap ha la Fase 6 e CLAUDE.md rende obbligatori i test nello stesso commit. Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 3 +- docs/MVP.md | 4 +- docs/README.md | 5 +- docs/ROADMAP.md | 16 ++- docs/TESTING.md | 180 +++++++++++++++++++++++++++++ docs/adr/0003-strategia-di-test.md | 52 +++++++++ 6 files changed, 256 insertions(+), 4 deletions(-) create mode 100644 docs/TESTING.md create mode 100644 docs/adr/0003-strategia-di-test.md diff --git a/CLAUDE.md b/CLAUDE.md index 324c819..6d55180 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,7 @@ agent-wiki-ui (già `LlmRun`; il nome ha un'unica sorgente, vedi [docs/README.md ## Workflow doc-first (obbligatorio) - Specifica e criteri di accettazione: [docs/MVP.md](docs/MVP.md). Fasi e stato: [docs/ROADMAP.md](docs/ROADMAP.md). Indice dei doc e regole (SSOT, ADR): [docs/README.md](docs/README.md); una decisione architetturale si scrive solo nel suo ADR, gli altri doc la richiamano con un link. Uso: [README.md](README.md). +- **Test obbligatori** ([docs/TESTING.md](docs/TESTING.md), [ADR-0003](docs/adr/0003-strategia-di-test.md)): ogni nuova funzione, correzione o cambio di comportamento va coperta da test nello stesso commit; prima si aggiunge la riga (con ID) alla matrice in TESTING.md, poi i test, poi il codice. Un bug si corregge partendo da un test che fallisce. - Ad ogni richiesta di modifica: aggiornare prima i doc interessati (MVP.md, ROADMAP.md con le spunte, README.md se cambia l'uso; un nuovo ADR in `docs/adr/` per ogni scelta architetturale), poi il codice, poi questo file se cambiano comandi, design o vincoli. Nessuna modifica deve lasciare i doc disallineati. ## Comandi @@ -16,7 +17,7 @@ agent-wiki-ui (già `LlmRun`; il nome ha un'unica sorgente, vedi [docs/README.md - Build: `dotnet build` (agent-wiki-ui.csproj, `net10.0`, Avalonia) - GUI: `dotnet run`; CLI: `dotnet run -- "prompt"` - Publish single-file (uno per sistema): `dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true` e `-r linux-x64` -- Test: non esistono ancora. Per verificare la GUI senza schermo si può usare `Avalonia.Headless` con `CaptureRenderedFrame()` in un progetto di prova fuori dal repo. +- Test: non esistono ancora (pianificati, Fase 6: `dotnet test`; il piano è in docs/TESTING.md). Per verificare la GUI senza schermo si può usare `Avalonia.Headless` con `CaptureRenderedFrame()` in un progetto di prova fuori dal repo. ## Architettura diff --git a/docs/MVP.md b/docs/MVP.md index ab2088e..4f56698 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -51,9 +51,11 @@ Le scelte architetturali sono negli [ADR](README.md#indice-adr). Sintesi: - L'avvio di `claude` è in un solo punto ([Core/Claude.cs](../Core/Claude.cs)), usato da CLI e GUI. ## Fuori scope (MVP) -Streaming token-per-token nella GUI, annullamento di una risposta in corso, rinomina/ricerca delle chat, config file, altri motori oltre a Claude Code (Codex, Antigravity: vedi [ADR-0002](adr/0002-motori-cli-claude-codex-antigravity.md)), logging su file, test automatici, supporto macOS. +Streaming token-per-token nella GUI, annullamento di una risposta in corso, rinomina/ricerca delle chat, config file, altri motori oltre a Claude Code (Codex, Antigravity: vedi [ADR-0002](adr/0002-motori-cli-claude-codex-antigravity.md)), logging su file, supporto macOS. I test automatici sono pianificati: [TESTING.md](TESTING.md). ## Criteri di accettazione +Ogni criterio ha test automatici nella matrice di [TESTING.md](TESTING.md) (CL-04, CL-08, CL-06, CL-01). + - [ ] Eseguito da un'altra cartella, `claude` parte nella cartella dell'exe (verificabile chiedendo "pwd"). - [ ] L'output appare in streaming, non solo alla fine. - [ ] L'exit code è propagato. diff --git a/docs/README.md b/docs/README.md index efd2d1f..a60922e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ Indice e regole. Ogni informazione vive in **un solo documento**; gli altri la r |---|---| | [../README.md](../README.md) | Uso, requisiti, build e publish | | [MVP.md](MVP.md) | Specifica funzionale, design UI/UX, criteri di accettazione | +| [TESTING.md](TESTING.md) | Piano dei test: regola "nuova funzione = nuovi test", soglie di copertura, matrice requisito → test | | [ROADMAP.md](ROADMAP.md) | Fasi e stato (spunte); solo passi, mai motivazioni | | `adr/NNNN-*.md` | Decisioni architetturali e loro motivazioni (indice sotto) | | [../CLAUDE.md](../CLAUDE.md) | Guida per Claude Code: comandi, architettura del codice, vincoli | @@ -20,7 +21,8 @@ Se il nome cambia di nuovo, aggiornare solo questi punti: - [ ] Titolo di [../README.md](../README.md) e riga iniziale di [../CLAUDE.md](../CLAUDE.md) (nome del csproj nei comandi) ## Flusso (doc-first, doc-as-code) -1. Prima i doc, poi il codice: ADR (se c'è una scelta architetturale) → MVP → ROADMAP → README → codice → CLAUDE.md. +1. Prima i doc, poi i test, poi il codice: ADR (se c'è una scelta architetturale) → MVP → matrice in [TESTING.md](TESTING.md) → test → codice → ROADMAP → README → CLAUDE.md. + Nessuna funzione è finita senza test che la coprono, nello stesso commit. 2. I doc stanno nel repo e cambiano con lo stesso commit del codice a cui si riferiscono. 3. Una modifica non deve lasciare i doc disallineati. @@ -35,3 +37,4 @@ Se il nome cambia di nuovo, aggiornare solo questi punti: |---|---|---| | [0001](adr/0001-gui-multipiattaforma-avalonia.md) | GUI multipiattaforma in solo C# con Avalonia | Accettata | | [0002](adr/0002-motori-cli-claude-codex-antigravity.md) | Più motori CLI (Claude Code, Codex, Antigravity) su Windows e Linux | Proposta | +| [0003](adr/0003-strategia-di-test.md) | Strategia di test e regola "nuova funzione = nuovi test" | Proposta | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5da4487..c3e0c20 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -23,7 +23,7 @@ Dettagli dell'MVP in [MVP.md](MVP.md). - [x] GUI: cronologia chat (salvataggio JSON, barra laterale, riapertura con `--resume`, eliminazione) - [ ] GUI: streaming della risposta (`--output-format stream-json`), stop, rinomina/ricerca chat - [ ] Config file accanto all'exe -- [ ] Test automatici e CI +- [ ] Test automatici e CI (vedi Fase 6) ## Fase 4 — Più motori (Claude Code, Codex, Antigravity) Decisione, tabella dei comandi e rischi: [ADR-0002](adr/0002-motori-cli-claude-codex-antigravity.md). Qui solo i passi. Va fatta dopo (o insieme a) la Fase 5, che estrae il punto unico di avvio processo. @@ -53,3 +53,17 @@ Decisione e motivazioni: [ADR-0001](adr/0001-gui-multipiattaforma-avalonia.md). - [x] Eliminare `ui.html` e il protocollo `WebMessage` - [ ] Publish single-file self-contained `win-x64` e `linux-x64`: compilano e partono (Linux: CLI e GUI su WSLg; Windows: solo CLI, via WSL); da provare la GUI su Windows nativo e su una distribuzione Linux pulita - [x] Aggiornare README.md e CLAUDE.md (comandi, architettura, vincoli) a migrazione conclusa + +## Fase 6 — Test e copertura completa +Decisione: [ADR-0003](adr/0003-strategia-di-test.md). Piano, soglie e matrice: [TESTING.md](TESTING.md). Da questa fase in poi vale la regola: ogni nuova funzione arriva con i suoi test. + +- [ ] Verificare le compatibilità dell'ADR-0003 (xUnit, Avalonia.Headless, coverlet, `dotnet test`) e portarlo ad "Accettata" +- [ ] Ristrutturare in `src/` e `tests/` con soluzione unica e `InternalsVisibleTo` +- [ ] Predisposizioni di testabilità: `ChatStore` a istanza, `IClaudeRunner` +- [ ] `FakeClaude`: eseguibile finto per i test di processo +- [ ] Test `Core/` (CL, ST) e `MainViewModel` (VM) +- [ ] Test del renderer markdown (MD) e della GUI headless (UI) +- [ ] Test end-to-end e smoke di publish per `win-x64` e `linux-x64` (PUB) +- [ ] Soglie di copertura che fanno fallire la build +- [ ] CI con matrice Windows + Linux (fornitore da scegliere) +- [ ] Batteria di contratto per ogni motore (EN), insieme alla Fase 4 diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..b24cc81 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,180 @@ +# Piano dei test + +Decisioni (strumenti, livelli, struttura): [ADR-0003](adr/0003-strategia-di-test.md). Qui: regola, flusso, soglie e **matrice requisito → test**, sorgente di verità per la copertura. Le spunte `[ ]` passano a `[x]` quando il test esiste e passa. + +## Regola +**Nuova funzione, correzione o cambio di comportamento ⇒ test nello stesso commit.** Niente test, funzione non finita. +- Una nuova funzione aggiunge righe alla matrice qui sotto (con ID) prima di scrivere il codice. +- Una correzione di bug parte da un test che fallisce e lo lascia come regressione. +- Una modifica che cambia un requisito aggiorna prima il doc, poi la riga della matrice, poi i test, poi il codice. +- Una riga senza test non è copertura: va lasciata `[ ]` e conta come debito visibile. + +## Flusso (estende il doc-first di [README.md](README.md)) +ADR (se serve) → MVP.md (requisito e criterio) → riga in questa matrice → test che falliscono → codice → test verdi → ROADMAP/README/CLAUDE.md. + +## Livelli +| Livello | Cosa prova | Come | +|---|---|---| +| **U** — unitario | `Core/` e `MainViewModel` | xUnit, finto runner, cartella dati temporanea | +| **R** — renderer | `MarkdownView` | xUnit + Headless, asserzioni sull'albero visuale | +| **G** — GUI headless | `MainWindow`, template, tastiera, finestre di dialogo | xUnit + Headless, senza schermo | +| **P** — processo | avvio reale di un processo (`Claude`) | `FakeClaude` nel PATH | +| **E** — end-to-end | l'eseguibile pubblicato (CLI, 127, exit code) | publish + processo figlio + `FakeClaude` | + +## Struttura prevista +``` +agent-wiki-ui.slnx +src/agent-wiki-ui/ app (Core/, Gui/, Program.cs) +tests/agent-wiki-ui.Tests/ U, R, G, P, E (Trait "Category": Unit | Gui | Process | E2E) +tests/FakeClaude/ eseguibile finto: registra argv/cwd/stdin; testo, stderr, exit code e ritardo da variabili d'ambiente +``` + +## Predisposizioni di testabilità (prerequisito dei test) +- [ ] `ChatStore` come istanza con cartella iniettata (oggi statico su `LocalApplicationData`) +- [ ] `IClaudeRunner` dietro `Core/Claude.cs`, iniettato nel `MainViewModel` +- [ ] `InternalsVisibleTo` verso il progetto di test +- [ ] Spostamento in `src/` e `tests/` con soluzione unica + +## Soglie di copertura (la build fallisce sotto soglia) +| Area | Righe | Rami | +|---|---|---| +| `Core/` | ≥ 95% | ≥ 90% | +| `Gui/MainViewModel` | ≥ 95% | ≥ 90% | +| `Gui/MarkdownView` | ≥ 90% | ≥ 85% | +| `Gui/MainWindow*`, `ConfirmDialog` (code-behind) | ≥ 85% | — | +| Totale | ≥ 90% | — | + +Esclusi dal calcolo: XAML generato, `Program.cs` (tre righe, coperte da E2E), `App.axaml.cs`. La soglia non sostituisce la matrice: copertura di righe alta con requisiti scoperti non basta. + +## Matrice requisito → test +Fonte dei requisiti: [MVP.md](MVP.md) e gli ADR. Livello: vedi sopra. Il nome del test è indicativo. + +### Avvio di `claude` — `Core/Claude.cs` +| ID | Requisito | Liv. | Test | | +|---|---|---|---|---| +| CL-01 | Il prompt è un solo argomento, anche con virgolette e spazi (criterio MVP 4) | U/P | `Prompt_con_virgolette_arriva_intatto` | [ ] | +| CL-02 | Primo messaggio: `--session-id `; successivi: `--resume ` | U | `Sessione_prima_e_ripresa` | [ ] | +| CL-03 | `--model`/`--effort` omessi se vuoti, presenti se valorizzati | U | `Flag_opzionali` | [ ] | +| CL-04 | Cartella di lavoro = cartella dell'exe, non la cwd del chiamante (criterio MVP 1) | U/P | `Cartella_di_lavoro_e_quella_dell_exe` | [ ] | +| CL-05 | Stdin chiuso subito: nessuna attesa di 3 s | P | `Stdin_chiuso_non_blocca` | [ ] | +| CL-06 | CLI: l'exit code di `claude` è propagato (criterio MVP 3) | P/E | `ExitCode_propagato` | [ ] | +| CL-07 | CLI: `claude` assente ⇒ messaggio chiaro ed exit code 127 | P/E | `Claude_assente_127` | [ ] | +| CL-08 | CLI: stdout/stderr ereditati e ritrasmessi (criterio MVP 2: streaming) | E | `Output_ereditato_in_tempo_reale` | [ ] | +| CL-09 | GUI: output catturato e ripulito agli estremi; stderr separato | P | `Output_catturato` | [ ] | +| CL-10 | GUI: `claude` assente ⇒ eccezione riconoscibile, non crash | P | `Claude_assente_in_GUI` | [ ] | +| CL-11 | Il catalogo modelli contiene gli ID attesi e gli effort previsti | U | `Catalogo_modelli_ed_effort` | [ ] | +| CL-12 | Senza argomenti non parte la CLI; con argomenti sì (tutti uniti in un prompt) | E | `Argomenti_uniti_in_un_prompt` | [ ] | + +### Cronologia — `Core/ChatStore.cs`, `Core/AppInfo.cs` +| ID | Requisito | Liv. | Test | | +|---|---|---|---|---| +| ST-01 | Salvataggio e rilettura: id, titolo, data, `started`, messaggi con ruolo e testo | U | `Salva_e_rileggi` | [ ] | +| ST-02 | Una chat senza messaggi non viene salvata | U | `Chat_vuota_non_salvata` | [ ] | +| ST-03 | Titolo = inizio del primo messaggio utente: 50 caratteri + "…", a capo sostituiti da spazi | U | `Titolo_troncato` | [ ] | +| ST-04 | L'id deve essere un Guid: un id con `..` o separatori non tocca il disco fuori dalla cartella | U | `Id_non_guid_rifiutato` | [ ] | +| ST-05 | L'elenco è ordinato dalla più recente | U | `Elenco_ordinato` | [ ] | +| ST-06 | File JSON corrotto ignorato senza eccezioni | U | `File_corrotto_ignorato` | [ ] | +| ST-07 | Eliminazione: rimuove solo il file di quella chat; id inesistente senza errori | U | `Elimina_chat` | [ ] | +| ST-08 | Compatibilità del formato: un JSON scritto dalla versione WinForms si legge | U | `Formato_storico_compatibile` | [ ] | +| ST-09 | Migrazione: la cartella di un nome precedente (`LlmRun`) si sposta nella nuova al primo avvio; se la nuova esiste non si tocca nulla | U | `Migrazione_da_nome_precedente` | [ ] | +| ST-10 | `AppInfo.Name` = `AssemblyName` del csproj | U | `Nome_da_assembly` | [ ] | +| ST-11 | Errore di I/O nel salvataggio non perde la chat a schermo | U | vedi VM-12 | [ ] | + +### Logica della chat — `Gui/MainViewModel.cs` +| ID | Requisito | Liv. | Test | | +|---|---|---|---|---| +| VM-01 | Senza chat salvate: schermata iniziale (`ShowEmpty`), elenco vuoto | U | `Avvio_senza_chat` | [ ] | +| VM-02 | All'avvio si riapre la chat più recente | U | `Avvio_riapre_ultima` | [ ] | +| VM-03 | `CanSend` falso se il testo è vuoto o solo spazi, o se occupato | U | `CanSend` | [ ] | +| VM-04 | Invio: il messaggio utente compare e viene **salvato prima** della risposta | U | `Messaggio_salvato_prima_della_risposta` | [ ] | +| VM-05 | Risposta riuscita: aggiunta come "ai", `started` diventa vero, il secondo invio usa `--resume` | U | `Risposta_e_ripresa` | [ ] | +| VM-06 | Risposta fallita (exit ≠ 0): messaggio "err" con codice e stderr; `started` resta falso e il tentativo successivo usa ancora `--session-id` | U | `Errore_non_avvia_la_sessione` | [ ] | +| VM-07 | `claude` assente: messaggio "'claude' non trovato nel PATH." | U | `Claude_assente_messaggio` | [ ] | +| VM-08 | Durante l'attesa: Invia e Nuova chat disabilitati; aprire/eliminare è ignorato | U | `Occupato_blocca_azioni` | [ ] | +| VM-09 | Modello: "Predefinito" ⇒ nessun flag; modello scelto ⇒ ID; "Altro ID…" ⇒ testo libero ripulito | U | `Scelta_modello` | [ ] | +| VM-10 | Effort: "Predefinito" ⇒ nessun flag; altrimenti il valore | U | `Scelta_effort` | [ ] | +| VM-11 | Nuova chat: svuota la vista, nuovo guid, `started` falso, elenco aggiornato | U | `Nuova_chat` | [ ] | +| VM-12 | Errore di I/O nel salvataggio non interrompe l'invio né cancella i messaggi | U | `Salvataggio_fallito_non_perde_chat` | [ ] | +| VM-13 | Apertura chat: carica messaggi, `started` e id; chat inesistente ⇒ elenco ricaricato | U | `Apri_chat` | [ ] | +| VM-14 | Eliminazione: con conferma rimuove; senza conferma non fa nulla; se è la chat aperta parte una nuova | U | `Elimina_con_e_senza_conferma` | [ ] | +| VM-15 | Suggerimento: precompila il campo e chiede il focus | U | `Suggerimento_precompila` | [ ] | +| VM-16 | Barra laterale: il comando commuta `SidebarOpen` | U | `Barra_laterale` | [ ] | +| VM-17 | Copia: il comando passa il testo alla funzione degli appunti | U | `Copia_negli_appunti` | [ ] | +| VM-18 | Notifiche di proprietà: `HasMessages`, `ShowEmpty`, `CanSend`, `IsCustomModel` cambiano quando devono | U | `Notifiche_proprieta` | [ ] | +| VM-19 | Il titolo e la data dell'elenco riflettono l'ultimo scambio; la chat aperta è marcata attiva | U | `Elenco_chat_attiva` | [ ] | + +### Renderer markdown — `Gui/MarkdownView.cs` +| ID | Requisito | Liv. | Test | | +|---|---|---|---|---| +| MD-01 | Sorgente vuota o nulla ⇒ nessun contenuto, senza errori | R | `Sorgente_vuota` | [ ] | +| MD-02 | Titoli h1–h6 con dimensioni decrescenti e peso semi-grassetto | R | `Titoli` | [ ] | +| MD-03 | Paragrafi; a capo morbido = spazio, a capo forzato = riga nuova | R | `A_capo` | [ ] | +| MD-04 | Grassetto, corsivo, barrato, anche annidati | R | `Enfasi` | [ ] | +| MD-05 | Codice inline in carattere monospazio con sfondo | R | `Codice_inline` | [ ] | +| MD-06 | Blocco di codice: monospazio, scorrimento orizzontale, pulsante Copia che copia solo il codice | R/G | `Blocco_codice_e_copia` | [ ] | +| MD-07 | Liste puntate e numerate (rispetta il numero iniziale), annidate | R | `Liste` | [ ] | +| MD-08 | Citazioni con barra laterale, anche annidate | R | `Citazioni` | [ ] | +| MD-09 | Tabelle: intestazione in evidenza, allineamenti sinistra/centro/destra, righe irregolari | R | `Tabelle` | [ ] | +| MD-10 | Linea orizzontale | R | `Linea_orizzontale` | [ ] | +| MD-11 | Link: colore d'accento e sottolineatura; il clic apre solo `http(s)` | R | `Link_http_https` | [ ] | +| MD-12 | Link: nessuna apertura se c'è una selezione in corso; cursore a mano sopra il link | R | `Link_con_selezione` | [ ] | +| MD-13 | Posizioni dei link corrette con enfasi, codice inline e a capo prima del link | R | `Intervalli_link` | [ ] | +| MD-14 | Immagini: si mostra il testo alternativo, non si scarica nulla | R | `Immagini_come_testo` | [ ] | +| MD-15 | HTML grezzo disabilitato: i tag compaiono come testo, non come elementi | R | `Html_disabilitato` | [ ] | +| MD-16 | Cambio di tema (chiaro/scuro): i colori si aggiornano ricostruendo la vista | R | `Cambio_tema` | [ ] | +| MD-17 | Markdown malformato o blocchi sconosciuti non fanno crashare | R | `Markdown_malformato` | [ ] | +| MD-18 | Testo selezionabile | R | `Testo_selezionabile` | [ ] | + +### Finestra e interazione — `Gui/MainWindow*`, `Gui/App.axaml`, `Gui/ConfirmDialog.cs` +| ID | Requisito | Liv. | Test | | +|---|---|---|---|---| +| UI-01 | La finestra si apre e mostra la schermata iniziale con quattro suggerimenti | G | `Apertura_schermata_iniziale` | [ ] | +| UI-02 | Proporzioni 16:10, 75% dell'area utile (max 1440), minimo 900x600; senza schermo primario 1280x800 | G | `Dimensioni_finestra` | [ ] | +| UI-03 | `Invio` invia, `Maiusc+Invio` va a capo, nessun invio a campo vuoto | G | `Tasti_invio` | [ ] | +| UI-04 | Il pulsante Invia è disabilitato con campo vuoto o in attesa | G | `Pulsante_invia` | [ ] | +| UI-05 | I messaggi hanno aspetto per ruolo: utente, Claude (con Copia), errore | G | `Template_per_ruolo` | [ ] | +| UI-06 | La conversazione scorre in fondo a ogni nuovo messaggio | G | `Scorrimento_in_fondo` | [ ] | +| UI-07 | Indicatore di attesa visibile solo durante l'elaborazione | G | `Indicatore_attesa` | [ ] | +| UI-08 | Elenco chat: voce attiva evidenziata, clic apre, ✕ elimina; "Nessuna chat salvata" se vuoto | G | `Elenco_chat` | [ ] | +| UI-09 | Il campo "Altro ID…" compare solo con quella voce del modello | G | `Campo_modello_personalizzato` | [ ] | +| UI-10 | Il pulsante a tre linee mostra/nasconde la barra laterale | G | `Mostra_nascondi_barra` | [ ] | +| UI-11 | Dialogo di conferma: restituisce vero con Elimina, falso con Annulla o chiusura | G | `Dialogo_conferma` | [ ] | +| UI-12 | Accessibilità: ogni pulsante a sola icona e il campo messaggio hanno `AutomationProperties.Name` | G | `Nomi_accessibili` | [ ] | +| UI-13 | Tema: sia il chiaro sia lo scuro risolvono tutti i token usati (nessuna risorsa mancante) | G | `Token_tema_completi` | [ ] | +| UI-14 | Focus sul campo messaggio all'apertura, dopo la risposta e dopo "Nuova chat" | G | `Focus_campo` | [ ] | +| UI-15 | Il nome dell'app compare nel titolo della finestra e nella barra laterale | G | `Nome_app_visibile` | [ ] | + +### Eseguibile pubblicato +| ID | Requisito | Liv. | Test | | +|---|---|---|---|---| +| PUB-01 | Il publish single-file `linux-x64` si compila e la CLI risponde con `FakeClaude` | E | `Publish_linux_smoke` | [ ] | +| PUB-02 | Il publish single-file `win-x64` si compila e la CLI risponde con `FakeClaude` | E | `Publish_windows_smoke` | [ ] | +| PUB-03 | Le librerie native (Skia/HarfBuzz) vengono estratte e la GUI si avvia (provata con timeout) | E | `Gui_si_avvia` | [ ] | + +### Più motori (ADR-0002) — da compilare quando si implementa la Fase 4 +Per **ogni** motore, la stessa batteria di contratto: +| ID | Requisito | Liv. | | +|---|---|---|---| +| EN-01 | Argomenti del primo messaggio e della ripresa | U | [ ] | +| EN-02 | Rilevamento nel PATH; assente ⇒ disabilitato e messaggio chiaro | U/P | [ ] | +| EN-03 | Lettura dell'id di sessione dall'output (Codex) | U | [ ] | +| EN-04 | Parser dell'output → markdown, con output di esempio reali salvati nei test | U | [ ] | +| EN-05 | Modelli ed effort mostrati secondo il motore | U/G | [ ] | +| EN-06 | Il JSON della chat memorizza il motore; cambiare motore apre una nuova chat | U | [ ] | +| EN-07 | Politica dei permessi: nessun flag che salta i controlli senza scelta esplicita | U | [ ] | +| EN-08 | Impostazioni persistite e rilette | U | [ ] | +| EN-09 | Gestione del blocco di `agy -p` con output rediretto (timeout, errore chiaro) | P | [ ] | + +## Come si eseguono (a implementazione avvenuta) +Comandi previsti, da confermare con ADR-0003 → "Da verificare": +- Tutti i test: `dotnet test` +- Con copertura e soglie: `dotnet test --collect:"XPlat Code Coverage"` e controllo delle soglie della tabella sopra +- Solo una categoria: `dotnet test --filter "Category=Unit"` (o `Gui`, `Process`, `E2E`) +Su Linux i test GUI girano senza display (Headless). + +## Definition of Done di una modifica +- [ ] Doc aggiornati (MVP, ROADMAP, ADR se serve) +- [ ] Righe nuove o modificate nella matrice, con ID +- [ ] Test scritti e verdi su Windows e Linux +- [ ] Soglie di copertura rispettate +- [ ] Nessun requisito della modifica resta con la casella `[ ]` senza motivo scritto diff --git a/docs/adr/0003-strategia-di-test.md b/docs/adr/0003-strategia-di-test.md new file mode 100644 index 0000000..93151f1 --- /dev/null +++ b/docs/adr/0003-strategia-di-test.md @@ -0,0 +1,52 @@ +# ADR-0003 — Strategia di test e regola "nuova funzione = nuovi test" + +- **Stato**: Proposta +- **Data**: 2026-10-15 +- **Collegato a**: [ADR-0001](0001-gui-multipiattaforma-avalonia.md), [ADR-0002](0002-motori-cli-claude-codex-antigravity.md) + +## Contesto +Oggi non esistono test. L'app gira su due sistemi operativi, ha una CLI, una GUI e una dipendenza esterna (`claude`) che non si può chiamare nei test: costa, è lenta e non è deterministica. Con l'arrivo di altri motori (ADR-0002) la superficie da proteggere cresce. Serve una strategia che garantisca **copertura completa** e che renda i test parte del lavoro di ogni nuova funzione. + +## Criteri di decisione +1. Nessun test dipende da `claude`, `codex`, `agy`, dalla rete o da un account. +2. Gli stessi test girano su Windows e Linux, senza schermo (CI senza display). +3. Copertura misurata e con soglia che fa fallire la build, non solo un numero da guardare. +4. Ogni requisito scritto nei doc è tracciabile a uno o più test. +5. Costo basso per aggiungere un test: il codice deve essere progettato per essere testabile. + +## Decisione +- **Regola**: una funzione, correzione o modifica di comportamento non è finita finché non ha test che la coprono, **nello stesso commit**. Una correzione di bug parte da un test che fallisce. La regola e il flusso sono in [TESTING.md](../TESTING.md). +- **Piano dei test come documento**: [TESTING.md](../TESTING.md) è l'unica sorgente della matrice requisito → test e della politica di copertura. Gli altri doc la richiamano con un link. +- **Strumenti**: xUnit come framework; `Avalonia.Headless` (con l'integrazione xUnit) per i test della GUI senza schermo; `coverlet` per la copertura. Le versioni compatibili con Avalonia 12 e .NET 10 vanno verificate (vedi sotto). +- **Doppio di `claude`**: un piccolo eseguibile di test, `FakeClaude`, scritto in C# e compilato insieme ai test. Registra argomenti, cartella di lavoro e stdin, e risponde in base a variabili d'ambiente (testo, stderr, exit code, ritardo). Essendo un eseguibile nativo funziona uguale su Windows e Linux (uno script `.sh` o `.cmd` non sarebbe avviabile direttamente come `claude`). +- **Livelli**: unitari (Core, ViewModel, renderer markdown), componenti GUI headless (finestra, template, tastiera), end-to-end sull'eseguibile pubblicato (CLI e codice 127) e smoke di publish per `win-x64` e `linux-x64`. +- **Predisposizioni di testabilità** nel codice (da fare insieme ai primi test): + - `ChatStore` diventa un'istanza con cartella iniettata, non uno statico legato a `LocalApplicationData`. + - L'esecuzione di `claude` sta dietro un'interfaccia (`IClaudeRunner`) iniettata nel ViewModel; l'implementazione reale resta in `Core/Claude.cs`. I test del ViewModel usano un finto runner; quelli di `Claude` usano `FakeClaude`. + - La conferma di eliminazione, gli appunti e il focus sono già delegati (`ConfirmDelete`, `CopyText`, `FocusInput`) e restano sostituibili. + - Gli ADR-0002 (motori) richiedono che ogni motore abbia la propria batteria di test sugli argomenti e sul parser dell'output. +- **Struttura del repo**: codice dell'app in `src/agent-wiki-ui/`, test in `tests/`, soluzione unica alla radice. Così il globbing del csproj dell'app non include i sorgenti dei test. Il progetto dell'app espone gli `internal` ai test con `InternalsVisibleTo`. +- **Porte di qualità**: copertura minima per area (in TESTING.md); `dotnet test` con copertura deve passare su Windows e Linux. Il fornitore di CI non è ancora scelto: i comandi sono indipendenti dal fornitore e vivono nel repo. + +## Opzioni considerate +| Opzione | Esito | Motivo | +|---|---|---| +| Test manuali con checklist | Scartata | Non scala, non gira in CI, non protegge dalle regressioni. | +| Test solo unitari sulla logica | Scartata | La GUI e la CLI reale (exit code, 127, cartella di lavoro, stdin) resterebbero scoperte. | +| Test GUI con screenshot di riferimento | Rinviata | Font e rendering cambiano tra Windows e Linux: i confronti pixel sono instabili. Si usano asserzioni sull'albero visuale; gli screenshot restano come artefatti da guardare. | +| `claude` finto come script | Scartata | Su Windows `claude` si avvia direttamente (senza `cmd /c`): serve un eseguibile. | +| **xUnit + Headless + FakeClaude + coverlet** | **Scelta** | Copre tutti i livelli, senza dipendenze esterne, su entrambi i sistemi. | + +## Conseguenze +**Positive**: regressioni intercettate su entrambi i sistemi; requisiti tracciabili; i motori aggiuntivi si aggiungono con test di contratto uniformi. +**Negative / costi**: un refactoring iniziale per iniettare cartella dati e runner; la struttura `src/` + `tests/` sposta file; la soglia di copertura obbliga a mantenere i test anche per codice di interfaccia; i test headless sono più lenti degli unitari. + +## Da verificare +- [ ] Versioni di xUnit, `Avalonia.Headless` (integrazione xUnit) e `coverlet` compatibili con Avalonia 12 e .NET 10, e `dotnet test` funzionante con la soluzione. +- [ ] `Avalonia.Headless` con rendering Skia disponibile su Windows e Linux senza display (in questa macchina il rendering headless è già stato provato fuori dal repo). +- [ ] Nessun test di GUI instabile per i tempi (usare attese su condizioni, non pause fisse). +- [ ] Scelta del fornitore di CI e traduzione dei comandi in una pipeline con matrice Windows + Linux. + +## Riferimenti +- Piano, matrice e regole: [TESTING.md](../TESTING.md). +- Passi: [ROADMAP.md](../ROADMAP.md), Fase 6. From fee788134dbf15cf053ef289a531d305073d2004 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Thu, 15 Oct 2026 11:26:06 +0200 Subject: [PATCH 05/17] GUI: impostazioni di modello ed effort in un riquadro in alto a destra Modello ed effort lasciano il composer e passano in un riquadro a comparsa aperto dal pulsante in alto a destra, simmetrico a quello della barra laterale. Il composer resta con campo di testo e pulsante Invia sulla stessa riga. Eliminati i suggerimenti della schermata iniziale. MVP.md e la matrice di TESTING.md sono aggiornati di conseguenza. Co-Authored-By: Claude Sonnet 5.5 --- Gui/App.axaml | 13 -------- Gui/MainViewModel.cs | 12 -------- Gui/MainWindow.axaml | 71 ++++++++++++++++++++++---------------------- docs/MVP.md | 6 ++-- docs/TESTING.md | 8 +++-- 5 files changed, 43 insertions(+), 67 deletions(-) diff --git a/Gui/App.axaml b/Gui/App.axaml index cd21281..b40b891 100644 --- a/Gui/App.axaml +++ b/Gui/App.axaml @@ -66,19 +66,6 @@ - - + + + + + + @@ -151,7 +163,7 @@