6 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5.5 f2086de31d ADR-0007: schema di una wiki interrogabile con immagini; documentazione generale
- Nuovo ADR-0007: convenzione in tre parti (indice con gli id delle figure,
  catalogo JSON con campi minimi, istruzioni solo-query) e ripartizione dei
  compiti tra wiki, motore e app. Nessuna cartella o formato imposti dall'app.
- ADR-0006, README, ROADMAP, PROJECT_STATE, TESTING: esempi resi generici
  (nessun riferimento a una wiki specifica) e nota sull'assenza di uno standard.
- Test delle immagini con struttura e testi neutri.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 16:11:00 +02:00
davideandClaude Sonnet 5.5 5870de6fda FakeClaude, test per area e renderer diviso in due file
- tests/FakeClaude: eseguibile finto 'claude' configurabile con variabili
  d'ambiente (registro di argomenti/cwd/stdin, output, exit code, ritardo),
  con le prove FK-01…FK-05.
- Test organizzati per livello (Gui/, Process/, Support/) e struttura
  descritta in TESTING.md.
- MarkdownView: la parte sulle immagini passa in MarkdownView.Images.cs
  (partial), senza cambiare il comportamento.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 16:00:25 +02:00
davideandClaude Sonnet 5.5 51993dd0c8 Immagini: percorsi relativi cercati nelle cartelle superiori (radice della wiki)
Il motore scrive i percorsi relativi alla radice del progetto (es. la wiki
cyber-tesi con l'exe in una sottocartella): ora si cercano nella cartella
dell'exe e poi in quelle superiori, vince la più vicina. Il catalogo JSON
delle figure resta del motore; l'app mostra solo le immagini citate.
Test MD-28…MD-30, ADR-0006, README, MVP e AGENT.md aggiornati.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 16:00:25 +02:00
davideandClaude Sonnet 5.5 b3223fb579 Merge del branch dev: immagini nel markdown, progetto di test, struttura src/tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:24:10 +02:00
davideandClaude Sonnet 5.5 15c664007d Immagini nel markdown da file locale e struttura src/tests
Il renderer mostra le immagini ![alt](percorso) da file locali (ADR-0006):
segnaposto [alt] e decodifica in background, ripiego sul testo per URL,
file mancanti, corrotti o troppo grandi. Nessuna richiesta di rete.

Primo progetto di test (xUnit v3 + Avalonia Headless, MD-14 e MD-19…MD-27).
Il codice passa in src/agent-wiki-ui/, c'è la soluzione agent-wiki-ui.slnx;
aggiornati script di publish, comandi e link nei documenti.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:22:39 +02:00
davideandClaude Sonnet 5.5 af171201b5 Documentazione: installazione di Claude Code e build su Linux ARM64
Aggiunti i passi per installare Claude Code da zero su Linux ARM64
(Raspberry Pi) e per installare il .NET SDK 10 e creare l'eseguibile
linux-arm64. Il target resta non verificato su hardware reale.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:13:45 +02:00
38 changed files with 854 additions and 37 deletions

No files matched your search

+13 -13
View File
@@ -14,24 +14,24 @@ agent-wiki-ui (già `LlmRun`; il nome ha un'unica sorgente, vedi [docs/README.md
## Comandi ## Comandi
- Build: `dotnet build` (agent-wiki-ui.csproj, `net10.0`, Avalonia) - Build: `dotnet build` dalla radice (soluzione `agent-wiki-ui.slnx`: app in `src/agent-wiki-ui/`, test in `tests/`; `net10.0`, Avalonia)
- GUI: `dotnet run`; CLI: `dotnet run -- "prompt"` - GUI: `dotnet run --project src/agent-wiki-ui`; CLI: `dotnet run --project src/agent-wiki-ui -- "prompt"`
- Publish single-file (uno per sistema): `scripts/publish.sh` (Linux/WSL/macOS) o `scripts/publish.ps1` (Windows PowerShell): creano `dist/win-x64/` e `dist/linux-x64/` (`dist/` è ignorata da git); i due script vanno tenuti allineati, oppure a mano `dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true` e `-r linux-x64` - Publish single-file (uno per sistema): `scripts/publish.sh` (Linux/WSL/macOS) o `scripts/publish.ps1` (Windows PowerShell): creano `dist/win-x64/` e `dist/linux-x64/` (`dist/` è ignorata da git); i due script vanno tenuti allineati, oppure a mano `dotnet publish src/agent-wiki-ui -c Release -r win-x64 --self-contained -p:PublishSingleFile=true` e `-r linux-x64`
- 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. - Test: `dotnet test` dalla radice (xUnit v3 + Avalonia.Headless con Skia reale; `tests/FakeClaude` è il `claude` finto per i test di processo). Per ora coprono solo le immagini del renderer (MD-14, MD-19…MD-30); il resto è pianificato in docs/TESTING.md (Fase 6). Per verificare la GUI senza schermo si può usare `Avalonia.Headless` con `CaptureRenderedFrame()` in un progetto di prova fuori dal repo.
## Architettura ## Architettura
Decisioni e motivazioni negli ADR ([docs/adr/](docs/adr/)); qui solo la mappa del codice. 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 (`EngineRunner.RunInherited`, motore scelto con `--engine <id>` come primo argomento, default Claude Code); senza avvia Avalonia (`App`). - [Program.cs](src/agent-wiki-ui/Program.cs): top-level statements. Con argomenti è la CLI (`EngineRunner.RunInherited`, motore scelto con `--engine <id>` come primo argomento, default Claude Code); senza avvia Avalonia (`App`).
- [Core/Engine.cs](Core/Engine.cs): astrazione "motore" (ADR-0002): `Engine` (id, nome, eseguibile, modelli, effort, `BuildArgs`, `Parse`, `FetchLatestVersionAsync` per il controllo delle versioni), `ClaudeEngine`, `CodexEngine`, registro `Engines` e ricerca nel PATH (`PathSearch`). Gli elenchi modelli sono fissi, da aggiornare quando ne escono di nuovi; `DefaultModelId` indica il modello più piccolo, scelto all'avvio insieme a effort `low` (se esce un modello più piccolo, aggiornarlo). - [Core/Engine.cs](src/agent-wiki-ui/Core/Engine.cs): astrazione "motore" (ADR-0002): `Engine` (id, nome, eseguibile, modelli, effort, `BuildArgs`, `Parse`, `FetchLatestVersionAsync` per il controllo delle versioni), `ClaudeEngine`, `CodexEngine`, registro `Engines` e ricerca nel PATH (`PathSearch`). Gli elenchi modelli sono fissi, da aggiornare quando ne escono di nuovi; `DefaultModelId` indica il modello più piccolo, scelto all'avvio insieme a effort `low` (se esce un modello più piccolo, aggiornarlo).
- [Core/EngineRunner.cs](Core/EngineRunner.cs): **unico punto di avvio dei motori** (cartella dell'exe, stdin chiuso, `ArgumentList`): CLI con output ereditato, GUI con output catturato e interpretato da `Engine.Parse`, e `RunToolAsync` per i comandi di servizio (`--version`, `update`) con tempo massimo. - [Core/EngineRunner.cs](src/agent-wiki-ui/Core/EngineRunner.cs): **unico punto di avvio dei motori** (cartella dell'exe, stdin chiuso, `ArgumentList`): CLI con output ereditato, GUI con output catturato e interpretato da `Engine.Parse`, e `RunToolAsync` per i comandi di servizio (`--version`, `update`) con tempo massimo.
- [Core/EngineUpdater.cs](Core/EngineUpdater.cs): controllo delle versioni in sola lettura (`CheckAsync`, `IsNewer`) e aggiornamento di un CLI con il suo `update` ufficiale (ADR-0004): versione prima/dopo, esito leggibile, metodo di installazione dedotto dal percorso reale e comando manuale da suggerire (l'app lo mostra, non lo esegue). - [Core/EngineUpdater.cs](src/agent-wiki-ui/Core/EngineUpdater.cs): controllo delle versioni in sola lettura (`CheckAsync`, `IsNewer`) e aggiornamento di un CLI con il suo `update` ufficiale (ADR-0004): versione prima/dopo, esito leggibile, metodo di installazione dedotto dal percorso reale e comando manuale da suggerire (l'app lo mostra, non lo esegue).
- [Core/ChatStore.cs](Core/ChatStore.cs): cronologia, un JSON per chat in `<LocalApplicationData>/<nome-app>/chats/<guid>.json` (nome file = id della chat, validato come Guid prima dell'uso; campi `Engine` e `SessionId` opzionali: i file vecchi sono chat di Claude Code). - [Core/ChatStore.cs](src/agent-wiki-ui/Core/ChatStore.cs): cronologia, un JSON per chat in `<LocalApplicationData>/<nome-app>/chats/<guid>.json` (nome file = id della chat, validato come Guid prima dell'uso; campi `Engine` e `SessionId` opzionali: i file vecchi sono chat di Claude Code).
- [Core/AppInfo.cs](Core/AppInfo.cs): nome dell'app, letto dall'`AssemblyName` del csproj (unica sorgente). - [Core/AppInfo.cs](src/agent-wiki-ui/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, interruttore CLI: cambiare motore apre una nuova chat; aggiornamento dei CLI con un solo aggiornamento alla volta, bloccando invio e cambio motore). - [Gui/MainViewModel.cs](src/agent-wiki-ui/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, interruttore CLI: cambiare motore apre una nuova chat; aggiornamento dei CLI con un solo aggiornamento alla volta, bloccando invio e cambio motore).
- [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/MainWindow.axaml](src/agent-wiki-ui/Gui/MainWindow.axaml) (+ `.cs`): layout XAML con binding compilati. [Gui/App.axaml](src/agent-wiki-ui/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`). - [Gui/MarkdownView.cs](src/agent-wiki-ui/Gui/MarkdownView.cs): renderer markdown proprio (albero Markdig → controlli Avalonia, `DisableHtml`); la parte sulle immagini è in [Gui/MarkdownView.Images.cs](src/agent-wiki-ui/Gui/MarkdownView.Images.cs) (`partial`). Le immagini si mostrano solo da file locale (ADR-0006), mai da URL; i percorsi relativi si cercano nella cartella dell'exe e poi nelle superiori (radice della wiki).
- Dipendenze NuGet: Avalonia (+ Desktop, Themes.Fluent), Markdig. - Dipendenze NuGet: Avalonia (+ Desktop, Themes.Fluent), Markdig.
## Vincoli non ovvi ## Vincoli non ovvi
+20 -6
View File
@@ -11,13 +11,13 @@ Eseguibile C# (.NET 10, Windows e Linux) che lancia Claude Code (`claude -p`) o
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). Il riquadro **Impostazioni** (in alto a destra) permette di scegliere la **CLI** (Claude Code o Codex), il **modello** e l'**effort** di ragionamento. All'avvio, e a ogni cambio di CLI, sono scelti il modello più piccolo (Haiku 5.5 per Claude Code, GPT-5.6 Luna per Codex) ed effort `low`, per risposte rapide e economiche; "Predefinito" = nessun flag, vale la config del motore. Le chat si salvano su disco e l'ultima si riapre all'avvio. Sempre nel riquadro, la sezione **Aggiornamenti** dice se Claude Code e Codex hanno una versione nuova (pulsante evidenziato) e li aggiorna uno alla volta. 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). Il riquadro **Impostazioni** (in alto a destra) permette di scegliere la **CLI** (Claude Code o Codex), il **modello** e l'**effort** di ragionamento. All'avvio, e a ogni cambio di CLI, sono scelti il modello più piccolo (Haiku 5.5 per Claude Code, GPT-5.6 Luna per Codex) ed effort `low`, per risposte rapide e economiche; "Predefinito" = nessun flag, vale la config del motore. Le chat si salvano su disco e l'ultima si riapre all'avvio. Sempre nel riquadro, la sezione **Aggiornamenti** dice se Claude Code e Codex hanno una versione nuova (pulsante evidenziato) e li aggiorna uno alla volta.
``` ```
dotnet run dotnet run --project src/agent-wiki-ui
``` ```
CLI (con argomenti): il prompt viene stampato su terminale, l'exit code è quello del motore (127 se non è nel PATH). Di default usa Claude Code; `--engine codex` usa Codex. CLI (con argomenti): il prompt viene stampato su terminale, l'exit code è quello del motore (127 se non è nel PATH). Di default usa Claude Code; `--engine codex` usa Codex.
``` ```
dotnet run -- "spiega questo progetto" dotnet run --project src/agent-wiki-ui -- "spiega questo progetto"
dotnet run -- --engine codex "spiega questo progetto" dotnet run --project src/agent-wiki-ui -- --engine codex "spiega questo progetto"
``` ```
## Creare gli eseguibili (Windows e Linux) ## Creare gli eseguibili (Windows e Linux)
@@ -25,7 +25,7 @@ Ogni eseguibile è un **unico file** che include il runtime .NET: chi lo usa non
### 1. Installare il .NET SDK 10 ### 1. Installare il .NET SDK 10
- **Windows** (PowerShell o Prompt dei comandi): `winget install Microsoft.DotNet.SDK.10`, oppure il programma di installazione da [dotnet.microsoft.com/download](https://dotnet.microsoft.com/download). - **Windows** (PowerShell o Prompt dei comandi): `winget install Microsoft.DotNet.SDK.10`, oppure il programma di installazione da [dotnet.microsoft.com/download](https://dotnet.microsoft.com/download).
- **Linux / WSL / macOS**: pacchetto della distribuzione o script ufficiale da [dotnet.microsoft.com/download](https://dotnet.microsoft.com/download). - **Linux / WSL / macOS**: pacchetto della distribuzione o script ufficiale da [dotnet.microsoft.com/download](https://dotnet.microsoft.com/download). Funziona anche su ARM64 (Raspberry Pi): `curl -sSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh && bash /tmp/dotnet-install.sh --channel 10.0`, poi `export DOTNET_ROOT=$HOME/.dotnet PATH=$PATH:$HOME/.dotnet` (da mettere in `~/.bashrc`).
- Controllo: `dotnet --version` deve stampare `10.x`. - Controllo: `dotnet --version` deve stampare `10.x`.
### 2. Creare gli eseguibili ### 2. Creare gli eseguibili
@@ -45,11 +45,11 @@ Risultato (circa 47 MB per file), in `dist/`, ignorata da git:
| Windows x64 | `dist/win-x64/agent-wiki-ui.exe` | | Windows x64 | `dist/win-x64/agent-wiki-ui.exe` |
| Linux x64 | `dist/linux-x64/agent-wiki-ui` | | Linux x64 | `dist/linux-x64/agent-wiki-ui` |
Altri identificatori (per esempio `win-arm64`, `linux-arm64`) si possono provare allo stesso modo, ma non sono verificati. Lo script è solo una comodità: il comando sotto fa la stessa cosa. Altri identificatori (per esempio `win-arm64`, `linux-arm64`) si passano allo script allo stesso modo (`scripts/publish.sh linux-arm64`, risultato in `dist/linux-arm64/`), ma non sono verificati. Su un Raspberry Pi servono anche i motori: per Claude Code vedi [docs/INSTALL.md](docs/INSTALL.md). Lo script è solo una comodità: il comando sotto fa la stessa cosa.
**Senza script** (identico su qualsiasi sistema; sostituisci `<rid>` con `win-x64` o `linux-x64`): **Senza script** (identico su qualsiasi sistema; sostituisci `<rid>` con `win-x64` o `linux-x64`):
``` ```
dotnet publish -c Release -r <rid> --self-contained -p:PublishSingleFile=true -o dist/<rid> dotnet publish src/agent-wiki-ui -c Release -r <rid> --self-contained -p:PublishSingleFile=true -o dist/<rid>
``` ```
Senza `-o` il risultato è in `bin/Release/net10.0/<rid>/publish/`. Senza `-o` il risultato è in `bin/Release/net10.0/<rid>/publish/`.
@@ -61,6 +61,20 @@ Verificato: da Linux/WSL entrambi gli eseguibili; da Windows nativo (PowerShell
- Al primo avvio le librerie native (Skia, HarfBuzz) vengono estratte in una cartella temporanea: il primo avvio è un po' più lento. - Al primo avvio le librerie native (Skia, HarfBuzz) vengono estratte in una cartella temporanea: il primo avvio è un po' più lento.
- I motori (`claude`, `codex`) devono essere installati e nel PATH dello stesso sistema: vedi [docs/INSTALL.md](docs/INSTALL.md). - I motori (`claude`, `codex`) devono essere installati e nel PATH dello stesso sistema: vedi [docs/INSTALL.md](docs/INSTALL.md).
## Immagini nelle risposte (wiki con catalogo di figure)
Non esiste (ancora) uno standard per cartella e catalogo delle immagini: la struttura qui sotto è solo un **esempio** e può cambiare. L'app non la conosce: mostra il file indicato dal percorso scritto nella risposta, ovunque stia.
Se la risposta contiene `![didascalia](percorso)` e il file esiste, la chat mostra l'immagine; altrimenti compare il testo `[didascalia]`. Gli URL `http(s)` non si scaricano. Un percorso relativo si cerca nella cartella dell'eseguibile e poi in quelle superiori, quindi funziona con una **wiki** (cartella di pagine markdown con un indice, un catalogo delle figure e le immagini) anche se l'eseguibile sta in una sottocartella dedicata alle sole query:
```
<wiki>/ radice del progetto, con CLAUDE.md (schema completo della wiki)
index.md indice delle pagine, con gli id delle figure di ogni pagina
<catalogo-figure>.json catalogo: metadati di ogni figura (file, didascalia, posizione, avvertenze)
<cartella-immagini>/<file> le immagini
query/CLAUDE.md istruzioni solo-query (sola lettura, instradamento per indici, regole per le figure)
query/agent-wiki-ui l'eseguibile, da avviare da qui: il motore legge sia query/CLAUDE.md sia quello della radice
```
Il catalogo lo legge il motore, non l'app: il `CLAUDE.md` chiede di trovare la figura nel catalogo e di citarla con un percorso relativo alla radice (`![Figura 1](<cartella-immagini>/<file>)`), con sotto la didascalia, la posizione e il link alla nota della figura. L'app mostra l'immagine e lascia la didascalia come testo markdown. Dettagli: [ADR-0006](docs/adr/0006-immagini-nel-markdown.md). Per preparare una wiki alle query (indice con la mappa delle figure, catalogo, istruzioni solo-query) c'è una convenzione, [ADR-0007](docs/adr/0007-schema-wiki-per-query-con-immagini.md),.
## Piattaforme ## Piattaforme
Windows e Linux con lo stesso codice ([ADR-0001](docs/adr/0001-gui-multipiattaforma-avalonia.md)). Motori supportati: Claude Code e Codex CLI, già installati da terminale; Antigravity CLI è pianificato: [ADR-0002](docs/adr/0002-motori-cli-claude-codex-antigravity.md). Windows e Linux con lo stesso codice ([ADR-0001](docs/adr/0001-gui-multipiattaforma-avalonia.md)). Motori supportati: Claude Code e Codex CLI, già installati da terminale; Antigravity CLI è pianificato: [ADR-0002](docs/adr/0002-motori-cli-claude-codex-antigravity.md).
+9
View File
@@ -0,0 +1,9 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/agent-wiki-ui/agent-wiki-ui.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/agent-wiki-ui.Tests/agent-wiki-ui.Tests.csproj" />
<Project Path="tests/FakeClaude/FakeClaude.csproj" />
</Folder>
</Solution>
+12 -1
View File
@@ -8,7 +8,7 @@ Il motore va installato **nello stesso ambiente in cui gira l'app**:
| Dove avvii l'app | Dove installare il motore | | Dove avvii l'app | Dove installare il motore |
|---|---| |---|---|
| Linux nativo | nel terminale Linux | | Linux nativo | nel terminale Linux |
| WSL (anche con `dotnet run`) | nel terminale della distribuzione WSL | | WSL (anche con `dotnet run --project src/agent-wiki-ui`) | nel terminale della distribuzione WSL |
| Windows (`.exe`) | in Windows (PowerShell), non in WSL | | Windows (`.exe`) | in Windows (PowerShell), non in WSL |
Dentro WSL il PATH di Windows è visibile (compare `/mnt/c/…`): se in WSL non c'è un `claude` o un `codex`, il comando può risolvere nella versione Windows. Controllalo con `command -v claude` e `command -v codex`. Dentro WSL il PATH di Windows è visibile (compare `/mnt/c/…`): se in WSL non c'è un `claude` o un `codex`, il comando può risolvere nella versione Windows. Controllalo con `command -v claude` e `command -v codex`.
@@ -31,6 +31,17 @@ Dentro WSL il PATH di Windows è visibile (compare `/mnt/c/…`): se in WSL non
L'installazione nativa non mostra avanzamento durante il download. A fine installazione apri un **nuovo** terminale. L'installazione nativa non mostra avanzamento durante il download. A fine installazione apri un **nuovo** terminale.
**Da zero su Linux, anche ARM64 (per esempio Raspberry Pi 5 con Raspberry Pi OS a 64 bit).** Il metodo nativo riconosce da solo l'architettura (`uname -m` deve dare `aarch64` o `x86_64`):
```
sudo apt install -y curl # se manca
curl -fsSL https://claude.ai/install.sh | bash
# apri un nuovo terminale, poi:
command -v claude # atteso: ~/.local/bin/claude
claude --version
claude # primo avvio: accesso nel browser
```
Su un Raspberry senza schermo l'accesso nel browser si fa da un altro dispositivo, seguendo l'indirizzo che `claude` stampa nel terminale (per esempio via SSH); in alternativa usa una API key o un provider cloud, vedi "Accesso". Se `~/.local/bin` non è nel PATH aggiungi `export PATH="$HOME/.local/bin:$PATH"` a `~/.bashrc`. Con la sola architettura ARM64 e Debian 10+ i requisiti sono soddisfatti; il metodo npm è l'alternativa se lo script nativo non funziona. Non provato su un Raspberry reale: le istruzioni vengono dalla guida ufficiale.
**Accesso**: esegui `claude` e segui le istruzioni nel browser. Serve un account Pro, Max, Team, Enterprise o Console (il piano gratuito di claude.ai non include Claude Code). In alternativa Amazon Bedrock, Google Cloud o Microsoft Foundry. **Accesso**: esegui `claude` e segui le istruzioni nel browser. Serve un account Pro, Max, Team, Enterprise o Console (il piano gratuito di claude.ai non include Claude Code). In alternativa Amazon Bedrock, Google Cloud o Microsoft Foundry.
**Verifica** **Verifica**
+3 -3
View File
@@ -21,7 +21,7 @@ Senza argomenti si apre una finestra Avalonia (C# + XAML, nessun HTML) in stile
**Struttura della finestra** **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". - **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. - **Conversazione**: messaggi alternati utente/Claude. Le risposte sono renderizzate in markdown: titoli, liste, tabelle, citazioni, blocchi di codice, grassetto/corsivo/barrato, codice inline, link, immagini. Le immagini `![alt](percorso)` si mostrano se il percorso è locale (assoluto, `file://` o relativo: cercato nella cartella dell'exe e poi nelle cartelle superiori, cioè nella radice del progetto del motore) e il file è un'immagine leggibile entro i limiti (caricata in background: prima compare `[alt]`, poi l'immagine); altrimenti, e sempre per gli URL `http(s)`, compare il testo alternativo `[alt]` e non si scarica nulla ([ADR-0006](adr/0006-immagini-nel-markdown.md)). Il rendering è un renderer proprio ([MarkdownView.cs](../src/agent-wiki-ui/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): solo logo, titolo e sottotitolo (nessun suggerimento precompilato). - **Schermata iniziale** (chat vuota): solo logo, titolo e sottotitolo (nessun suggerimento precompilato).
- **Composer** (in basso, scheda arrotondata): campo di testo che cresce fino a ~8 righe e pulsante Invia circolare con icona. `Invio` invia, `Maiusc+Invio` va a capo. Hint sulla tastiera sotto la scheda. Non contiene più i selettori di modello ed effort. - **Composer** (in basso, scheda arrotondata): campo di testo che cresce fino a ~8 righe e pulsante Invia circolare con icona. `Invio` invia, `Maiusc+Invio` va a capo. Hint sulla tastiera sotto la scheda. Non contiene più i selettori di modello ed effort.
- **Impostazioni** (in alto a destra, pulsante con icona a cursori, simmetrico a quello della barra laterale): aprono un riquadro a comparsa con l'interruttore **CLI** (Claude Code, Codex; un motore non installato è mostrato "non installato" e non è selezionabile; disabilitato durante l'attesa), poi **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). **Valori iniziali**: il modello più piccolo del motore (Claude Code: Haiku 5.5; Codex: GPT-5.6 Luna) ed effort `low`, per risposte rapide e poco costose; sono quelli che si ritrovano all'avvio e a ogni cambio di CLI. Si chiude con `Esc` o con un clic fuori; le scelte restano valide per i messaggi successivi (non sono ancora salvate su disco). Passati come `--model` / `--effort`; con "Predefinito" il flag viene omesso. Solo GUI. - **Impostazioni** (in alto a destra, pulsante con icona a cursori, simmetrico a quello della barra laterale): aprono un riquadro a comparsa con l'interruttore **CLI** (Claude Code, Codex; un motore non installato è mostrato "non installato" e non è selezionabile; disabilitato durante l'attesa), poi **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). **Valori iniziali**: il modello più piccolo del motore (Claude Code: Haiku 5.5; Codex: GPT-5.6 Luna) ed effort `low`, per risposte rapide e poco costose; sono quelli che si ritrovano all'avvio e a ogni cambio di CLI. Si chiude con `Esc` o con un clic fuori; le scelte restano valide per i messaggi successivi (non sono ancora salvate su disco). Passati come `--model` / `--effort`; con "Predefinito" il flag viene omesso. Solo GUI.
@@ -35,7 +35,7 @@ Senza argomenti si apre una finestra Avalonia (C# + XAML, nessun HTML) in stile
- 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. - 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) ### Design (UI/UX)
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). 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](../src/agent-wiki-ui/Gui/App.axaml).
- **Tema**: segue il sistema (chiaro/scuro) tramite `RequestedThemeVariant="Default"`. - **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. - **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**: 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). - **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).
@@ -50,7 +50,7 @@ Le scelte architetturali sono negli [ADR](README.md#indice-adr). Sintesi:
- `ProcessStartInfo.ArgumentList` per passare il prompt (niente problemi di quoting). - `ProcessStartInfo.ArgumentList` per passare il prompt (niente problemi di quoting).
- `claude` è un eseguibile 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. - Working dir: `AppContext.BaseDirectory`, non la cwd del chiamante.
- L'avvio dei motori è in un solo punto ([Core/EngineRunner.cs](../Core/EngineRunner.cs)), usato da CLI e GUI; ogni motore descrive argomenti e parser in [Core/Engine.cs](../Core/Engine.cs). - L'avvio dei motori è in un solo punto ([Core/EngineRunner.cs](../src/agent-wiki-ui/Core/EngineRunner.cs)), usato da CLI e GUI; ogni motore descrive argomenti e parser in [Core/Engine.cs](../src/agent-wiki-ui/Core/Engine.cs).
## Fuori scope (MVP) ## 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, supporto macOS. I test automatici sono pianificati: [TESTING.md](TESTING.md). 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).
+2
View File
@@ -42,6 +42,8 @@ Da [MVP.md](MVP.md). Restano non spuntati finché non hanno test automatici (reg
| [0003](adr/0003-strategia-di-test.md) Strategia di test | Proposta | | [0003](adr/0003-strategia-di-test.md) Strategia di test | Proposta |
| [0004](adr/0004-aggiornamento-dei-cli.md) Aggiornamento dei CLI | Proposta | | [0004](adr/0004-aggiornamento-dei-cli.md) Aggiornamento dei CLI | Proposta |
| [0005](adr/0005-windows-senza-console.md) Windows senza console | Proposta | | [0005](adr/0005-windows-senza-console.md) Windows senza console | Proposta |
| [0006](adr/0006-immagini-nel-markdown.md) Immagini nel markdown (solo file locali) | Proposta (implementata, test verdi) |
| [0007](adr/0007-schema-wiki-per-query-con-immagini.md) Schema di una wiki interrogabile, con immagini | Proposta (solo documentazione; nessun codice) |
## Per chiudere l'MVP ## Per chiudere l'MVP
Proposta, derivata dai punti aperti sopra; da confermare. Proposta, derivata dai punti aperti sopra; da confermare.
+3 -1
View File
@@ -17,7 +17,7 @@ Indice e regole. Ogni informazione vive in **un solo documento**; gli altri la r
Il nome è **`agent-wiki-ui`** (prima `LlmRun`). Questa sezione è l'unica sorgente: gli altri doc scrivono "l'app" o `<nome-app>`. Il nome è **`agent-wiki-ui`** (prima `LlmRun`). Questa sezione è l'unica sorgente: gli altri doc scrivono "l'app" o `<nome-app>`.
Se il nome cambia di nuovo, aggiornare solo questi punti: 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 `<nome-app>.exe` / `<nome-app>` - [ ] `AssemblyName` in [../src/agent-wiki-ui/agent-wiki-ui.csproj](../src/agent-wiki-ui/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 `<nome-app>.exe` / `<nome-app>`
- [ ] `RootNamespace` e namespace C# (`AgentWikiUi.*`, anche in `x:Class` e `xmlns` dei file `.axaml`): il nome del namespace non può avere trattini - [ ] `RootNamespace` e namespace C# (`AgentWikiUi.*`, anche in `x:Class` e `xmlns` dei file `.axaml`): il nome del namespace non può avere trattini
- [ ] **Cartella dati** `<LocalApplicationData>/<nome-app>/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` - [ ] **Cartella dati** `<LocalApplicationData>/<nome-app>/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 [../AGENT.md](../AGENT.md) (nome del csproj nei comandi) - [ ] Titolo di [../README.md](../README.md) e riga iniziale di [../AGENT.md](../AGENT.md) (nome del csproj nei comandi)
@@ -52,3 +52,5 @@ Non si scrive nulla nei puntatori: ogni modifica va in AGENT.md. Per un altro st
| [0003](adr/0003-strategia-di-test.md) | Strategia di test e regola "nuova funzione = nuovi test" | Proposta | | [0003](adr/0003-strategia-di-test.md) | Strategia di test e regola "nuova funzione = nuovi test" | Proposta |
| [0004](adr/0004-aggiornamento-dei-cli.md) | Aggiornamento dei CLI dalle Impostazioni | Proposta | | [0004](adr/0004-aggiornamento-dei-cli.md) | Aggiornamento dei CLI dalle Impostazioni | Proposta |
| [0005](adr/0005-windows-senza-console.md) | Windows: eseguibile senza finestra di console | Proposta | | [0005](adr/0005-windows-senza-console.md) | Windows: eseguibile senza finestra di console | Proposta |
| [0006](adr/0006-immagini-nel-markdown.md) | Immagini nel markdown: solo file locali | Proposta |
| [0007](adr/0007-schema-wiki-per-query-con-immagini.md) | Schema di una wiki interrogabile, con immagini | Proposta |
+4 -2
View File
@@ -22,6 +22,8 @@ Dettagli dell'MVP in [MVP.md](MVP.md). Stadio attuale e debiti: [PROJECT_STATE.m
## Fase 3 — Oltre ## Fase 3 — Oltre
- [x] GUI: cronologia chat (salvataggio JSON, barra laterale, riapertura con `--resume`, eliminazione) - [x] GUI: cronologia chat (salvataggio JSON, barra laterale, riapertura con `--resume`, eliminazione)
- [ ] GUI: streaming della risposta (`--output-format stream-json`), stop, rinomina/ricerca chat - [ ] GUI: streaming della risposta (`--output-format stream-json`), stop, rinomina/ricerca chat
- [x] GUI: immagini nel markdown da percorso locale, con ripiego sul testo alternativo ([ADR-0006](adr/0006-immagini-nel-markdown.md); test MD-14, MD-19…MD-30); immagini remote rimandate
- [x] Schema di una wiki interrogabile con immagini: convenzione per le istruzioni solo-query, l'indice e il catalogo ([ADR-0007](adr/0007-schema-wiki-per-query-con-immagini.md)); da provare con la chat reale su una wiki di esempio
- [ ] Config file accanto all'exe - [ ] Config file accanto all'exe
- [ ] Test automatici e CI (vedi Fase 6) - [ ] Test automatici e CI (vedi Fase 6)
@@ -64,9 +66,9 @@ Decisione e motivazioni: [ADR-0001](adr/0001-gui-multipiattaforma-avalonia.md).
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. 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" - [ ] 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` - [x] Ristrutturare in `src/` e `tests/` con soluzione unica e `InternalsVisibleTo`
- [ ] Predisposizioni di testabilità: `ChatStore` a istanza, `IClaudeRunner` - [ ] Predisposizioni di testabilità: `ChatStore` a istanza, `IClaudeRunner`
- [ ] `FakeClaude`: eseguibile finto per i test di processo - [x] `FakeClaude`: eseguibile finto per i test di processo (`tests/FakeClaude`, prove FK-01…FK-05)
- [ ] Test `Core/` (CL, ST) e `MainViewModel` (VM) - [ ] Test `Core/` (CL, ST) e `MainViewModel` (VM)
- [ ] Test del renderer markdown (MD) e della GUI headless (UI) - [ ] 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) - [ ] Test end-to-end e smoke di publish per `win-x64` e `linux-x64` (PUB)
+41 -6
View File
@@ -21,20 +21,34 @@ ADR (se serve) → MVP.md (requisito e criterio) → riga in questa matrice →
| **P** — processo | avvio reale di un processo (`Claude`) | `FakeClaude` nel PATH | | **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` | | **E** — end-to-end | l'eseguibile pubblicato (CLI, 127, exit code) | publish + processo figlio + `FakeClaude` |
## Struttura prevista ## Struttura
``` ```
agent-wiki-ui.slnx agent-wiki-ui.slnx
src/agent-wiki-ui/ app (Core/, Gui/, Program.cs) 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/agent-wiki-ui.Tests/ Support/ (TestApp, PngFactory), Gui/ (R, G), Process/ (P), Core/ (U, quando arrivano), E2E/ (E)
tests/FakeClaude/ eseguibile finto: registra argv/cwd/stdin; testo, stderr, exit code e ritardo da variabili d'ambiente tests/FakeClaude/ eseguibile finto `claude` (vedi sotto)
``` ```
Una cartella per livello, creata quando c'è il primo test; il livello si dichiara anche con `[Trait("Category", "Unit" | "Gui" | "Process" | "E2E")]`.
### FakeClaude
Piccolo eseguibile (`AssemblyName` `claude`, nel PATH dei test) che prende il posto del vero motore. Un test lo configura con variabili d'ambiente del processo che avvia:
| Variabile | Effetto |
|---|---|
| `FAKE_CLAUDE_LOG` | file in cui accoda una riga JSON per invocazione: `{"args": [...], "cwd": "...", "stdin": "..."}` |
| `FAKE_CLAUDE_OUT` | testo scritto su stdout (anche per `--version`) |
| `FAKE_CLAUDE_ERR` | testo scritto su stderr |
| `FAKE_CLAUDE_EXIT` | exit code (predefinito 0) |
| `FAKE_CLAUDE_DELAY_MS` | attesa prima di rispondere |
Legge stdin fino alla fine e lo registra: se il chiamante non lo chiude, il processo non termina (così CL-05 è verificabile). Il percorso del file compilato lo fornisce l'helper di test `FakeClaudePath`.
## Predisposizioni di testabilità (prerequisito dei test) ## Predisposizioni di testabilità (prerequisito dei test)
- [ ] `ChatStore` come istanza con cartella iniettata (oggi statico su `LocalApplicationData`) - [ ] `ChatStore` come istanza con cartella iniettata (oggi statico su `LocalApplicationData`)
- [ ] `IEngineRunner` dietro `Core/EngineRunner.cs`, iniettato nel `MainViewModel` - [ ] `IEngineRunner` dietro `Core/EngineRunner.cs`, iniettato nel `MainViewModel`
- [x] `EngineUpdater.CheckAsync` accetta un `HttpClient`: i test usano un gestore finto (rete assente, risposta non valida, versioni di prova) - [x] `EngineUpdater.CheckAsync` accetta un `HttpClient`: i test usano un gestore finto (rete assente, risposta non valida, versioni di prova)
- [ ] `InternalsVisibleTo` verso il progetto di test - [x] `InternalsVisibleTo` verso il progetto di test
- [ ] Spostamento in `src/` e `tests/` con soluzione unica - [x] Spostamento in `src/agent-wiki-ui/` e `tests/` con soluzione unica `agent-wiki-ui.slnx`
## Soglie di copertura (la build fallisce sotto soglia) ## Soglie di copertura (la build fallisce sotto soglia)
| Area | Righe | Rami | | Area | Righe | Rami |
@@ -163,7 +177,19 @@ Si provano con `FakeClaude`/`FakeCodex` che rispondono a `--version` e `update`;
| MD-11 | Link: colore d'accento e sottolineatura; il clic apre solo `http(s)` | R | `Link_http_https` | [ ] | | 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-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-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-14 | Immagini con URL `http(s)` o altro schema: si mostra il testo alternativo, nessuna richiesta di rete | R | `Immagini_remote_come_testo` | [x] |
| MD-19 | Immagine con percorso assoluto di un file png valido ⇒ controllo immagine con le dimensioni attese (ADR-0006) | R | `Immagine_percorso_assoluto` | [x] |
| MD-20 | Percorso relativo risolto rispetto a `AppContext.BaseDirectory`; anche URI `file://` | R | `Immagine_percorso_relativo_e_file_uri` | [x] |
| MD-21 | File mancante, illeggibile, corrotto o con estensione non ammessa ⇒ testo alternativo, nessuna eccezione | R | `Immagine_non_valida_ripiego` | [x] |
| MD-22 | File oltre il limite di 20 MB ⇒ testo alternativo, file non decodificato | R | `Immagine_oltre_limite` | [x] |
| MD-23 | Immagine più larga della colonna ⇒ ridotta in larghezza mantenendo le proporzioni; mai ingrandita oltre l'originale | R | `Immagine_larghezza_massima` | [x] |
| MD-24 | Il testo alternativo è nome accessibile e suggerimento dell'immagine | R | `Immagine_testo_alternativo_accessibile` | [x] |
| MD-25 | Immagine dentro un link, in una lista o in una tabella ⇒ resa senza rompere intervalli dei link (MD-13) né il layout | R | `Immagine_in_contesti_annidati` | [x] |
| MD-26 | Cambio di tema con immagini presenti ⇒ vista ricostruita, immagini ancora mostrate | R | `Immagine_cambio_tema` | [x] |
| MD-27 | Il caricamento è in background: subito dopo il rendering c'è il segnaposto `[alt]` e un'immagine in attesa; a fine decodifica compare l'immagine; un file corrotto lascia `[alt]` | R | `Immagine_caricamento_in_background` | [x] |
| MD-28 | Percorso relativo non presente nella cartella dell'exe ma in una cartella superiore (radice del progetto, es. `figure/<file>` con l'exe in una sottocartella) ⇒ immagine mostrata | R | `Immagine_percorso_relativo_alla_radice` | [x] |
| MD-29 | Se il file esiste in più cartelle, vince la più vicina all'exe; se non esiste in nessuna ⇒ testo alternativo | R | `Immagine_cartella_piu_vicina` | [x] |
| MD-30 | Struttura tipo wiki (catalogo JSON + cartella immagini + risposta con immagine, didascalia e link `[[fig-NNN]]` sotto): l'immagine compare e la didascalia resta testo; l'app non legge il JSON | R | `Immagine_risposta_stile_wiki` | [x] |
| MD-15 | HTML grezzo disabilitato: i tag compaiono come testo, non come elementi | R | `Html_disabilitato` | [ ] | | 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-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-17 | Markdown malformato o blocchi sconosciuti non fanno crashare | R | `Markdown_malformato` | [ ] |
@@ -230,6 +256,15 @@ Per **ogni** motore, la stessa batteria di contratto:
| EN-17 | CLI `--engine <id> "prompt"`; id sconosciuto o prompt mancante ⇒ uso su stderr ed exit 2; senza `--engine` ⇒ Claude Code | E | [ ] | | EN-17 | CLI `--engine <id> "prompt"`; id sconosciuto o prompt mancante ⇒ uso su stderr ed exit 2; senza `--engine` ⇒ Claude Code | E | [ ] |
| EN-18 | Rilevamento nel PATH: nome esatto su Linux, `.exe` su Windows; cartella inesistente nel PATH ignorata | U | [ ] | | EN-18 | Rilevamento nel PATH: nome esatto su Linux, `.exe` su Windows; cartella inesistente nel PATH ignorata | U | [ ] |
### FakeClaude — `tests/FakeClaude`
| ID | Requisito | Liv. | Test | |
|---|---|---|---|---|
| FK-01 | Registra argomenti (anche con virgolette e spazi, uno per elemento), cartella di lavoro e stdin | P | `Registra_argomenti_cwd_e_stdin` | [x] |
| FK-02 | Scrive su stdout e stderr il testo configurato | P | `Output_e_stderr_configurati` | [x] |
| FK-03 | L'exit code configurato è quello del processo | P | `ExitCode_configurato` | [x] |
| FK-04 | Il ritardo configurato è rispettato | P | `Ritardo_configurato` | [x] |
| FK-05 | Termina solo dopo la chiusura di stdin | P | `Attende_la_chiusura_di_stdin` | [x] |
## Come si eseguono (a implementazione avvenuta) ## Come si eseguono (a implementazione avvenuta)
Comandi previsti, da confermare con ADR-0003 → "Da verificare": Comandi previsti, da confermare con ADR-0003 → "Da verificare":
- Tutti i test: `dotnet test` - Tutti i test: `dotnet test`
+47
View File
@@ -0,0 +1,47 @@
# ADR-0006 — Immagini nel markdown: solo file locali
- **Stato**: Proposta
- **Data**: 2026-10-15
- **Collegato a**: [ADR-0001](0001-gui-multipiattaforma-avalonia.md) (GUI), [ADR-0003](0003-strategia-di-test.md) (test)
## Contesto
Il renderer markdown ([Gui/MarkdownView.cs](../../src/agent-wiki-ui/Gui/MarkdownView.cs)) trasforma `![alt](percorso)` nel solo testo alternativo `[alt]`: non carica nulla. Un motore (Claude Code, Codex) può però produrre o citare un'immagine su disco (un grafico, uno screenshot) e rispondere con `![descrizione](percorso)`. Se il percorso è noto e il file esiste, l'utente si aspetta di vederla nella chat. Oggi l'app contatta la rete solo per il controllo delle versioni ([AGENT.md](../../AGENT.md), "Vincoli non ovvi"); aprire le immagini remote cambierebbe questo vincolo.
## Criteri di decisione
1. Mostrare l'immagine quando il percorso è locale e il file è leggibile.
2. Nessun accesso alla rete oltre a quello già previsto.
3. Nessun crash e nessun blocco della chat per file mancanti, enormi o corrotti: ripiego sul testo alternativo di oggi.
4. Coerenza con il resto del renderer: colori dal tema, testo selezionabile, accessibilità.
## Opzioni considerate
| Opzione | Esito | Motivo |
|---|---|---|
| Lasciare il testo `[alt]` | Scartata | Il percorso noto resta inutilizzato. |
| **Solo percorsi locali** | **Scelta** | Soddisfa il caso d'uso, nessuna rete, sola lettura. |
| Locali + URL `http(s)` | Rimandata | Una risposta del modello farebbe partire richieste di rete verso indirizzi scelti dal modello (tracciamento, indirizzo IP dell'utente esposto). Si può riprendere con un nuovo ADR e un consenso esplicito. |
## Decisione
- `![alt](destinazione)` mostra l'immagine se la destinazione è un **percorso locale**: assoluto, relativo oppure un URI `file://`.
- **Percorsi relativi: radice del progetto.** Il motore gira nella cartella dell'exe, ma le istruzioni che segue (il `CLAUDE.md` di una wiki) stanno di solito in una cartella superiore e scrivono i percorsi relativi a *quella* radice (esempio: una wiki con l'exe in `<wiki>/query/`, accanto a un `CLAUDE.md` solo-query, e istruzioni del tipo «`![Figura N](<cartella-immagini>/<file>)`, percorso relativo alla radice del repo»). Un percorso relativo si cerca quindi nella cartella dell'exe e poi in ciascuna cartella superiore, fino alla radice del filesystem; vince la prima in cui il file esiste (la più vicina all'exe).
- **Nessuno standard imposto (prototipo).** L'app non conosce né una cartella delle immagini né un formato di catalogo: la cartella delle immagini e il catalogo sono la convenzione di ciascuna wiki, non un requisito. Cartella e catalogo possono cambiare; la wiki lo dice al motore nelle sue istruzioni, e l'app mostra qualunque file locale raggiungibile con il percorso scritto nella risposta.
- **Cataloghi e metadati restano del motore.** Struttura tipo di una wiki con figure: un catalogo JSON con i metadati delle figure (file, didascalia stampata e generata, posizione, avvertenze) e una cartella delle immagini. Chi legge il catalogo e sceglie le figure è il motore, guidato dalle istruzioni della wiki; l'app non legge né interpreta il catalogo: mostra le immagini che la risposta cita in markdown, e la didascalia, la posizione e le avvertenze che il motore scrive sotto l'immagine sono testo markdown normale.
- Formati: png, jpg/jpeg, gif (primo fotogramma), webp, bmp. Altre estensioni ⇒ ripiego.
- Limiti: file fino a **20 MB**; se l'immagine è più larga di **1600 px** si decodifica a 1600 px di larghezza, per non occupare memoria inutile. In visualizzazione l'immagine non supera la larghezza della colonna dei messaggi (proporzioni mantenute) e non è mai ingrandita oltre l'originale.
- Un paragrafo fatto solo di immagini le mostra come blocchi; un'immagine dentro una riga di testo (o dentro un link) è inline, con larghezza massima di 480 px.
- **Ripiego** sul testo `[alt]` (come oggi) quando: la destinazione è `http(s)` o altro schema, il file manca o non è leggibile, il formato non è ammesso, il file supera il limite, la decodifica fallisce. Nessun errore a schermo, nessuna eccezione.
- **Caricamento in background**: al rendering un controllo di controllo rapido e sincrono (percorso, estensione, esistenza, dimensione) decide se l'immagine è plausibile; se no, compare subito `[alt]`. Se sì, compare `[alt]` come segnaposto e la decodifica avviene su un altro thread; a fine decodifica il segnaposto diventa l'immagine, se la decodifica fallisce resta `[alt]`. Così la GUI non si blocca con file grandi o dischi lenti.
- Accessibilità: l'immagine espone `alt` come nome per le tecnologie assistive e come suggerimento al passaggio del mouse.
- Nessuna azione al clic (non si apre il file). Il pulsante **Copia** della risposta continua a copiare il markdown originale.
## Conseguenze
**Positive**: le immagini prodotte dai motori si vedono nella chat; nessuna richiesta di rete in più; ripiego sicuro.
**Negative**: una risposta può far leggere e mostrare un file immagine qualsiasi dell'utente (sola lettura, solo a schermo, nessun invio altrove); le immagini remote restano non supportate e si vedono come testo.
## Da verificare
- Quali formati decodifica davvero Avalonia 12 con Skia su Windows e Linux (in particolare webp e gif) e se il limite di 20 MB è adeguato.
- Il comportamento dei percorsi Windows (`C:\…`, UNC) e di quelli WSL (`/mnt/c/…`).
- Se la ricerca nelle cartelle superiori debba fermarsi alla prima cartella con un marcatore di progetto (`CLAUDE.md`, `.git`) invece di salire fino alla radice del filesystem.
- Se e quando emergerà uno standard per cataloghi e cartelle delle immagini (nomi, campi minimi come file, didascalia, posizione), valutare un nuovo ADR; per ora è fuori dall'app.
## Riferimenti
- [MVP.md](../MVP.md) → "GUI (chat)"; matrice MD-14, MD-19…MD-30 in [TESTING.md](../TESTING.md).
@@ -0,0 +1,62 @@
# ADR-0007 — Schema di una wiki interrogabile, con immagini (prototipo)
- **Stato**: Proposta
- **Data**: 2026-10-15
- **Collegato a**: [ADR-0006](0006-immagini-nel-markdown.md) (l'app mostra le immagini citate nella risposta)
## Contesto
agent-wiki-ui è il terminale di chat per interrogare una wiki mantenuta da un motore (Claude Code, Codex), per esempio una wiki di studio o di documentazione tecnica: pagine markdown, un indice, un catalogo JSON delle figure con i metadati e una cartella di immagini. Per rispondere in modo strutturato — meglio di un RAG, che recupera frammenti di testo e perde le relazioni — il motore deve sapere **dove cercare** (indice → pagina → tabella o figura) e **come citare** un'immagine perché la chat la stampi. Oggi questo sapere sta in un file scritto per una sola wiki. Non esiste uno standard: la cartella delle immagini e il formato del catalogo cambiano da wiki a wiki, ed è un prototipo.
## Criteri di decisione
1. Un solo posto per i percorsi delle immagini, senza duplicarli a mano.
2. Le mappe sono derivate dal catalogo (si rigenerano), non scritte a mano.
3. Nessuna cartella o nome di file imposti: la wiki dichiara la sua struttura.
4. Degrado sicuro: un percorso sbagliato dà `[alt]` nella chat (ADR-0006), mai un errore.
5. Nessun codice nell'app: la logica sta nelle istruzioni al motore e negli script della wiki.
6. Pochi campi minimi, perché lo standard possa nascere dall'uso.
## Opzioni considerate
| Opzione | Esito | Motivo |
|---|---|---|
| Solo RAG sul testo (embedding di `search_text`) | Scartata come unica via | Perde la struttura che rende affidabile la risposta: dato → pagina unica, avvertenze, difetti noti, figura → file. Resta come ripiego (ricerca testuale). |
| L'app legge il catalogo e inserisce le immagini nelle risposte | Scartata | Lega l'app a un formato non standard e duplica la scelta delle figure, che il motore fa già leggendo la domanda. |
| **Schema in tre parti, letto dal motore; l'app stampa** | **Scelta** | Conserva la struttura, non tocca l'app, si adatta a wiki diverse. |
## Decisione
Una wiki interrogabile ha tre parti. Il formato è una convenzione consigliata, non un requisito dell'app.
1. **Indice** (`index.md`): catalogo delle pagine, una riga per pagina, `- [[pagina]] — sintesi`. Una pagina con figure aggiunge in coda gli id del catalogo: `· figure: fig-026, fig-027`. L'indice non elenca le figure una per una e non contiene percorsi di file.
2. **Catalogo delle figure** (JSON, nome e posizione dichiarati nello schema della wiki). Campi minimi per figura:
- `id` stabile, `label` (ad es. «Figura 26»);
- `caption_printed` (testo della fonte, alla lettera) e, a parte, ogni testo generato dall'immagine, contrassegnato come non verificato;
- `location` (sezione e riga nella fonte);
- `panels[]` con `file` e `path` (relativo alla cartella del catalogo) e una descrizione;
- `quality`: `flags` e `review_notes`.
Indici facoltativi per sezione, composto, tipo e concetto. L'ordine di preferenza per trovare una figura è: indice dell'indice → pagina → `figures` nel frontmatter → indici del catalogo → ricerca testuale.
3. **Istruzioni per le query**: un file `CLAUDE.md` (per Codex `AGENTS.md`) in una cartella dedicata sotto la radice della wiki, da cui si avvia l'app. Si aggiunge a quello della radice e prevale su di esso. Impone la **sola lettura**, instrada le domande per tipo verso l'indice giusto e fissa le regole per le immagini.
**Regole per le immagini nella risposta:**
- Il percorso si ricava da catalogo e `path`, si **verifica che esista** e si scrive **relativo alla radice** della wiki, senza `..` (l'app lo cerca nella sua cartella e poi nelle superiori, ADR-0006).
- Subito sotto: didascalia stampata alla lettera, posizione nella fonte, link alla nota della figura; una riga di avvertenza se `flags` o `review_notes`.
- Ciò che deriva dall'immagine (descrizioni, valori letti da un grafico) si dichiara non verificato. Massimo 3-4 immagini per risposta.
**Percorsi derivati.** Se il catalogo ha percorsi relativi alla propria cartella, la wiki genera con uno script una mappa figura → percorso dalla radice → pagine che la citano (per esempio colonne in più di `figure-indice.md`). Lo script, e un controllo in fase di lint che ogni percorso della mappa esista, sono della wiki, non di questo repo.
**Chi fa cosa**
| Parte | Compito |
|---|---|
| Wiki | Mantiene indice, catalogo e immagini; rigenera la mappa; controlla che i percorsi esistano |
| Motore | Instrada la domanda, legge indici e pagine, sceglie le figure, scrive il markdown con l'immagine e la didascalia |
| App | Stampa il markdown; mostra l'immagine se il file esiste, altrimenti `[alt]` |
## Conseguenze
**Positive**: una risposta ripercorre indice → pagina → figura invece di cercare frammenti; i percorsi stanno in un solo posto e si rigenerano; l'app non cambia; wiki diverse adottano lo stesso modello cambiando pochi campi.
**Negative**: il rispetto dello schema dipende dal motore che segue le istruzioni (può sbagliare percorso: l'app lo mostra come `[alt]`); la wiki deve mantenere lo script e il controllo dei percorsi; lo standard è provvisorio.
## Da verificare
- Che Claude Code, avviato dalla cartella dedicata, legga e rispetti sia il file di quella cartella sia quello della radice (prova con la chat reale su una wiki di esempio).
- L'equivalente per Codex (`AGENTS.md`) e come si comporta con le immagini nei suoi comandi.
- Se il campo minimo `path` debba diventare un percorso dalla radice nel catalogo stesso, eliminando la mappa derivata.
## Riferimenti
- Uso con le immagini: [README.md](../../README.md), sezione «Immagini nelle risposte».
+1 -1
View File
@@ -8,7 +8,7 @@ Set-Location (Split-Path -Parent $PSScriptRoot)
foreach ($rid in $Rids) { foreach ($rid in $Rids) {
Write-Host "== $rid" Write-Host "== $rid"
dotnet publish agent-wiki-ui.csproj -c Release -r $rid --self-contained -p:PublishSingleFile=true -o "dist/$rid" --nologo -v quiet dotnet publish src/agent-wiki-ui/agent-wiki-ui.csproj -c Release -r $rid --self-contained -p:PublishSingleFile=true -o "dist/$rid" --nologo -v quiet
if ($LASTEXITCODE -ne 0) { throw "publish fallito per $rid" } if ($LASTEXITCODE -ne 0) { throw "publish fallito per $rid" }
Get-ChildItem "dist/$rid" -Filter *.pdb -Recurse | Remove-Item # simboli delle librerie native: inutili a chi usa l'app Get-ChildItem "dist/$rid" -Filter *.pdb -Recurse | Remove-Item # simboli delle librerie native: inutili a chi usa l'app
} }
+1 -1
View File
@@ -10,7 +10,7 @@ rids=("$@")
for rid in "${rids[@]}"; do for rid in "${rids[@]}"; do
echo "== $rid" echo "== $rid"
dotnet publish agent-wiki-ui.csproj -c Release -r "$rid" --self-contained -p:PublishSingleFile=true -o "dist/$rid" --nologo -v quiet dotnet publish src/agent-wiki-ui/agent-wiki-ui.csproj -c Release -r "$rid" --self-contained -p:PublishSingleFile=true -o "dist/$rid" --nologo -v quiet
find "dist/$rid" -name '*.pdb' -delete # simboli delle librerie native: inutili a chi usa l'app find "dist/$rid" -name '*.pdb' -delete # simboli delle librerie native: inutili a chi usa l'app
done done
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,124 @@
using Avalonia.Automation;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
namespace AgentWikiUi.Gui;
/// <summary>Immagini nel markdown (ADR-0006): solo file locali, caricamento in background, ripiego sul testo alternativo.</summary>
partial class MarkdownView
{
// Immagini (ADR-0006): solo file locali, entro questi limiti
const long MaxImageBytes = 20L * 1024 * 1024;
const int MaxDecodeWidth = 1600;
const double MaxInlineImageWidth = 480;
static readonly string[] ImageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"];
/// <summary>Immagini ancora in decodifica (ADR-0006); i test attendono che scenda a zero.</summary>
internal int PendingImages { get; private set; }
/// <summary>Cartella da cui si cercano i percorsi relativi delle immagini: l'exe, poi le cartelle superiori (ADR-0006). Modificabile dai test.</summary>
internal string BaseDirectory { get; set; } = AppContext.BaseDirectory;
static string AltText(LinkInline img) => string.Concat(img.OfType<LiteralInline>().Select(l => l.Content.ToString()));
/// <summary>Percorso locale a cui punta la destinazione di un'immagine; nullo per URL remoti o altri schemi.
/// Un percorso relativo si cerca in <paramref name="baseDir"/> e poi nelle cartelle superiori (radice del progetto del motore): vince la prima in cui il file esiste.</summary>
internal static string? ResolveImagePath(string? url, string baseDir)
{
if (string.IsNullOrWhiteSpace(url)) return null;
if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) return uri.IsFile ? uri.LocalPath : null;
var relative = Uri.UnescapeDataString(url);
var first = Path.GetFullPath(relative, baseDir);
for (var dir = new DirectoryInfo(baseDir); dir is not null; dir = dir.Parent)
{
var candidate = Path.GetFullPath(relative, dir.FullName);
if (File.Exists(candidate)) return candidate;
}
return first; // non esiste da nessuna parte: il controllo successivo lo scarta
}
/// <summary>Controllo rapido e sincrono: percorso locale, formato ammesso, file esistente entro il limite. Non decodifica.</summary>
static bool IsImageCandidate(string? path)
{
try
{
if (path is null || !ImageExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase)) return false;
var info = new FileInfo(path);
return info.Exists && info.Length <= MaxImageBytes;
}
catch { return false; }
}
/// <summary>Decodifica l'immagine (chiamata fuori dal thread della GUI); nullo se illeggibile, mai eccezioni.</summary>
static Bitmap? DecodeImage(string path)
{
try
{
using var fs = File.OpenRead(path);
var bmp = new Bitmap(fs);
if (bmp.PixelSize.Width <= MaxDecodeWidth) return bmp;
bmp.Dispose();
fs.Position = 0;
return Bitmap.DecodeToWidth(fs, MaxDecodeWidth);
}
catch { return null; }
}
// Segnaposto `[alt]` che diventa l'immagine a decodifica finita; se la decodifica fallisce resta il testo.
Control ImageHost(string path, string alt, double maxWidth)
{
var host = new Decorator { HorizontalAlignment = HorizontalAlignment.Left, Child = new TextBlock { Text = "[" + alt + "]", TextWrapping = TextWrapping.Wrap } };
PendingImages++;
_ = LoadInto(host, path, alt, maxWidth);
return host;
}
async Task LoadInto(Decorator host, string path, string alt, double maxWidth)
{
try
{
if (await Task.Run(() => DecodeImage(path)) is { } bmp) host.Child = ImageOf(bmp, alt, maxWidth);
}
finally { PendingImages--; }
}
static Image ImageOf(Bitmap bmp, string alt, double maxWidth)
{
var img = new Image
{
Source = bmp, Stretch = Stretch.Uniform, StretchDirection = StretchDirection.DownOnly,
MaxWidth = Math.Min(maxWidth, bmp.Size.Width), HorizontalAlignment = HorizontalAlignment.Left,
};
AutomationProperties.SetName(img, alt);
ToolTip.SetTip(img, alt);
return img;
}
// Paragrafo fatto solo di immagini: blocchi alla larghezza della colonna. Nullo se non è così o nessuna è plausibile.
Control? ImageParagraph(ParagraphBlock p)
{
if (p.Inline is null) return null;
var images = new List<LinkInline>();
foreach (var node in p.Inline)
{
if (node is LinkInline { IsImage: true } img) images.Add(img);
else if (node is not LineBreakInline && !(node is LiteralInline lit && string.IsNullOrWhiteSpace(lit.Content.ToString()))) return null;
}
if (images.Count == 0) return null;
var paths = images.Select(i => ResolveImagePath(i.Url, BaseDirectory)).ToList();
if (!paths.Any(IsImageCandidate)) return null;
var panel = new StackPanel { Spacing = 8 };
for (var i = 0; i < images.Count; i++)
{
var image = images[i];
panel.Children.Add(IsImageCandidate(paths[i]) ? ImageHost(paths[i]!, AltText(image), double.PositiveInfinity)
: new SelectableTextBlock { Text = "[" + AltText(image) + "]", TextWrapping = TextWrapping.Wrap });
}
return panel;
}
}
@@ -13,7 +13,7 @@ using Markdig.Syntax.Inlines;
namespace AgentWikiUi.Gui; namespace AgentWikiUi.Gui;
/// <summary>Mostra markdown come controlli Avalonia (nessun HTML): il testo è Markdig, il rendering è qui.</summary> /// <summary>Mostra markdown come controlli Avalonia (nessun HTML): il testo è Markdig, il rendering è qui.</summary>
class MarkdownView : Decorator partial class MarkdownView : Decorator
{ {
public static readonly StyledProperty<string?> SourceProperty = AvaloniaProperty.Register<MarkdownView, string?>(nameof(Source)); public static readonly StyledProperty<string?> SourceProperty = AvaloniaProperty.Register<MarkdownView, string?>(nameof(Source));
static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().DisableHtml().Build(); static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().DisableHtml().Build();
@@ -47,7 +47,7 @@ class MarkdownView : Decorator
Control RenderBlock(Block block) => block switch 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 }), 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), ParagraphBlock p => ImageParagraph(p) ?? Text(p.Inline),
FencedCodeBlock or CodeBlock => Code(string.Join('\n', ((LeafBlock)block).Lines.Lines.Take(((LeafBlock)block).Lines.Count).Select(l => l.ToString()))), 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) }, 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), ListBlock l => List(l),
@@ -156,7 +156,12 @@ class MarkdownView : Decorator
AddInlines(target, em, inner, links, ref pos); AddInlines(target, em, inner, links, ref pos);
break; break;
case LinkInline { IsImage: true } img: case LinkInline { IsImage: true } img:
Add(target, "[" + string.Concat(img.OfType<LiteralInline>().Select(l => l.Content.ToString())) + "]", st, ref pos); if (ResolveImagePath(img.Url, BaseDirectory) is { } imgPath && IsImageCandidate(imgPath))
{
target.Add(new InlineUIContainer(ImageHost(imgPath, AltText(img), MaxInlineImageWidth)));
pos += 1; // un controllo incorporato occupa una posizione nel testo
}
else Add(target, "[" + AltText(img) + "]", st, ref pos);
break; break;
case LinkInline link: case LinkInline link:
var start = pos; var start = pos;
File renamed without changes.
File renamed without changes.
@@ -16,6 +16,10 @@
<DebugType>none</DebugType> <DebugType>none</DebugType>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="agent-wiki-ui.Tests" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" Version="12.1.4" /> <PackageReference Include="Avalonia" Version="12.1.4" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.4" /> <PackageReference Include="Avalonia.Desktop" Version="12.1.4" />
+13
View File
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Doppio di test di `claude` (vedi docs/TESTING.md → FakeClaude): l'eseguibile si chiama `claude` per poterlo mettere nel PATH -->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<AssemblyName>claude</AssemblyName>
<RootNamespace>FakeClaude</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+15
View File
@@ -0,0 +1,15 @@
using System.Text.Json;
// Configurazione e formato del registro: docs/TESTING.md → FakeClaude.
var stdin = Console.In.ReadToEnd(); // resta in attesa finché il chiamante non chiude stdin
if (Environment.GetEnvironmentVariable("FAKE_CLAUDE_LOG") is { Length: > 0 } log)
File.AppendAllText(log, JsonSerializer.Serialize(new { args, cwd = Directory.GetCurrentDirectory(), stdin }) + "\n");
if (int.TryParse(Environment.GetEnvironmentVariable("FAKE_CLAUDE_DELAY_MS"), out var delay) && delay > 0)
Thread.Sleep(delay);
if (Environment.GetEnvironmentVariable("FAKE_CLAUDE_OUT") is { } o) Console.Out.Write(o);
if (Environment.GetEnvironmentVariable("FAKE_CLAUDE_ERR") is { } e) Console.Error.Write(e);
return int.TryParse(Environment.GetEnvironmentVariable("FAKE_CLAUDE_EXIT"), out var code) ? code : 0;
@@ -0,0 +1,265 @@
using Avalonia;
using Avalonia.Automation;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Headless.XUnit;
using Avalonia.Media.Imaging;
using Avalonia.Styling;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AgentWikiUi.Gui;
namespace AgentWikiUi.Tests;
/// <summary>Immagini nel markdown (ADR-0006): matrice MD-14 e MD-19…MD-26 in docs/TESTING.md.</summary>
[Trait("Category", "Gui")]
public sealed class MarkdownImageTests : IDisposable
{
readonly string dir = Directory.CreateTempSubdirectory("awui-img-").FullName;
readonly List<string> inBase = [];
public void Dispose()
{
Directory.Delete(dir, true);
foreach (var f in inBase) File.Delete(f);
}
string Png(string name, int w, int h)
{
var path = Path.Combine(dir, name);
File.WriteAllBytes(path, PngFactory.Create(w, h));
return path;
}
static (Window Window, MarkdownView View) Show(string markdown, double width = 500, string? baseDir = null)
{
var view = new MarkdownView();
if (baseDir is not null) view.BaseDirectory = baseDir;
var window = new Window { Width = width, Height = 800, Content = view };
window.Show();
view.Source = markdown;
WaitForImages(view);
return (window, view);
}
// il caricamento è in background (ADR-0006): si fa girare la coda della GUI finché tutte le immagini sono decodificate
static void WaitForImages(MarkdownView view)
{
var deadline = DateTime.UtcNow.AddSeconds(10);
while (view.PendingImages > 0 && DateTime.UtcNow < deadline) { Dispatcher.UIThread.RunJobs(); Thread.Sleep(5); }
Dispatcher.UIThread.RunJobs();
(TopLevel.GetTopLevel(view) as Window)?.UpdateLayout();
Assert.Equal(0, view.PendingImages);
}
static List<Image> Images(Visual v) => v.GetVisualDescendants().OfType<Image>().ToList();
static string Text(Visual v) => string.Concat(v.GetVisualDescendants().OfType<TextBlock>()
.Select(t => t.Inlines is { Count: > 0 } ? string.Concat(t.Inlines.OfType<Run>().Select(r => r.Text)) : t.Text));
[AvaloniaTheory]
[InlineData("https://example.com/a.png")]
[InlineData("http://example.com/a.png")]
[InlineData("ftp://example.com/a.png")]
[InlineData("data:image/png;base64,AAAA")]
public void Immagini_remote_come_testo(string url)
{
var (_, view) = Show($"Vedi ![logo]({url}) qui");
Assert.Empty(Images(view));
Assert.Contains("[logo]", Text(view));
}
[AvaloniaFact]
public void Immagine_percorso_assoluto()
{
var path = Png("a.png", 40, 20);
var (_, view) = Show($"![griglia]({path})");
var img = Assert.Single(Images(view));
var bmp = Assert.IsAssignableFrom<Bitmap>(img.Source);
Assert.Equal(new PixelSize(40, 20), bmp.PixelSize);
Assert.Equal(40, img.Bounds.Width, 0.5);
Assert.DoesNotContain("[griglia]", Text(view));
}
[AvaloniaFact]
public void Immagine_percorso_relativo_e_file_uri()
{
var name = $"awui-{Guid.NewGuid():N}.png";
var inBasePath = Path.Combine(AppContext.BaseDirectory, name);
File.WriteAllBytes(inBasePath, PngFactory.Create(10, 10));
inBase.Add(inBasePath);
var (_, relative) = Show($"![r]({name})");
Assert.Single(Images(relative));
var uri = new Uri(Png("b.png", 12, 12)).AbsoluteUri;
var (_, fileUri) = Show($"![u]({uri})");
Assert.Single(Images(fileUri));
}
[AvaloniaTheory]
[InlineData("manca")]
[InlineData("corrotto")]
[InlineData("estensione")]
public void Immagine_non_valida_ripiego(string caso)
{
var path = caso switch
{
"manca" => Path.Combine(dir, "non-esiste.png"),
"corrotto" => Write("rotto.png", "questo non è un png"u8.ToArray()),
_ => Write("dati.txt", PngFactory.Create(8, 8)),
};
var (_, view) = Show($"![alt]({path})");
Assert.Empty(Images(view));
Assert.Contains("[alt]", Text(view));
}
string Write(string name, byte[] bytes)
{
var path = Path.Combine(dir, name);
File.WriteAllBytes(path, bytes);
return path;
}
[AvaloniaFact]
public void Immagine_oltre_limite()
{
var path = Path.Combine(dir, "grande.png");
using (var fs = File.Create(path)) { fs.Write(PngFactory.Create(8, 8)); fs.SetLength(20L * 1024 * 1024 + 1); }
var (_, view) = Show($"![enorme]({path})");
Assert.Empty(Images(view));
Assert.Contains("[enorme]", Text(view));
}
[AvaloniaFact]
public void Immagine_larghezza_massima()
{
var (_, wide) = Show($"![larga]({Png("larga.png", 2000, 100)})", width: 500);
var img = Assert.Single(Images(wide));
Assert.True(img.Bounds.Width <= 500, $"larghezza {img.Bounds.Width}");
Assert.Equal(img.Bounds.Width / 20, img.Bounds.Height, 1); // proporzioni 20:1
var (_, small) = Show($"![piccola]({Png("piccola.png", 50, 50)})", width: 500);
Assert.Equal(50, Assert.Single(Images(small)).Bounds.Width, 0.5); // mai ingrandita
}
[AvaloniaFact]
public void Immagine_testo_alternativo_accessibile()
{
var (_, view) = Show($"![un grafico]({Png("g.png", 10, 10)})");
var img = Assert.Single(Images(view));
Assert.Equal("un grafico", AutomationProperties.GetName(img));
Assert.Equal("un grafico", ToolTip.GetTip(img));
}
[AvaloniaFact]
public void Immagine_in_contesti_annidati()
{
var p = Png("n.png", 10, 10);
var markdown = $"""
Testo ![in riga]({p}) e ancora testo con [un link](https://example.com).
[![dentro link]({p})](https://example.com)
- voce ![in lista]({p})
| a | b |
|---|---|
| ![in tabella]({p}) | x |
""";
var (_, view) = Show(markdown);
Assert.Equal(4, Images(view).Count);
Assert.Contains("un link", Text(view));
}
[AvaloniaFact]
public void Immagine_cambio_tema()
{
var (window, view) = Show($"![t]({Png("t.png", 10, 10)})");
window.RequestedThemeVariant = ThemeVariant.Dark;
WaitForImages(view);
Assert.Single(Images(view));
window.RequestedThemeVariant = ThemeVariant.Light;
WaitForImages(view);
Assert.Single(Images(view));
}
[AvaloniaFact]
public void Immagine_caricamento_in_background()
{
var path = Png("bg.png", 30, 30);
var view = new MarkdownView();
var window = new Window { Width = 500, Height = 800, Content = view };
window.Show();
view.Source = $"![segnaposto]({path})";
// subito dopo il rendering: immagine in attesa e solo il segnaposto (il completamento passa dalla coda della GUI)
Assert.Equal(1, view.PendingImages);
Assert.Empty(Images(view));
Assert.Contains("[segnaposto]", Text(view));
WaitForImages(view);
Assert.Single(Images(view));
Assert.DoesNotContain("[segnaposto]", Text(view));
// file corrotto: la decodifica fallisce e il segnaposto resta
var rotto = Write("rotto2.png", "no"u8.ToArray());
view.Source = $"![guasta]({rotto})";
WaitForImages(view);
Assert.Empty(Images(view));
Assert.Contains("[guasta]", Text(view));
}
// Struttura tipo wiki: <radice>/catalogo.json, <radice>/figure/<file>, exe in <radice>/query/
string Wiki(string name, params string[] files)
{
var root = Path.Combine(dir, name);
Directory.CreateDirectory(Path.Combine(root, "figure"));
Directory.CreateDirectory(Path.Combine(root, "query"));
foreach (var f in files) File.WriteAllBytes(Path.Combine(root, "figure", f), PngFactory.Create(24, 12));
File.WriteAllText(Path.Combine(root, "catalogo.json"), "{\"figures\":[]}");
return root;
}
[AvaloniaFact]
public void Immagine_percorso_relativo_alla_radice()
{
var root = Wiki("wiki-a", "fig1.png");
var (_, view) = Show("![Figura 1](figure/fig1.png)", baseDir: Path.Combine(root, "query"));
Assert.Single(Images(view));
Assert.DoesNotContain("[Figura 1]", Text(view));
}
[AvaloniaFact]
public void Immagine_cartella_piu_vicina()
{
var root = Wiki("wiki-b");
File.WriteAllBytes(Path.Combine(root, "figure", "x.png"), PngFactory.Create(30, 30));
var exeDir = Path.Combine(root, "query");
Directory.CreateDirectory(Path.Combine(exeDir, "figure"));
File.WriteAllBytes(Path.Combine(exeDir, "figure", "x.png"), PngFactory.Create(10, 10));
var (_, view) = Show("![x](figure/x.png)", baseDir: exeDir);
var bmp = Assert.IsAssignableFrom<Bitmap>(Assert.Single(Images(view)).Source);
Assert.Equal(new PixelSize(10, 10), bmp.PixelSize); // vince la cartella più vicina all'exe
var (_, nowhere) = Show("![y](figure/non-esiste.png)", baseDir: exeDir);
Assert.Empty(Images(nowhere));
Assert.Contains("[y]", Text(nowhere));
}
[AvaloniaFact]
public void Immagine_risposta_stile_wiki()
{
var root = Wiki("wiki-c", "schema-a.png");
var risposta = """
Lo schema è in Figura 1.
![Figura 1](figure/schema-a.png)
Figura 1 - schema generale del sistema · §2.1 r.40 · [[fig-001]]
""";
var (_, view) = Show(risposta, baseDir: Path.Combine(root, "query"));
Assert.Single(Images(view));
var text = Text(view);
Assert.Contains("Figura 1 - schema generale", text);
Assert.Contains("[[fig-001]]", text); // il link alla nota resta testo: l'app non legge il catalogo JSON
}
}
@@ -0,0 +1,89 @@
using System.Diagnostics;
using System.Text.Json;
namespace AgentWikiUi.Tests;
/// <summary>Il doppio di test di <c>claude</c> (docs/TESTING.md → FakeClaude, FK-01…FK-05).</summary>
[Trait("Category", "Process")]
public sealed class FakeClaudeTests : IDisposable
{
readonly string dir = Directory.CreateTempSubdirectory("awui-fake-").FullName;
public void Dispose() => Directory.Delete(dir, true);
string LogPath => Path.Combine(dir, "log.jsonl");
Process Start(IEnumerable<string> args, Dictionary<string, string>? env = null, string? cwd = null)
{
var psi = new ProcessStartInfo(FakeClaudePath.Exe)
{
UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true,
WorkingDirectory = cwd ?? dir,
};
foreach (var a in args) psi.ArgumentList.Add(a);
psi.Environment["FAKE_CLAUDE_LOG"] = LogPath;
foreach (var (k, v) in env ?? []) psi.Environment[k] = v;
return Process.Start(psi)!;
}
[Fact]
public void Registra_argomenti_cwd_e_stdin()
{
var cwd = Directory.CreateDirectory(Path.Combine(dir, "lavoro")).FullName;
using var p = Start(["-p", "dì \"ciao\" a tutti", "--model", "m"], cwd: cwd);
p.StandardInput.Write("dati da stdin");
p.StandardInput.Close();
Assert.True(p.WaitForExit(10_000));
using var line = JsonDocument.Parse(File.ReadAllLines(LogPath).Single());
var r = line.RootElement;
Assert.Equal(["-p", "dì \"ciao\" a tutti", "--model", "m"], r.GetProperty("args").EnumerateArray().Select(a => a.GetString()));
Assert.Equal(cwd, r.GetProperty("cwd").GetString());
Assert.Equal("dati da stdin", r.GetProperty("stdin").GetString());
}
[Fact]
public void Output_e_stderr_configurati()
{
using var p = Start([], new() { ["FAKE_CLAUDE_OUT"] = "risposta", ["FAKE_CLAUDE_ERR"] = "avviso" });
p.StandardInput.Close();
Assert.Equal("risposta", p.StandardOutput.ReadToEnd());
Assert.Equal("avviso", p.StandardError.ReadToEnd());
Assert.True(p.WaitForExit(10_000));
}
[Theory]
[InlineData(null, 0)]
[InlineData("3", 3)]
[InlineData("127", 127)]
public void ExitCode_configurato(string? configured, int expected)
{
using var p = Start([], configured is null ? null : new() { ["FAKE_CLAUDE_EXIT"] = configured });
p.StandardInput.Close();
Assert.True(p.WaitForExit(10_000));
Assert.Equal(expected, p.ExitCode);
}
[Fact]
public void Ritardo_configurato()
{
using var p = Start([], new() { ["FAKE_CLAUDE_DELAY_MS"] = "600" });
var sw = Stopwatch.StartNew();
p.StandardInput.Close();
Assert.True(p.WaitForExit(10_000));
Assert.True(sw.ElapsedMilliseconds >= 500, $"terminato dopo {sw.ElapsedMilliseconds} ms");
}
[Fact]
public void Attende_la_chiusura_di_stdin()
{
using var p = Start([]);
try
{
Assert.False(p.WaitForExit(700), "il processo è terminato con stdin ancora aperto");
p.StandardInput.Close();
Assert.True(p.WaitForExit(10_000));
}
finally { if (!p.HasExited) p.Kill(); }
}
}
@@ -0,0 +1,17 @@
namespace AgentWikiUi.Tests;
/// <summary>Percorso del <c>claude</c> finto compilato (tests/FakeClaude), nella stessa configurazione dei test.</summary>
static class FakeClaudePath
{
public static string Exe
{
get
{
// …/tests/agent-wiki-ui.Tests/bin/<config>/net10.0/
var tfm = new DirectoryInfo(AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar));
var config = tfm.Parent!;
var testsRoot = config.Parent!.Parent!.Parent!;
return Path.Combine(testsRoot.FullName, "FakeClaude", "bin", config.Name, tfm.Name, OperatingSystem.IsWindows() ? "claude.exe" : "claude");
}
}
}
@@ -0,0 +1,52 @@
using System.IO.Compression;
namespace AgentWikiUi.Tests;
/// <summary>Scrive PNG minimali (colore pieno) senza dipendere da Avalonia.</summary>
static class PngFactory
{
public static byte[] Create(int width, int height)
{
var raw = new byte[height * (1 + width * 3)];
for (var y = 0; y < height; y++)
for (var x = 0; x < width; x++) raw[y * (1 + width * 3) + 1 + x * 3] = 200; // filtro 0 per riga, canale rosso
using var ms = new MemoryStream();
ms.Write([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
var ihdr = new byte[13];
WriteInt(ihdr, 0, width); WriteInt(ihdr, 4, height);
ihdr[8] = 8; ihdr[9] = 2; // 8 bit, RGB
Chunk(ms, "IHDR", ihdr);
using (var z = new MemoryStream())
{
using (var zs = new ZLibStream(z, CompressionLevel.Fastest, leaveOpen: true)) zs.Write(raw);
Chunk(ms, "IDAT", z.ToArray());
}
Chunk(ms, "IEND", []);
return ms.ToArray();
}
static void WriteInt(byte[] b, int at, int v) { b[at] = (byte)(v >> 24); b[at + 1] = (byte)(v >> 16); b[at + 2] = (byte)(v >> 8); b[at + 3] = (byte)v; }
static void Chunk(Stream s, string type, byte[] data)
{
var len = new byte[4]; WriteInt(len, 0, data.Length);
s.Write(len);
var body = new byte[4 + data.Length];
for (var i = 0; i < 4; i++) body[i] = (byte)type[i];
data.CopyTo(body, 4);
s.Write(body);
var crc = new byte[4]; WriteInt(crc, 0, (int)Crc32(body));
s.Write(crc);
}
static uint Crc32(byte[] data)
{
var crc = 0xFFFFFFFFu;
foreach (var b in data)
{
crc ^= b;
for (var k = 0; k < 8; k++) crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB88320u : crc >> 1;
}
return ~crc;
}
}
@@ -0,0 +1,21 @@
using Avalonia;
using Avalonia.Headless;
using Avalonia.Themes.Fluent;
using AgentWikiUi.Tests;
[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))]
namespace AgentWikiUi.Tests;
public class TestApp : Application
{
public override void Initialize() => Styles.Add(new FluentTheme());
}
public static class TestAppBuilder
{
// Skia reale (non il disegno finto): serve per decodificare davvero le immagini
public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure<TestApp>()
.UseSkia()
.UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false });
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>AgentWikiUi.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0" />
<PackageReference Include="Avalonia.Headless.XUnit" Version="12.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/agent-wiki-ui/agent-wiki-ui.csproj" />
<!-- solo compilato (non referenziato): i test avviano l'eseguibile -->
<ProjectReference Include="../FakeClaude/FakeClaude.csproj" ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>