diff --git a/.gitignore b/.gitignore index cd42ee3..9ba2259 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ bin/ obj/ +dist/ diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..d689afe --- /dev/null +++ b/AGENT.md @@ -0,0 +1,58 @@ +# AGENT.md + +Guida per gli agenti di codice (Claude Code, Gemini CLI, altri) che lavorano in questo repository. È l'**unica sorgente** delle istruzioni: `CLAUDE.md` e `GEMINI.md` sono puntatori che importano questo file, quindi ogni modifica si fa qui (vedi [docs/README.md](docs/README.md#istruzioni-per-gli-agenti-di-codice)). + +## Progetto + +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`) o Codex (`codex exec`) da terminale. Senza argomenti apre una GUI di chat Avalonia, con argomenti funziona da CLI. + +## Workflow doc-first (obbligatorio) + +- Stato attuale, stadio (MVP), debiti e cosa manca: [docs/PROJECT_STATE.md](docs/PROJECT_STATE.md); aggiornarlo nello stesso commit di ogni cambio di stadio, funzione, ADR o debito. 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 + +- Build: `dotnet build` (agent-wiki-ui.csproj, `net10.0`, Avalonia) +- GUI: `dotnet run`; CLI: `dotnet run -- "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` +- 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 + +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 ` 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/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/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/ChatStore.cs](Core/ChatStore.cs): cronologia, un JSON per chat in `//chats/.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). +- [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/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 del motore va redirezionato e chiuso subito: `claude` aspetta 3 s, `codex` stampa "Reading additional input from stdin…" e attende. +- `claude` è un eseguibile nativo (`~/.local/bin`), avviabile direttamente. Se fosse `claude.cmd` (Windows) serve `cmd.exe /c`. +- Codex (`CodexEngine`): l'id di sessione lo genera Codex e si legge da `thread.started` nell'output JSONL (`--json`); la risposta è l'ultimo `agent_message`; `exec resume` non accetta `-s`, la sola lettura si impone con `-c 'sandbox_mode="read-only"'`; serve `--skip-git-repo-check` (la cartella dell'exe non è un repository); `--` prima del prompt. Dettagli e prove in ADR-0002. Il modello predefinito è quello di `~/.codex/config.toml`: se richiede un Codex più recente l'errore compare nella chat (vedi docs/INSTALL.md). +- Il controllo delle versioni contatta solo `downloads.claude.ai` (Claude Code) e `api.github.com` (Codex, serve lo User-Agent), al massimo ogni 5 minuti e in background; un errore di rete non è un errore dell'app (stato "sconosciuto"). +- L'aggiornamento di un CLI è sempre un gesto esplicito dell'utente (pulsante Aggiorna): nessun aggiornamento automatico e nessun fallback che esegua npm/brew/winget al posto suo. +- Windows: l'eseguibile è `WinExe` (nessuna console al doppio clic); in modalità CLI `Core/ConsoleHost.cs` riaggancia il terminale padre con `AttachConsole` e collega stdout/stderr a `CONOUT$` solo se non sono già reindirizzati (ADR-0005). Il codice deve poter fallire in silenzio senza terminale. Conseguenza: su Windows l'exit code della CLI arriva solo se il chiamante attende il processo. +- 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 + +`.claude/settings.json` abilita il plugin `ui-ux-pro-max` (marketplace `nextlevelbuilder/ui-ux-pro-max-skill`). Usarlo per le modifiche alla GUI Avalonia (layout, usabilità, accessibilità). + +## Commit + +Messaggi di commit in italiano: titolo breve all'indicativo o in forma nominale, eventuale corpo che spiega il perché. Mantenere la riga finale `Co-Authored-By` prevista dall'ambiente. Fare commit solo su richiesta. diff --git a/CLAUDE.md b/CLAUDE.md index 7edc027..480c7a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,43 +1,5 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Le istruzioni per gli agenti di codice sono in [AGENT.md](AGENT.md), unica sorgente. Questo file è solo un puntatore: non aggiungere nulla qui. -## 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. - -## 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. - -## Comandi - -- Build: `dotnet build` (LlmRun.csproj, net10.0-windows, WinForms) -- 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. - -## 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. - -## 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`. - -## Skill del repo - -`.claude/settings.json` abilita il plugin `ui-ux-pro-max` (marketplace `nextlevelbuilder/ui-ux-pro-max-skill`). Usarlo per le modifiche alla GUI WinForms (layout, usabilità, accessibilità). - -## Commit - -Messaggi di commit in italiano: titolo breve all'indicativo o in forma nominale, eventuale corpo che spiega il perché. Mantenere la riga finale `Co-Authored-By` prevista dall'ambiente. Fare commit solo su richiesta. +@AGENT.md 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..7864ae8 --- /dev/null +++ b/Core/ChatStore.cs @@ -0,0 +1,71 @@ +using System.Text.Json; + +namespace AgentWikiUi.Core; + +record Msg(string Role, string Text); // Role: user | ai | err +// Engine e SessionId mancano nei file scritti prima dei motori multipli: valgono Claude Code e SessionId = Id. +record Stored(string Id, string Title, DateTime Updated, bool Started, List Messages, string? Engine = null, string? SessionId = null) +{ + public string EngineId => Engine ?? "claude"; + public string? EffectiveSessionId => SessionId ?? (EngineId == "claude" ? Id : null); +} + +// 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, string engine, string? sessionId, 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, engine, sessionId))); + } + + 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/ConsoleHost.cs b/Core/ConsoleHost.cs new file mode 100644 index 0000000..a107346 --- /dev/null +++ b/Core/ConsoleHost.cs @@ -0,0 +1,40 @@ +using System.Runtime.InteropServices; + +namespace AgentWikiUi.Core; + +// Su Windows l'eseguibile è senza console (WinExe, ADR-0005). In modalità CLI si riaggancia il terminale +// da cui parte, così l'output è visibile; senza terminale padre non succede nulla. +static class ConsoleHost +{ + const int AttachParentProcess = -1; + const int StdOutput = -11, StdError = -12; + const uint GenericReadWrite = 0xC0000000, ShareReadWrite = 3, OpenExisting = 3; + static readonly nint InvalidHandle = -1; + + [DllImport("kernel32.dll", SetLastError = true)] static extern bool AttachConsole(int processId); + [DllImport("kernel32.dll", SetLastError = true)] static extern nint GetStdHandle(int stdHandle); + [DllImport("kernel32.dll", SetLastError = true)] static extern bool SetStdHandle(int stdHandle, nint handle); + [DllImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, CharSet = CharSet.Unicode)] + static extern nint CreateFile(string name, uint access, uint share, nint security, uint disposition, uint flags, nint template); + + public static void AttachToParent() + { + if (!OperatingSystem.IsWindows()) return; + try + { + if (!AttachConsole(AttachParentProcess)) return; // nessun terminale padre (doppio clic, avvio da altro programma) + Connect(StdOutput); + Connect(StdError); + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) { } + } + + // Collega l'handle alla console solo se non è già valido: un file o una pipe ereditati (> file, | altro) restano com'erano. + static void Connect(int std) + { + var current = GetStdHandle(std); + if (current != 0 && current != InvalidHandle) return; + var console = CreateFile("CONOUT$", GenericReadWrite, ShareReadWrite, 0, OpenExisting, 0, 0); + if (console != InvalidHandle) SetStdHandle(std, console); + } +} diff --git a/Core/Engine.cs b/Core/Engine.cs new file mode 100644 index 0000000..3a4d3af --- /dev/null +++ b/Core/Engine.cs @@ -0,0 +1,188 @@ +using System.Text.Json; +using System.Net.Http.Headers; + +namespace AgentWikiUi.Core; + +record ModelOption(string Label, string Id); + +/// Esito di un messaggio: testo, id di sessione appreso dall'output (se il motore lo sceglie lui) o errore. +record EngineReply(string Text, string? SessionId, string? Error); + +/// Un motore CLI (Claude Code, Codex): come si avvia, cosa si può scegliere, come si legge l'output. Vedi ADR-0002. +abstract class Engine +{ + /// Identificatore stabile, salvato nei JSON delle chat e usato da --engine. + public abstract string Id { get; } + public abstract string Name { get; } + /// Etichetta sulle risposte ("Claude", "Codex"). + public abstract string Author { get; } + public abstract string Executable { get; } + public abstract ModelOption[] Models { get; } + public abstract string[] Efforts { get; } + /// Modello scelto all'avvio e a ogni cambio di CLI: il più piccolo, per risposte rapide ed economiche. + public abstract string DefaultModelId { get; } + public string DefaultEffort => "low"; + /// True se l'id di sessione lo genera il motore e l'app lo legge dall'output. + public abstract bool OwnsSessionId { get; } + + /// true = GUI (output catturato e da interpretare); false = CLI (output ereditato). + public abstract List BuildArgs(string prompt, string? sessionId, bool resume, string model, string effort, bool structured); + public abstract EngineReply Parse(int code, string stdout, string stderr); + /// Testo che annuncia l'ultima versione pubblicata (sola lettura, vedi ADR-0004). Eccezioni di rete/formato: le gestisce il chiamante. + public abstract Task FetchLatestVersionAsync(HttpClient http, CancellationToken ct); + + public bool IsInstalled() => PathSearch.Find(Executable) is not null; + + protected string Failed(int code, string detail) => $"{Executable} è uscito con codice {code}\n{detail}".Trim(); +} + +sealed class ClaudeEngine : Engine +{ + public override string Id => "claude"; + public override string Name => "Claude Code"; + public override string Author => "Claude"; + public override string Executable => "claude"; + public override bool OwnsSessionId => false; + + // Da aggiornare quando escono nuovi modelli. + public override ModelOption[] Models { get; } = + [ + 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 override string[] Efforts { get; } = ["low", "medium", "high", "xhigh", "max"]; + public override string DefaultModelId => "claude-haiku-5-5"; + + public override List BuildArgs(string prompt, string? sessionId, bool resume, string model, string effort, bool structured) + { + var args = new List { "-p", prompt }; + if (sessionId is not null) args.AddRange([resume ? "--resume" : "--session-id", sessionId]); + if (model != "") args.AddRange(["--model", model]); + if (effort != "") args.AddRange(["--effort", effort]); + return args; + } + + public override EngineReply Parse(int code, string stdout, string stderr) => + code == 0 ? new(stdout.Trim(), null, null) : new("", null, Failed(code, stderr.Trim())); + + // Stessa sorgente dell'installer nativo: un file di testo con la versione. + public override async Task FetchLatestVersionAsync(HttpClient http, CancellationToken ct) => + (await http.GetStringAsync("https://downloads.claude.ai/claude-code-releases/latest", ct)).Trim(); +} + +sealed class CodexEngine : Engine +{ + public override string Id => "codex"; + public override string Name => "Codex"; + public override string Author => "Codex"; + public override string Executable => "codex"; + public override bool OwnsSessionId => true; + + // Il catalogo di Codex è dinamico (~/.codex/models_cache.json): elenco fisso, da aggiornare. + public override ModelOption[] Models { get; } = + [ + new("GPT-5.6 Sol", "gpt-5.6-sol"), + new("GPT-5.6 Terra", "gpt-5.6-terra"), + new("GPT-5.6 Luna", "gpt-5.6-luna"), + ]; + + public override string[] Efforts { get; } = ["low", "medium", "high", "xhigh", "max"]; + public override string DefaultModelId => "gpt-5.6-luna"; + + public override List BuildArgs(string prompt, string? sessionId, bool resume, string model, string effort, bool structured) + { + var args = new List { "exec" }; + var resuming = resume && sessionId is not null; + if (resuming) args.Add("resume"); + if (structured) args.Add("--json"); + args.Add("--skip-git-repo-check"); + // `exec resume` non accetta -s: la sola lettura si impone con la configurazione + if (resuming) args.AddRange(["-c", "sandbox_mode=\"read-only\""]); + else args.AddRange(["-s", "read-only"]); + if (model != "") args.AddRange(["-m", model]); + if (effort != "") args.AddRange(["-c", $"model_reasoning_effort={effort}"]); + if (resuming) args.Add(sessionId!); + args.AddRange(["--", prompt]); // `--`: un prompt che inizia con "-" non è un'opzione + return args; + } + + // stdout è JSONL: thread.started (id), item.completed/agent_message (testo), turn.failed/error (errore). + public override EngineReply Parse(int code, string stdout, string stderr) + { + string? threadId = null, lastMessage = null, error = null; + foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + try + { + using var doc = JsonDocument.Parse(line); + var e = doc.RootElement; + switch (e.GetProperty("type").GetString()) + { + case "thread.started": threadId = e.GetProperty("thread_id").GetString(); break; + case "item.completed" when e.GetProperty("item").GetProperty("type").GetString() == "agent_message": + lastMessage = e.GetProperty("item").GetProperty("text").GetString(); break; + case "turn.failed": error = InnerMessage(e.GetProperty("error").GetProperty("message").GetString()); break; + case "error": error ??= InnerMessage(e.GetProperty("message").GetString()); break; + } + } + catch (Exception ex) when (ex is JsonException or KeyNotFoundException or InvalidOperationException) { } // riga non JSON o evento sconosciuto + } + if (code != 0) return new("", threadId, Failed(code, error ?? stderr.Trim())); + if (string.IsNullOrWhiteSpace(lastMessage)) return new("", threadId, error ?? "codex non ha restituito nessuna risposta."); + return new(lastMessage.Trim(), threadId, null); + } + + // Ultima release su GitHub: tag_name tipo "rust-v0.162.1". L'API richiede lo User-Agent (impostato dal chiamante). + public override async Task FetchLatestVersionAsync(HttpClient http, CancellationToken ct) + { + using var req = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/repos/openai/codex/releases/latest"); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + using var resp = await http.SendAsync(req, ct); + resp.EnsureSuccessStatusCode(); + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); + return doc.RootElement.GetProperty("tag_name").GetString(); + } + + // L'errore arriva come stringa che contiene un JSON {"error":{"message":…}}: si mostra il messaggio interno. + static string? InnerMessage(string? raw) + { + if (raw is null) return null; + try + { + using var doc = JsonDocument.Parse(raw); + return doc.RootElement.GetProperty("error").GetProperty("message").GetString() ?? raw; + } + catch (Exception ex) when (ex is JsonException or KeyNotFoundException or InvalidOperationException) { return raw; } + } +} + +static class Engines +{ + public static readonly Engine Claude = new ClaudeEngine(); + public static readonly Engine Codex = new CodexEngine(); + public static readonly Engine[] All = [Claude, Codex]; + + public static Engine? Find(string? id) => All.FirstOrDefault(e => e.Id == id); +} + +static class PathSearch +{ + // Su Windows i motori si avviano direttamente: solo .exe (niente script .cmd, vedi ADR-0002). + public static string? Find(string name) + { + var file = OperatingSystem.IsWindows() ? name + ".exe" : name; + foreach (var dir in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + try + { + var full = Path.Combine(dir.Trim('"'), file); + if (File.Exists(full)) return full; + } + catch (ArgumentException) { } // voce del PATH non valida + } + return null; + } +} diff --git a/Core/EngineRunner.cs b/Core/EngineRunner.cs new file mode 100644 index 0000000..7bd393f --- /dev/null +++ b/Core/EngineRunner.cs @@ -0,0 +1,72 @@ +using System.Diagnostics; + +namespace AgentWikiUi.Core; + +// Unico punto di avvio dei motori, condiviso da CLI e GUI. +static class EngineRunner +{ + public static ProcessStartInfo CreateStart(Engine engine, string prompt, bool captured, string? sessionId = null, bool resume = false, string model = "", string effort = "") => + CreateStart(engine, engine.BuildArgs(prompt, sessionId, resume, model, effort, structured: captured), captured); + + public static ProcessStartInfo CreateStart(Engine engine, IEnumerable args, bool captured) + { + var psi = new ProcessStartInfo(engine.Executable) + { + 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 del motore + RedirectStandardOutput = captured, + RedirectStandardError = captured, + CreateNoWindow = true, + }; + foreach (var a in args) psi.ArgumentList.Add(a); + return psi; + } + + // CLI: stdout/stderr ereditati (streaming), exit code propagato, 127 se l'eseguibile manca. + public static int RunInherited(Engine engine, string prompt) + { + try + { + using var p = Process.Start(CreateStart(engine, prompt, captured: false))!; + p.StandardInput.Close(); + p.WaitForExit(); + return p.ExitCode; + } + catch (System.ComponentModel.Win32Exception) + { + Console.Error.WriteLine($"'{engine.Executable}' non trovato nel PATH."); + return 127; + } + } + + // GUI: output catturato e interpretato dal motore. Win32Exception se l'eseguibile non è nel PATH. + public static async Task RunCapturedAsync(Engine engine, string prompt, string? sessionId, bool resume, string model, string effort) + { + using var p = Process.Start(CreateStart(engine, prompt, captured: true, sessionId, resume, model, effort))!; + p.StandardInput.Close(); + var o = p.StandardOutput.ReadToEndAsync(); + var e = p.StandardError.ReadToEndAsync(); + await p.WaitForExitAsync(); + return engine.Parse(p.ExitCode, await o, await e); + } + + // Comandi di servizio del motore (--version, update): output catturato, tempo massimo, nessuna chat. + // Win32Exception se l'eseguibile non è nel PATH; allo scadere il processo e i suoi figli sono terminati. + public static async Task<(int Code, string Out, string Err, bool TimedOut)> RunToolAsync(Engine engine, IEnumerable args, TimeSpan timeout) + { + using var p = Process.Start(CreateStart(engine, args, captured: true))!; + p.StandardInput.Close(); + var o = p.StandardOutput.ReadToEndAsync(); + var e = p.StandardError.ReadToEndAsync(); + using var cts = new CancellationTokenSource(timeout); + try { await p.WaitForExitAsync(cts.Token); } + catch (OperationCanceledException) + { + try { p.Kill(entireProcessTree: true); } catch (InvalidOperationException) { } + await p.WaitForExitAsync(); + return (-1, await o, await e, true); + } + return (p.ExitCode, await o, await e, false); + } +} diff --git a/Core/EngineUpdater.cs b/Core/EngineUpdater.cs new file mode 100644 index 0000000..d4fb21e --- /dev/null +++ b/Core/EngineUpdater.cs @@ -0,0 +1,131 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace AgentWikiUi.Core; + +enum InstallMethod { Native, Npm, Homebrew, WinGet } + +/// Esito del controllo (sola lettura): versione installata (riga di `--version`), ultima pubblicata e se è più nuova. Available null = sconosciuto. +record UpdateCheck(string? InstalledText, string? Latest, bool? Available); + +/// Esito di un aggiornamento: messaggio da mostrare e, se riuscito, le versioni prima e dopo. +record UpdateOutcome(bool Ok, string Message, string? Before, string? After); + +// Aggiornamento di un CLI con il suo comando ufficiale (` update`). Vedi ADR-0004. +static class EngineUpdater +{ + public static readonly TimeSpan UpdateTimeout = TimeSpan.FromMinutes(10); + static readonly TimeSpan VersionTimeout = TimeSpan.FromSeconds(30); + + static readonly TimeSpan CheckTimeout = TimeSpan.FromSeconds(10); + static readonly HttpClient SharedHttp = NewHttp(); + + static HttpClient NewHttp() + { + var http = new HttpClient { Timeout = CheckTimeout }; + http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(AppInfo.Name, "1.0")); // GitHub lo richiede + return http; + } + + /// Primo numero N.N.N del testo ("2.1.300 (Claude Code)", "codex-cli 0.162.1", "rust-v0.162.1"). + public static Version? ParseVersion(string? text) + { + var m = text is null ? null : Regex.Match(text, @"\d+\.\d+\.\d+"); + return m is { Success: true } && Version.TryParse(m.Value, out var v) ? v : null; + } + + /// true se è più nuova di ; null se una delle due non si legge. + public static bool? IsNewer(string? latest, string? installed) => + ParseVersion(latest) is { } l && ParseVersion(installed) is { } i ? l > i : null; + + /// Controlla se c'è una versione più nuova senza installare nulla. Mai eccezioni: rete o formato inattesi ⇒ sconosciuto. + public static async Task CheckAsync(Engine engine, HttpClient? http = null) + { + var installed = await GetVersionAsync(engine); + string? latest = null; + try + { + using var cts = new CancellationTokenSource(CheckTimeout); + latest = await engine.FetchLatestVersionAsync(http ?? SharedHttp, cts.Token); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException or JsonException or KeyNotFoundException or InvalidOperationException) { } + return new(installed, latest, IsNewer(latest, installed)); + } + + /// Prima riga di `--version` ripulita; null se l'eseguibile manca o fallisce. + public static async Task GetVersionAsync(Engine engine, TimeSpan? timeout = null) + { + try + { + var r = await EngineRunner.RunToolAsync(engine, ["--version"], timeout ?? VersionTimeout); + if (r.TimedOut || r.Code != 0) return null; + return r.Out.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault(); + } + catch (System.ComponentModel.Win32Exception) { return null; } + } + + public static async Task UpdateAsync(Engine engine, TimeSpan? timeout = null) + { + var before = await GetVersionAsync(engine); + (int Code, string Out, string Err, bool TimedOut) r; + try { r = await EngineRunner.RunToolAsync(engine, ["update"], timeout ?? UpdateTimeout); } + catch (System.ComponentModel.Win32Exception) + { + return new(false, $"'{engine.Executable}' non trovato nel PATH.", before, null); + } + + var manual = ManualCommand(engine, DetectMethod(PathSearch.Find(engine.Executable))); + if (r.TimedOut) return new(false, $"Aggiornamento interrotto: più di {Elapsed(timeout ?? UpdateTimeout)}.\nComando manuale: {manual}", before, null); + if (r.Code != 0) return new(false, $"Aggiornamento non riuscito (codice {r.Code}).\n{Tail(r.Err, r.Out)}\nComando manuale: {manual}".Replace("\n\n", "\n"), before, null); + + var after = await GetVersionAsync(engine); + if (before is not null && after is not null && before != after) return new(true, $"Aggiornato da {before} a {after}.", before, after); + return new(true, $"Già aggiornato ({after ?? before ?? "versione sconosciuta"}).", before, after); + } + + static string Elapsed(TimeSpan t) => t.TotalMinutes >= 1 ? $"{t.TotalMinutes:0} minuti" : $"{t.TotalSeconds:0} secondi"; + + // Ultime righe utili dell'output (stderr se c'è, altrimenti stdout). + static string Tail(string err, string output) + { + var text = string.IsNullOrWhiteSpace(err) ? output : err; + return string.Join('\n', text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).TakeLast(4)); + } + + /// Metodo di installazione dedotto dal percorso dell'eseguibile, anche dopo aver risolto i collegamenti simbolici. + public static InstallMethod DetectMethod(string? path) + { + if (path is null) return InstallMethod.Native; + foreach (var p in new[] { path, RealPath(path) }) + { + if (Has(p, "node_modules") || Has(p, ".npm")) return InstallMethod.Npm; + if (Has(p, "Cellar") || Has(p, "homebrew") || Has(p, "linuxbrew")) return InstallMethod.Homebrew; + if (Has(p, "WinGet") || Has(p, "WindowsApps")) return InstallMethod.WinGet; + } + return InstallMethod.Native; + } + + static bool Has(string path, string part) => path.Contains(part, StringComparison.OrdinalIgnoreCase); + + static string RealPath(string path) + { + try { return new FileInfo(path).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? path; } + catch (IOException) { return path; } + } + + /// Comando da eseguire a mano quando l'aggiornamento automatico non basta (tabella dell'ADR-0004). + public static string ManualCommand(Engine engine, InstallMethod method) => (engine.Id, method) switch + { + ("claude", InstallMethod.Npm) => "npm install -g @anthropic-ai/claude-code@latest", + ("claude", InstallMethod.Homebrew) => "brew upgrade claude-code", + ("claude", InstallMethod.WinGet) => "winget upgrade Anthropic.ClaudeCode", + ("claude", _) => "claude update", + ("codex", InstallMethod.Npm) => "npm install -g @openai/codex@latest", + ("codex", InstallMethod.Homebrew) => "brew upgrade --cask codex", + ("codex", _) => OperatingSystem.IsWindows() + ? "powershell -ExecutionPolicy ByPass -c \"irm https://chatgpt.com/codex/install.ps1 | iex\"" + : "curl -fsSL https://chatgpt.com/codex/install.sh | sh", + _ => $"{engine.Executable} update", + }; +} diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..93ee1c8 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,5 @@ +# GEMINI.md + +Le istruzioni per gli agenti di codice sono in [AGENT.md](AGENT.md), unica sorgente. Questo file è solo un puntatore: non aggiungere nulla qui. + +@AGENT.md 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/Gui/App.axaml b/Gui/App.axaml new file mode 100644 index 0000000..9694d89 --- /dev/null +++ b/Gui/App.axaml @@ -0,0 +1,135 @@ + + + + + + + #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..96ce6d2 --- /dev/null +++ b/Gui/MainViewModel.cs @@ -0,0 +1,323 @@ +using System.Collections.ObjectModel; +using System.Windows.Input; +using AgentWikiUi.Core; + +namespace AgentWikiUi.Gui; + +class MessageVm(string role, string text, string author) +{ + public string Role { get; } = role; + public string Text { get; } = text; + public string Author { get; } = author; + 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, string engineName) +{ + public string Id { get; } = id; + public string Title { get; } = title; + public string When { get; } = $"{engineName} · {updated:d MMM HH:mm}"; + public bool IsActive { get; } = active; +} + +/// Voce dell'interruttore CLI: un motore non installato è mostrato ma non selezionabile. Porta anche versione ed esito dell'ultimo aggiornamento. +class EngineChoice(Engine engine) : ObservableObject +{ + string version = ""; + string status = ""; + bool statusIsError; + bool? available; + string? latest; + + public Engine Engine { get; } = engine; + public bool Installed { get; } = engine.IsInstalled(); + public string Name => Engine.Name; + public string Label => Installed ? Engine.Name : $"{Engine.Name} (non installato)"; + public string Version { get => version; set => Set(ref version, value); } + public string Status { get => status; set { if (Set(ref status, value)) Raise(nameof(HasStatus)); } } + public bool HasStatus => status != ""; + public bool StatusIsError { get => statusIsError; set => Set(ref statusIsError, value); } + + /// Esito del controllo: true = c'è una versione nuova, false = aggiornato, null = non controllato o sconosciuto. + public bool? Available { get => available; private set { if (Set(ref available, value)) { Raise(nameof(UpdateAvailable)); Raise(nameof(CheckText)); } } } + public bool UpdateAvailable => available == true; + public string CheckText => available switch + { + true => $"Aggiornamento disponibile: {latest}", + false => "Aggiornato", + null => checkedOnce ? "Controllo non riuscito" : "", + }; + bool checkedOnce; + + public void SetCheck(bool? isAvailable, string? latestVersion) + { + latest = latestVersion; checkedOnce = true; + Raise(nameof(CheckText)); + Available = isAvailable; + } +} + +class MainViewModel : ObservableObject +{ + static readonly ModelOption Default = new("Predefinito", ""); + static readonly ModelOption Custom = new("Altro ID…", "__custom"); + const string NoEffort = "Predefinito"; + + public ObservableCollection Messages { get; } = []; + public ObservableCollection Chats { get; } = []; + public EngineChoice[] EngineChoices { get; } = Engines.All.Select(e => new EngineChoice(e)).ToArray(); + /// Righe della sezione Aggiornamenti: solo i CLI installati. + public EngineChoice[] InstalledEngines => EngineChoices.Where(c => c.Installed).ToArray(); + /// Puntino sul pulsante Impostazioni quando almeno un CLI ha una versione nuova. + public bool AnyUpdateAvailable => EngineChoices.Any(c => c.UpdateAvailable); + + public ICommand NewChatCommand => newChat; + public ICommand CopyCommand { get; } + public ICommand SendCommand { get; } + public ICommand OpenCommand { get; } + public ICommand DeleteCommand { get; } + public ICommand ToggleSidebarCommand { get; } + public ICommand UpdateCommand { get; } + + /// Conferma di eliminazione, fornita dalla finestra. + public Func> ConfirmDelete { get; set; } = () => Task.FromResult(true); + /// Copia negli appunti, fornita dalla finestra. + public Func CopyText { get; set; } = _ => Task.CompletedTask; + /// Chiesto alla finestra di rimettere il fuoco sul campo messaggio. + public event Action? FocusInput; + + // Una chat = un motore. chatId è il nome del file; sessionId è l'id del motore: + // per Claude Code lo sceglie l'app (= chatId), per Codex arriva dall'output dopo il primo messaggio. + string chatId = Guid.NewGuid().ToString(); + string? sessionId; + bool started; // true dopo il primo messaggio riuscito: da lì in poi si riprende la sessione + List history = []; + + EngineChoice selectedEngine; + ModelOption[] modelChoices = []; + string[] effortChoices = []; + string input = ""; + bool busy, updating, sidebarOpen = true; + ModelOption selectedModel = Default; + string customModel = ""; + string selectedEffort = NoEffort; + readonly RelayCommand send, newChat; + + public MainViewModel() + { + selectedEngine = EngineChoices.FirstOrDefault(c => c.Installed) ?? EngineChoices[0]; + ApplyEngine(selectedEngine); + send = new(_ => _ = SendAsync(), () => CanSend); + SendCommand = send; + newChat = new(_ => NewChat(), () => !busy && !updating); + 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); }); + ToggleSidebarCommand = new RelayCommand(_ => SidebarOpen = !SidebarOpen); + UpdateCommand = new RelayCommand(c => { if (c is not null) _ = UpdateEngineAsync(c); }); + Messages.CollectionChanged += (_, _) => { Raise(nameof(HasMessages)); Raise(nameof(ShowEmpty)); }; + ResetSession(); + + _ = CheckUpdatesAsync(); + + // all'avvio riapre la chat più recente + if (ChatStore.List().FirstOrDefault() is { } last) OpenChat(last.Id); else RefreshChats(); + } + + Engine Engine => selectedEngine.Engine; + + public bool HasMessages => Messages.Count > 0; + public bool ShowEmpty => Messages.Count == 0; + public bool CanSend => !busy && !updating && !string.IsNullOrWhiteSpace(input); + /// I pulsanti Aggiorna sono attivi solo se non c'è una risposta né un altro aggiornamento in corso. + public bool CanUpdate => !busy && !updating; + public string Author => Engine.Author; + public string InputPlaceholder => $"Scrivi un messaggio a {Engine.Author}…"; + public string BusyText => $"{Engine.Author} sta scrivendo…"; + + 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)); Raise(nameof(CanSwitchEngine)); Raise(nameof(CanUpdate)); send.Refresh(); newChat.Refresh(); } } + } + + public bool SidebarOpen { get => sidebarOpen; set => Set(ref sidebarOpen, value); } + + bool IsUpdating + { + get => updating; + set { if (Set(ref updating, value)) { Raise(nameof(CanSend)); Raise(nameof(CanSwitchEngine)); Raise(nameof(CanUpdate)); send.Refresh(); newChat.Refresh(); } } + } + + public bool CanSwitchEngine => !busy && !updating; + + /// Interruttore CLI: cambiare motore apre una nuova chat (il contesto non passa da un motore all'altro). + public EngineChoice SelectedEngine + { + get => selectedEngine; + set + { + if (value is null || value == selectedEngine || busy || updating || !value.Installed) return; + ApplyEngine(value); + NewChat(); + } + } + + public ModelOption[] ModelChoices { get => modelChoices; private set => Set(ref modelChoices, value); } + public string[] EffortChoices { get => effortChoices; private set => Set(ref effortChoices, 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); } + + // Adotta un motore: liste di modelli ed effort sue, scelte riportate a "Predefinito". + void ApplyEngine(EngineChoice choice) + { + selectedEngine = choice; + ModelChoices = [Default, .. choice.Engine.Models, Custom]; + EffortChoices = [NoEffort, .. choice.Engine.Efforts]; + // valori iniziali: il modello più piccolo del motore ed effort basso ("Predefinito" resta selezionabile) + SelectedModel = ModelChoices.FirstOrDefault(m => m.Id == choice.Engine.DefaultModelId) ?? Default; + CustomModel = ""; SelectedEffort = choice.Engine.DefaultEffort; + Raise(nameof(SelectedEngine)); Raise(nameof(Author)); Raise(nameof(InputPlaceholder)); Raise(nameof(BusyText)); + } + + void ResetSession() + { + chatId = Guid.NewGuid().ToString(); + sessionId = Engine.OwnsSessionId ? null : chatId; + started = false; + history = []; + } + + void RefreshChats() + { + Chats.Clear(); + foreach (var c in ChatStore.List()) + Chats.Add(new(c.Id, c.Title, c.Updated, c.Id == chatId, Engines.Find(c.EngineId)?.Name ?? c.EngineId)); + } + + void Show(List msgs, string author) + { + Messages.Clear(); + foreach (var m in msgs) Messages.Add(new(m.Role, m.Text, author)); + } + + void NewChat() + { + ResetSession(); + Show(history, Author); RefreshChats(); FocusInput?.Invoke(); + } + + void OpenChat(string id) + { + if (busy) return; + if (ChatStore.Load(id) is not { } chat) { RefreshChats(); return; } + var choice = EngineChoices.FirstOrDefault(c => c.Engine.Id == chat.EngineId); + if (choice is not null && choice != selectedEngine) ApplyEngine(choice); + chatId = chat.Id; sessionId = chat.EffectiveSessionId; started = chat.Started; history = chat.Messages; + Show(history, Author); RefreshChats(); + } + + async Task DeleteAsync(ChatItem c) + { + if (busy || !await ConfirmDelete()) return; + ChatStore.Delete(c.Id); + if (c.Id == chatId) NewChat(); else RefreshChats(); + } + + async Task SendAsync() + { + if (!CanSend) return; + var text = input.Trim(); + var model = IsCustomModel ? customModel.Trim() : selectedModel.Id; + var effort = selectedEffort == NoEffort ? "" : 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 EngineRunner.RunCapturedAsync(Engine, text, sessionId, started, model, effort); + if (r.Error is null) + { + started = true; + if (r.SessionId is not null) sessionId = r.SessionId; + Add("ai", r.Text); + } + else Add("err", r.Error); + } + catch (System.ComponentModel.Win32Exception) { Add("err", $"'{Engine.Executable}' non trovato nel PATH."); } + catch (Exception ex) { Add("err", ex.Message); } + finally + { + IsBusy = false; + Persist(); + RefreshChats(); + FocusInput?.Invoke(); + } + } + + static readonly TimeSpan CheckEvery = TimeSpan.FromMinutes(5); + DateTime lastCheck = DateTime.MinValue; + bool checking; + + /// Controlla (sola lettura) se i CLI hanno una versione nuova. All'avvio, all'apertura del riquadro (al massimo ogni 5 minuti) e dopo un aggiornamento. + public async Task CheckUpdatesAsync(bool force = false) + { + if (checking || (!force && DateTime.UtcNow - lastCheck < CheckEvery)) return; + checking = true; + try + { + foreach (var c in InstalledEngines) + { + var r = await EngineUpdater.CheckAsync(c.Engine); + if (r.InstalledText is not null) c.Version = r.InstalledText; + c.SetCheck(r.Available, r.Latest); + Raise(nameof(AnyUpdateAvailable)); + } + lastCheck = DateTime.UtcNow; + } + finally { checking = false; } + } + + // Aggiorna un solo CLI con il suo comando ufficiale e mostra l'esito sulla sua riga (ADR-0004). + async Task UpdateEngineAsync(EngineChoice c) + { + if (!CanUpdate || !c.Installed) return; + IsUpdating = true; + c.StatusIsError = false; c.Status = "Aggiornamento in corso…"; + try + { + var r = await EngineUpdater.UpdateAsync(c.Engine); + c.StatusIsError = !r.Ok; + c.Status = r.Message; + if (r.Ok) c.Version = r.After ?? r.Before ?? c.Version; + } + catch (Exception ex) { c.StatusIsError = true; c.Status = ex.Message; } + finally { IsUpdating = false; } + await CheckUpdatesAsync(force: true); // dopo l'aggiornamento: ripete il controllo, il pulsante non resta evidenziato + } + + void Add(string role, string text) { history.Add(new(role, text)); Messages.Add(new(role, text, Author)); } + + void Persist() + { + try { ChatStore.Save(chatId, Engine.Id, 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..478ab93 --- /dev/null +++ b/Gui/MainWindow.axaml @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Gui/MainWindow.axaml.cs b/Gui/MainWindow.axaml.cs new file mode 100644 index 0000000..bfc4db7 --- /dev/null +++ b/Gui/MainWindow.axaml.cs @@ -0,0 +1,51 @@ +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(); + } + + // All'apertura del riquadro si ricontrolla se ci sono aggiornamenti (al massimo ogni 5 minuti). + void OnSettingsOpening(object? sender, EventArgs e) => _ = (DataContext as MainViewModel)?.CheckUpdatesAsync(); +} 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/LlmRun.csproj b/LlmRun.csproj deleted file mode 100644 index d301037..0000000 --- a/LlmRun.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - Exe - net10.0-windows - true - enable - enable - $(NoWarn);WFO5001 - - - - - - - - - - - - diff --git a/Program.cs b/Program.cs index 44936f9..b9d599e 100644 --- a/Program.cs +++ b/Program.cs @@ -1,44 +1,25 @@ -using System.Diagnostics; -using System.Windows.Forms; +using Avalonia; +using AgentWikiUi.Core; +using AgentWikiUi.Gui; -if (args.Length == 0) +// Con argomenti: CLI. Con `--engine ` per primo si sceglie il motore (default Claude Code); +// il resto degli argomenti è unito in un solo prompt. Senza argomenti: GUI. +if (args.Length > 0) { - // WebView2 richiede un thread STA, che i top-level statements non danno - var ui = new Thread(() => + ConsoleHost.AttachToParent(); // Windows: l'eseguibile non ha console, si usa quella del terminale padre + var engine = Engines.Claude; + var words = args; + if (args[0] == "--engine") { - ApplicationConfiguration.Initialize(); - Application.SetColorMode(SystemColorMode.System); - Application.Run(new Gui()); - }); - ui.SetApartmentState(ApartmentState.STA); - ui.Start(); - ui.Join(); - return 0; + if (args.Length < 3 || Engines.Find(args[1]) is not { } chosen) + { + Console.Error.WriteLine($"Uso: [--engine {string.Join('|', Engines.All.Select(e => e.Id))}] \"prompt\""); + return 2; + } + engine = chosen; + words = args[2..]; + } + return EngineRunner.RunInherited(engine, string.Join(' ', words)); } -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/README.md b/README.md index 95f9c81..58ae372 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,69 @@ -# 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`) o Codex (`codex exec`) 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 Codex lo stesso con `codex`. Istruzioni: [docs/INSTALL.md](docs/INSTALL.md) +- 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). 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 ``` -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 del motore (127 se non è nel PATH). Di default usa Claude Code; `--engine codex` usa Codex. ``` dotnet run -- "spiega questo progetto" +dotnet run -- --engine codex "spiega questo progetto" ``` -Eseguibile già compilato: -``` -dotnet build -bin\Debug\net10.0-windows\LlmRun.exe -``` +## Creare gli eseguibili (Windows e Linux) +Ogni eseguibile è un **unico file** che include il runtime .NET: chi lo usa non deve installare nulla. Per crearlo serve solo il .NET SDK 10 sulla macchina che compila, e **da ciascun sistema si creano entrambi gli eseguibili** (da Windows anche quello per Linux, e viceversa). -## Publish single-file -``` -dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -``` +### 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). +- **Linux / WSL / macOS**: pacchetto della distribuzione o script ufficiale da [dotnet.microsoft.com/download](https://dotnet.microsoft.com/download). +- Controllo: `dotnet --version` deve stampare `10.x`. -Documentazione: [docs/MVP.md](docs/MVP.md), [docs/ROADMAP.md](docs/ROADMAP.md). +### 2. Creare gli eseguibili +Dalla cartella del progetto. + +| Sistema | Comando | Scelta del sistema di destinazione | +|---|---|---| +| **Linux, WSL, macOS** (bash) | `scripts/publish.sh` | `scripts/publish.sh linux-x64` | +| **Windows** (PowerShell) | `.\scripts\publish.ps1` | `.\scripts\publish.ps1 win-x64` | + +Senza argomenti si creano entrambi. Su Windows, se PowerShell rifiuta di eseguire lo script (criteri di esecuzione): `powershell -ExecutionPolicy Bypass -File .\scripts\publish.ps1`. + +Risultato (circa 47 MB per file), in `dist/`, ignorata da git: + +| Sistema | File | +|---|---| +| Windows x64 | `dist/win-x64/agent-wiki-ui.exe` | +| 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. + +**Senza script** (identico su qualsiasi sistema; sostituisci `` con `win-x64` o `linux-x64`): +``` +dotnet publish -c Release -r --self-contained -p:PublishSingleFile=true -o dist/ +``` +Senza `-o` il risultato è in `bin/Release/net10.0//publish/`. + +Verificato: da Linux/WSL entrambi gli eseguibili; da Windows nativo (PowerShell 5.1, SDK 10.0.203) entrambi, e il `.exe` creato parte. + +**Usarli** +- Windows: doppio clic su `agent-wiki-ui.exe` per la GUI (non si apre nessuna finestra di console), oppure da terminale `agent-wiki-ui.exe "prompt"` per la CLI. Da `cmd` o PowerShell il prompt torna subito e l'output compare dopo; per aspettare la fine e leggere l'exit code usa `start /wait agent-wiki-ui.exe "prompt"` (cmd) o `Start-Process -Wait -NoNewWindow agent-wiki-ui.exe "prompt"` (PowerShell); `& .\agent-wiki-ui.exe "prompt" | Out-Host` aspetta ma non dà l'exit code. Spiegazione in [ADR-0005](docs/adr/0005-windows-senza-console.md). Il file non è firmato: Windows può mostrare un avviso di sicurezza al primo avvio. +- Linux: `chmod +x agent-wiki-ui` (se serve) e poi `./agent-wiki-ui`. La GUI richiede un desktop con X11 o XWayland. +- 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). + +## 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). + +> **Stato: MVP** (prima della 1.0): la parte funzionale c'è, i test automatici e alcune verifiche su macchine reali mancano. Dettagli in [docs/PROJECT_STATE.md](docs/PROJECT_STATE.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/agent-wiki-ui.csproj b/agent-wiki-ui.csproj new file mode 100644 index 0000000..2995468 --- /dev/null +++ b/agent-wiki-ui.csproj @@ -0,0 +1,26 @@ + + + + + WinExe + net10.0 + + agent-wiki-ui + AgentWikiUi + enable + enable + true + + true + true + none + + + + + + + + + + diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..075b315 --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,104 @@ +# Installare i motori (Claude Code e Codex) + +L'app non installa né autentica i motori: lancia quelli che trova nel PATH, già installati e con l'accesso fatto ([ADR-0002](adr/0002-motori-cli-claude-codex-antigravity.md)). Questa pagina spiega come prepararli. Fonti e data di consultazione in fondo. + +## Prima di iniziare: un ambiente, un motore +Il motore va installato **nello stesso ambiente in cui gira l'app**: + +| Dove avvii l'app | Dove installare il motore | +|---|---| +| Linux nativo | nel terminale Linux | +| WSL (anche con `dotnet run`) | nel terminale della distribuzione 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`. + +## Claude Code + +**Requisiti**: macOS 13+, Windows 10 1809+ o Server 2019+, Ubuntu 20.04+, Debian 10+, Alpine 3.19+; 4 GB di RAM, x64 o ARM64, connessione a internet. Su Windows nativo [Git for Windows](https://git-scm.com/downloads/win) è consigliato ma facoltativo. + +**Installazione** (una delle opzioni; la prima è la raccomandata): + +| Metodo | Comando | +|---|---| +| Nativo, Linux/macOS/WSL | `curl -fsSL https://claude.ai/install.sh \| bash` | +| Nativo, Windows PowerShell | `irm https://claude.ai/install.ps1 \| iex` | +| Nativo, Windows CMD | `curl -fsSL https://claude.ai/install.cmd -o install.cmd && install.cmd && del install.cmd` | +| Homebrew | `brew install --cask claude-code` | +| WinGet | `winget install Anthropic.ClaudeCode` | +| npm (serve Node.js 22 o successivo) | `npm install -g @anthropic-ai/claude-code` (mai con `sudo`) | +| apt / dnf / apk | repository firmati: vedi la guida ufficiale | + +L'installazione nativa non mostra avanzamento durante il download. A fine installazione apri un **nuovo** terminale. + +**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** +``` +claude --version # stampa la versione, es. 2.1.x (Claude Code) +claude doctor # diagnostica di installazione e impostazioni +claude auth status # mostra se hai fatto l'accesso +``` + +**Aggiornamento**: l'installazione nativa si aggiorna da sola; `claude update` forza l'aggiornamento. Homebrew, WinGet, npm e i gestori di pacchetti vanno aggiornati a mano (`npm install -g @anthropic-ai/claude-code@latest`, evitando `npm update -g`). + +## Codex CLI + +**Requisiti**: macOS (Apple Silicon e Intel), Linux (x86_64 e arm64), Windows tramite lo script PowerShell. Per l'installazione con npm serve Node.js. Serve un piano ChatGPT (Plus, Pro, Business, Edu o Enterprise) oppure una API key. + +**Installazione** (una delle opzioni): + +| Metodo | Comando | +|---|---| +| Script, Linux/macOS/WSL | `curl -fsSL https://chatgpt.com/codex/install.sh \| sh` | +| Script, Windows PowerShell | `powershell -ExecutionPolicy ByPass -c "irm https://chatgpt.com/codex/install.ps1 \| iex"` | +| npm | `npm install -g @openai/codex` | +| Homebrew | `brew install --cask codex` | +| Binario | archivio dalla pagina delle release su GitHub (`openai/codex`), estratto e rinominato `codex` | + +Gli script scaricano da `releases.openai.com` e, se fallisce, da GitHub Releases; per forzare GitHub imposta `CODEX_INSTALLER_USE_RELEASES_OPENAI_COM=false`. + +**Su Windows preferisci lo script PowerShell a npm.** L'app avvia l'eseguibile direttamente, senza `cmd.exe /c`; un'installazione con npm potrebbe produrre uno script `.cmd` che così non parte (rischio indicato nell'ADR-0002, da verificare su Windows). + +**Accesso**: esegui `codex` e scegli "Sign in with ChatGPT" (consigliato), oppure usa una API key (guida ufficiale: sezione autenticazione). + +**Verifica** +``` +codex --version # es. codex-cli 0.144.x +codex login status # "Logged in using ChatGPT" se l'accesso è fatto +codex exec --help # uso non interattivo, usato dall'app +``` + +**Stato su questa macchina** (WSL, controllato il 2026-10-15): `codex` 0.144.3 in `~/.local/bin/codex`, collegamento a `~/.codex/packages/standalone/current/bin/codex`, con accesso ChatGPT già fatto. `claude` 2.1.252 in `~/.npm-global/bin/claude`, con accesso claude.ai già fatto. + +**Problema noto: "requires a newer version of Codex".** Se il modello predefinito del tuo `~/.codex/config.toml` richiede un Codex più recente, `codex exec` fallisce con quell'errore (l'app lo mostra nella chat). Aggiorna con `codex update`, oppure scegli un altro modello nelle Impostazioni dell'app. Controllato su questa macchina il 2026-10-15 con Codex 0.144.3. + +## Aggiornare un CLI dall'app +Nel riquadro Impostazioni, sezione **Aggiornamenti**, l'app controlla da sola, all'avvio e all'apertura del riquadro, se c'è una versione più recente (Claude Code: `downloads.claude.ai`; Codex: release di GitHub): se c'è, la riga dice "Aggiornamento disponibile", il pulsante **Aggiorna** si evidenzia e compare un puntino sul pulsante Impostazioni. Il pulsante esegue `claude update` o `codex update` (controllano e installano solo se c'è una versione nuova). Nulla si aggiorna senza un clic. Se non va a buon fine l'app mostra l'errore e il comando manuale, che dipende da come hai installato il CLI: + +| Installazione | Claude Code | Codex | +|---|---|---| +| npm | `npm install -g @anthropic-ai/claude-code@latest` | `npm install -g @openai/codex@latest` | +| Homebrew | `brew upgrade claude-code` | `brew upgrade --cask codex` | +| WinGet | `winget upgrade Anthropic.ClaudeCode` | reinstalla con lo script PowerShell | +| Nativo / script | `claude update` | `codex update` (o rilancia lo script di installazione) | + +Dettagli e decisioni: [ADR-0004](adr/0004-aggiornamento-dei-cli.md). + +## Controllo finale per l'app +Dallo stesso terminale da cui avvii l'app: +``` +command -v claude codex # Linux/WSL; in PowerShell: Get-Command claude, codex +claude -p "rispondi solo: ok" +codex exec "rispondi solo: ok" +``` +Se i comandi rispondono, l'app li trova. Se l'app dice "'claude' non trovato nel PATH": +- apri un nuovo terminale (il PATH si aggiorna solo ai nuovi); +- controlla che la cartella d'installazione sia nel PATH: per Claude Code nativo e per Codex con script è `~/.local/bin` su Linux/macOS/WSL, `%USERPROFILE%\.local\bin` su Windows (per Claude Code); +- verifica di non aver installato in un ambiente diverso da quello dell'app (tabella iniziale). + +## Fonti +- Claude Code: documentazione ufficiale "Advanced setup", consultata il 2026-10-15 (`code.claude.com/docs/en/setup`). +- Codex: README del repository `github.com/openai/codex`, consultato il 2026-10-15. La pagina openai.com/it-IT/codex/ non era raggiungibile (HTTP 403). +- Comandi `--version`, `login status`, `auth status` e `exec --help`: provati sulla macchina descritta sopra. +- Antigravity CLI (`agy`): non ancora trattato, da aggiungere quando verrà integrato. diff --git a/docs/MVP.md b/docs/MVP.md index a89e052..f7ef0ca 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -5,58 +5,62 @@ 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. +Tutti gli argomenti sono uniti in un unico prompt. Con `--engine ` come primo argomento (`claude` o `codex`) si sceglie il motore, per esempio ` --engine codex "spiega questo progetto"`; senza, si usa Claude Code. Id sconosciuto o prompt mancante: uso su stderr ed exit code 2. ## Comportamento 1. Se non ci sono argomenti: stampa l'uso ed esce con codice 2. -2. Avvia `claude -p ` (modalità non interattiva) con `WorkingDirectory = AppContext.BaseDirectory`. +2. Avvia il motore scelto in modalità non interattiva (`claude -p ` o `codex exec … -- `) con `WorkingDirectory = AppContext.BaseDirectory`. 3. Ritrasmette stdout e stderr del processo figlio in tempo reale. -4. Esce con lo stesso exit code di `claude`. -5. Se `claude` non è trovato: messaggio chiaro ed exit code 127. +4. Esce con lo stesso exit code del motore. **Su Windows** l'eseguibile non ha finestra di console ([ADR-0005](adr/0005-windows-senza-console.md)): `cmd` e PowerShell non aspettano la fine del programma, quindi l'exit code è disponibile solo se il chiamante attende il processo (`start /wait`, `Start-Process -Wait`, `| Out-Host`, script). +5. Se l'eseguibile del motore non è trovato: messaggio chiaro ed exit code 127. ## GUI (chat) -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. +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). -**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. -- **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. -- Passati come `--model` / `--effort`; con "Predefinito" il flag viene omesso. Solo GUI. +**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): 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. +- **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. +- **Aggiornamenti** (nello stesso riquadro, sotto le impostazioni): una riga per ogni CLI installato con nome, versione e pulsante **Aggiorna**. L'app **controlla prima** se esiste una versione più recente (all'avvio, all'apertura del riquadro se l'ultimo controllo ha più di 5 minuti, e dopo ogni aggiornamento): se c'è, la riga dice "Aggiornamento disponibile: X", il pulsante è evidenziato e un puntino compare sul pulsante Impostazioni; se no, la riga dice "Aggiornato" e nulla è evidenziato; senza rete "Controllo non riuscito". Il pulsante esegue il comando ufficiale ` update` e mostra l'esito sulla riga: "Aggiornato da X a Y", "Già aggiornato (X)" o l'errore con il comando manuale adatto al metodo di installazione (npm, Homebrew, WinGet, nativo). Un solo aggiornamento alla volta; i pulsanti sono disabilitati durante una risposta. Nessun aggiornamento parte da solo. Vedi [ADR-0004](adr/0004-aggiornamento-dei-cli.md). +- **Motori** ([ADR-0002](adr/0002-motori-cli-claude-codex-antigravity.md)): una chat usa un solo motore. Cambiare **CLI** apre una nuova chat con quel motore; modello ed effort tornano ai valori iniziali del nuovo motore (il modello più piccolo ed effort `low`) e le liste dipendono dal motore (Codex: modelli GPT-5.6 Sol/Terra/Luna, stessi livelli di effort). Aprendo una chat salvata il selettore passa al motore di quella chat. L'etichetta sulle risposte, il segnaposto del composer e il messaggio d'attesa usano il nome del motore, e l'elenco chat lo indica sotto il titolo. Con Codex la sessione è letta dall'output (`thread_id`) e usata per riprendere la chat; i permessi sono di sola lettura. - **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`, 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, motore (`Engine`) e id di sessione del motore (`SessionId`); i file senza questi campi sono chat di Claude Code. 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 -- .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 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). ## 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, 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. +- [ ] L'exit code è propagato (su Windows, quando il chiamante attende il processo: vedi sopra). - [ ] Un prompt con virgolette e spazi arriva intatto. ## Domande aperte diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md new file mode 100644 index 0000000..4d219cb --- /dev/null +++ b/docs/PROJECT_STATE.md @@ -0,0 +1,56 @@ +# Stato del progetto + +> **Stadio: MVP** (prima di una versione 1.0). Ultimo aggiornamento: 2026-10-15. +> Questo documento è la fotografia dello stato, non la fonte di dettagli: i passi sono in [ROADMAP.md](ROADMAP.md), i requisiti in [MVP.md](MVP.md), le decisioni negli ADR (indice in [README.md](README.md)), i test in [TESTING.md](TESTING.md). Qui si legge a colpo d'occhio dove siamo; per il resto si segue il collegamento. + +## In una frase +`agent-wiki-ui` è un eseguibile C# (.NET 10) per Windows e Linux che inoltra prompt a Claude Code e Codex già installati, da riga di comando o da una GUI di chat. La parte funzionale dell'MVP c'è; mancano i test automatici e alcune verifiche su macchine reali. + +## Stato per area +| Area | Stato | Note | +|---|---|---| +| CLI (`agent-wiki-ui "prompt"`, `--engine`) | Funzionante | Exit code e 127 provati su Linux; su Windows l'exit code arriva solo se il chiamante attende il processo ([ADR-0005](adr/0005-windows-senza-console.md)). | +| GUI di chat (Avalonia) | Funzionante | Barra laterale, cronologia su disco, markdown, tema chiaro/scuro, Impostazioni. Provata con screenshot headless e su WSLg; non su Windows nativo. | +| Motori | Claude Code e Codex | Una chat = un motore; sessione di Codex letta dall'output. Antigravity non iniziato ([ADR-0002](adr/0002-motori-cli-claude-codex-antigravity.md)). | +| Aggiornamento dei CLI | Funzionante, non provato in reale | Controllo preventivo e pulsante evidenziato; l'`update` vero non è mai stato lanciato dall'app ([ADR-0004](adr/0004-aggiornamento-dei-cli.md)). | +| Eseguibili standalone | Funzionanti | `win-x64` e `linux-x64` creati da entrambi i sistemi con `scripts/publish.sh` / `.ps1` (circa 47 MB). Windows senza console al doppio clic. | +| Test automatici | **Non iniziati** | Piano e matrice pronti ([ADR-0003](adr/0003-strategia-di-test.md)); nessun test scritto. Vedi debiti. | +| Documentazione | Allineata al codice | Doc-first; ogni funzione ha le righe della matrice in TESTING.md. | + +## Criteri di accettazione dell'MVP +Da [MVP.md](MVP.md). Restano non spuntati finché non hanno test automatici (regola di [TESTING.md](TESTING.md)). + +| Criterio | Verifica a mano | Test automatico | +|---|---|---| +| `claude` parte nella cartella dell'exe | Sì: Linux e Windows (da `C:\Windows` il motore vede la cartella dell'exe) | No (CL-04) | +| Output in streaming nella CLI | Output ereditato dal motore; non misurato | No (CL-08) | +| Exit code propagato | Linux sì; Windows solo con attesa del processo | No (CL-06) | +| Prompt con virgolette e spazi intatto | Sì, con un `claude` finto su Linux | No (CL-01) | + +## Debiti noti +- **Test automatici assenti** (Fase 6): contraddice la regola "nuova funzione = nuovi test" per tutto il codice scritto finora. È il debito principale. +- **Verifiche su macchine reali**: GUI su Windows nativo e su una distribuzione Linux pulita; doppio clic senza console e riaggancio al terminale (ADR-0005); tema che segue il sistema su Linux; `update` reale dei CLI. +- **Impostazioni non salvate su disco**: modello, effort e motore tornano ai valori iniziali a ogni avvio (si parte dal modello più piccolo con effort `low`). +- **Nessuno streaming nella GUI**: la risposta compare a fine generazione. +- **Elenchi dei modelli fissi** (Claude Code e Codex): da aggiornare a mano; il "modello più piccolo" di Codex è un'assunzione (GPT-5.6 Luna). + +## Decisioni +| ADR | Stato | +|---|---| +| [0001](adr/0001-gui-multipiattaforma-avalonia.md) GUI Avalonia multipiattaforma | Accettata | +| [0002](adr/0002-motori-cli-claude-codex-antigravity.md) Più motori CLI | Accettata (Claude Code, Codex) | +| [0003](adr/0003-strategia-di-test.md) Strategia di test | Proposta | +| [0004](adr/0004-aggiornamento-dei-cli.md) Aggiornamento dei CLI | Proposta | +| [0005](adr/0005-windows-senza-console.md) Windows senza console | Proposta | + +## Per chiudere l'MVP +Proposta, derivata dai punti aperti sopra; da confermare. +1. Test automatici per `Core/` e `MainViewModel` e per i quattro criteri di accettazione (Fase 6, prime righe di CL, ST, VM, CX). +2. Gli ADR 0003, 0004 e 0005 passati ad "Accettata" dopo le loro verifiche. +3. GUI provata su Windows nativo e su Linux pulito. +4. Criteri di accettazione di MVP.md spuntati. + +Oltre l'MVP (non bloccanti): Antigravity, impostazioni persistite, streaming nella GUI, rinomina e ricerca delle chat, CI con matrice Windows e Linux, firma dell'eseguibile Windows. + +## Come tenere aggiornato questo file +Si aggiorna nello stesso commit di ogni cambio di stadio, di funzione, di ADR o di debito: stato per area, criteri, debiti, tabella delle decisioni, data. Se una riga qui contraddice ROADMAP.md o un ADR, vale quello e questa pagina va corretta. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..29abd01 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,54 @@ +# 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 | +| [PROJECT_STATE.md](PROJECT_STATE.md) | Stato del progetto a colpo d'occhio: stadio (MVP), stato per area, debiti, decisioni, cosa manca per chiudere l'MVP | +| [MVP.md](MVP.md) | Specifica funzionale, design UI/UX, criteri di accettazione | +| [INSTALL.md](INSTALL.md) | Come installare e autenticare i motori (Claude Code, Codex), verifica e problemi comuni | +| [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) | +| [../AGENT.md](../AGENT.md) | Istruzioni per gli agenti di codice (Claude Code, Gemini CLI, altri): comandi, architettura del codice, vincoli. Unica sorgente; `CLAUDE.md` e `GEMINI.md` sono solo puntatori | + +## Nome del progetto +Il nome è **`agent-wiki-ui`** (prima `LlmRun`). Questa sezione è l'unica sorgente: gli altri doc scrivono "l'app" o ``. + +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 [../AGENT.md](../AGENT.md) (nome del csproj nei comandi) + +## Istruzioni per gli agenti di codice +Le istruzioni per chi lavora sul repository con un assistente (Claude Code, Gemini CLI, altri) stanno **solo in [../AGENT.md](../AGENT.md)**. Gli strumenti cercano file con nomi propri, quindi esistono due file puntatori che contengono una riga di importazione e nient'altro: + +| File | Letto da | Contenuto | +|---|---|---| +| [../CLAUDE.md](../CLAUDE.md) | Claude Code | `@AGENT.md` (importazione) | +| [../GEMINI.md](../GEMINI.md) | Gemini CLI | `@AGENT.md` (importazione) | + +Non si scrive nulla nei puntatori: ogni modifica va in AGENT.md. Per un altro strumento si aggiunge un puntatore con il nome che quello strumento cerca (per esempio `AGENTS.md`, usato da Codex), senza copiare il contenuto. Il nome `AGENT.md` è quello scelto per il progetto, non uno standard. + +## Flusso (doc-first, doc-as-code) +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 → PROJECT_STATE → README → AGENT.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. + +## 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 | Accettata | +| [0002](adr/0002-motori-cli-claude-codex-antigravity.md) | Più motori CLI (Claude Code, Codex, Antigravity) su Windows e Linux | Accettata (Claude Code, Codex) | +| [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 | +| [0005](adr/0005-windows-senza-console.md) | Windows: eseguibile senza finestra di console | Proposta | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 70ca0a6..5e897ec 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Roadmap -Dettagli dell'MVP in [MVP.md](MVP.md). +Dettagli dell'MVP in [MVP.md](MVP.md). Stadio attuale e debiti: [PROJECT_STATE.md](PROJECT_STATE.md) (siamo in fase di **MVP**). ## Fase 0 — Doc-first - [x] Scrivere MVP.md e ROADMAP.md @@ -23,35 +23,53 @@ 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) -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. Il punto unico di avvio processo l'ha estratto la Fase 5. -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): +- [x] Verificare i flag reali di `codex` (provati su Linux/WSL, ADR-0002 accettata per Claude Code e Codex); `agy` e Windows da verificare +- [x] Astrazione "motore" in C# (`Core/Engine.cs`, `Core/EngineRunner.cs`) +- [x] Rilevamento dei motori nel PATH; motori assenti disabilitati, messaggio chiaro +- [x] Il JSON di ogni chat memorizza il motore e l'id di sessione; cambiare motore apre una nuova chat +- [x] GUI: interruttore CLI nel riquadro Impostazioni; modelli ed effort in base al motore; motore della chat indicato nell'interfaccia +- [ ] Impostazioni (motore, modello, effort) persistite su disco +- [x] Motore Codex: lettura dell'id di sessione dall'output (`thread_id`) per la ripresa +- [ ] Motore Antigravity: gestione del blocco con output rediretto (timeout, errore chiaro) +- [x] Permessi di Codex in sola lettura (nessun flag che salta i controlli) +- [ ] Opzione esplicita per concedere permessi di scrittura +- [x] CLI: opzione `--engine`, coerente con la GUI +- [x] Aggiornamento dei CLI dalle Impostazioni, un pulsante per CLI con `update` ufficiale ed esito leggibile ([ADR-0004](adr/0004-aggiornamento-dei-cli.md)) +- [x] Controllo preventivo della versione più recente (sorgenti ufficiali, cache di 5 minuti); pulsante Aggiorna evidenziato e puntino sul pulsante Impostazioni solo se c'è una versione nuova +- [ ] Provare gli aggiornamenti reali (finora provati solo con CLI finti, e rilevamento del metodo di installazione sui percorsi reali) su Linux e Windows (installazione nativa e npm) e portare l'ADR-0004 ad "Accettata" -| | 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. +- [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 dei motori (`Core/EngineRunner.cs`: cartella dell'exe, stdin chiuso), prima 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` +- [x] Windows: eseguibile senza finestra di console, la CLI riaggancia il terminale padre ([ADR-0005](adr/0005-windows-senza-console.md)); da provare su Windows nativo +- [ ] 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 AGENT.md (già CLAUDE.md: comandi, architettura, vincoli) a migrazione conclusa -**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). +## 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. -**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. +- [ ] 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..d444c2f --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,245 @@ +# 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/AGENT.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`) +- [ ] `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) +- [ ] `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 dei motori — `Core/EngineRunner.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: eseguibile del motore assente ⇒ messaggio chiaro (con il nome dell'eseguibile) 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` | [ ] | + +### Codex — `Core/Engine.cs` (parser e argomenti), `Core/EngineRunner.cs` +Le righe CL valgono per ogni motore; queste sono specifiche di Codex (ADR-0002). Il parser si prova con output JSONL reali salvati nei test. +| ID | Requisito | Liv. | Test | | +|---|---|---|---|---| +| CX-01 | Primo messaggio: `exec --json --skip-git-repo-check -s read-only … -- ` | U | `Codex_primo_messaggio` | [ ] | +| CX-02 | Ripresa: `exec resume --json … -c sandbox_mode="read-only" -- `, senza `-s` | U | `Codex_ripresa` | [ ] | +| CX-03 | Un prompt che inizia con `-` arriva intatto (grazie a `--`) | U/P | `Codex_prompt_con_trattino` | [ ] | +| CX-04 | Modello ⇒ `-m`; effort ⇒ `-c model_reasoning_effort=`; vuoti ⇒ omessi | U | `Codex_modello_ed_effort` | [ ] | +| CX-05 | Il parser legge `thread_id` da `thread.started` | U | `Codex_thread_id` | [ ] | +| CX-06 | La risposta è l'ultimo `agent_message`, anche con più messaggi | U | `Codex_ultimo_agent_message` | [ ] | +| CX-07 | `turn.failed`: messaggio interno di `error.message` estratto dal JSON annidato; exit ≠ 0 ⇒ errore | U | `Codex_errore_turno` | [ ] | +| CX-08 | Eventi `item.completed` di tipo `error` (avvisi) non fanno fallire la risposta | U | `Codex_avvisi_ignorati` | [ ] | +| CX-09 | Exit 0 senza `agent_message` ⇒ errore "nessuna risposta" | U | `Codex_nessuna_risposta` | [ ] | +| CX-10 | Righe non JSON o eventi sconosciuti sono ignorati senza eccezioni | U | `Codex_righe_non_json` | [ ] | +| CX-11 | In modalità CLI (output ereditato) non si usa `--json` | U | `Codex_cli_senza_json` | [ ] | +| CX-12 | Con un `FakeClaude` configurato come Codex il ciclo primo messaggio + ripresa funziona (id letto e riusato) | P | `Codex_ciclo_completo_con_finto` | [ ] | + +### Aggiornamento dei CLI — `Core/EngineUpdater.cs`, `Gui/MainViewModel.cs` (ADR-0004) +Si provano con `FakeClaude`/`FakeCodex` che rispondono a `--version` e `update`; nessun test aggiorna un CLI reale. +| ID | Requisito | Liv. | Test | | +|---|---|---|---|---| +| UP-01 | Versione letta da `--version` (prima riga ripulita); eseguibile assente ⇒ versione nulla | P | `Versione_letta` | [ ] | +| UP-02 | Versione cambiata dopo `update` ⇒ esito "Aggiornato da X a Y" | P | `Aggiornato` | [ ] | +| UP-03 | Exit 0 e versione invariata ⇒ "Già aggiornato (X)" | P | `Gia_aggiornato` | [ ] | +| UP-04 | Exit ≠ 0 ⇒ errore con le ultime righe dell'output e il comando manuale | P | `Aggiornamento_fallito` | [ ] | +| UP-05 | Tempo massimo superato ⇒ processo terminato ed errore chiaro | P | `Aggiornamento_scaduto` | [ ] | +| UP-06 | Eseguibile assente ⇒ riga non offerta / errore "non trovato" | U | `Aggiornamento_senza_eseguibile` | [ ] | +| UP-07 | Metodo di installazione dedotto dal percorso reale (npm, Homebrew, WinGet, nativo) con collegamenti simbolici risolti | U | `Metodo_di_installazione` | [ ] | +| UP-08 | Comando manuale corretto per ogni coppia motore × metodo (tabella dell'ADR-0004) | U | `Comando_manuale` | [ ] | +| UP-09 | Un solo aggiornamento alla volta; durante una risposta in corso i pulsanti sono disabilitati e la chat non parte durante l'aggiornamento | U | `Aggiornamento_esclusivo` | [ ] | +| UP-10 | Dopo un aggiornamento riuscito le versioni mostrate si rileggono; la chat aperta resta invariata | U | `Versioni_rilette` | [ ] | +| UP-11 | Le righe di aggiornamento compaiono solo per i CLI installati | U/G | `Righe_solo_installati` | [ ] | +| UP-12 | Il pulsante Aggiorna del riquadro agisce solo sul CLI della sua riga | G | `Pulsante_per_riga` | [ ] | +| UP-13 | Nessun aggiornamento parte senza un clic | U | `Nessun_aggiornamento_automatico` | [ ] | +| UP-14 | Claude Code: l'ultima versione si legge dal testo di `…/claude-code-releases/latest` (spazi e a capo ripuliti) | U | `Ultima_versione_claude` | [ ] | +| UP-15 | Codex: l'ultima versione si legge da `tag_name` della release GitHub (`rust-v0.162.1` ⇒ 0.162.1); intestazione User-Agent inviata | U | `Ultima_versione_codex` | [ ] | +| UP-16 | Confronto: ultima > installata ⇒ disponibile; uguale o più nuova ⇒ aggiornato; versioni con testo attorno (`2.1.300 (Claude Code)`, `codex-cli 0.162.1`) lette bene | U | `Confronto_versioni` | [ ] | +| UP-17 | Rete assente, risposta non valida o timeout ⇒ stato "sconosciuto", nessuna eccezione, pulsante normale | U | `Controllo_senza_rete` | [ ] | +| UP-18 | Disponibile ⇒ riga con "Aggiornamento disponibile: X", pulsante evidenziato e puntino sul pulsante Impostazioni; aggiornato ⇒ nessuno dei tre | U/G | `Evidenziazione_aggiornamento` | [ ] | +| UP-19 | Il controllo parte all'avvio e all'apertura del riquadro solo se l'ultimo ha più di 5 minuti; dopo un aggiornamento riuscito si ripete; non blocca invio né interfaccia | U | `Quando_si_controlla` | [ ] | +| UP-20 | Il controllo non installa nulla e non modifica i CLI | P | `Controllo_in_sola_lettura` | [ ] | + +### 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-12 | Il JSON salva `Engine` e `SessionId`; un file senza questi campi si legge come chat di Claude Code con `SessionId` = id | U | `Motore_e_sessione_salvati_e_compatibilita` | [ ] | +| 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-20 | Valori iniziali: modello più piccolo del motore (Claude Code: Haiku 5.5; Codex: GPT-5.6 Luna) ed effort `low`; il primo invio passa `--model`/`--effort` (o `-m`/`-c model_reasoning_effort`) con quei valori | U | `Valori_iniziali` | [ ] | +| 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-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 (logo, titolo, sottotitolo, nessun suggerimento) | 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 | Nel riquadro Impostazioni 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-16 | Il pulsante Impostazioni (in alto a destra) apre il riquadro con Modello ed Effort; `Esc` lo chiude | G | `Riquadro_impostazioni` | [ ] | +| UI-17 | Il composer non contiene selettori di modello ed effort | G | `Composer_senza_selettori` | [ ] | +| UI-18 | Le scelte fatte nel riquadro arrivano ai flag `--model`/`--effort` del messaggio successivo | G/U | `Impostazioni_applicate_all_invio` | [ ] | +| UI-15 | Il nome dell'app compare nel titolo della finestra e nella barra laterale | G | `Nome_app_visibile` | [ ] | + +### Eseguibile pubblicato +Righe WIN: console su Windows (ADR-0005). +| ID | Requisito | Liv. | Test | | +|---|---|---|---|---| +| WIN-01 | L'eseguibile `win-x64` pubblicato ha sottosistema "Windows GUI" (intestazione PE); quello `linux-x64` non è interessato | E | `Sottosistema_gui` | [ ] | +| WIN-02 | CLI con output reindirizzato su file o pipe: l'output arriva, handle ereditati non toccati | E | `Cli_con_output_reindirizzato` | [ ] | +| WIN-03 | CLI da un terminale: output visibile (`AttachConsole` + `CONOUT$`); senza terminale padre l'attacco fallisce in silenzio e l'app prosegue | E (manuale su Windows nativo) | `Cli_con_terminale_padre` | [ ] | +| WIN-04 | Exit code corretto quando il chiamante attende il processo (`start /wait`, `Start-Process -Wait`) | E (manuale su Windows nativo) | `ExitCode_con_attesa` | [ ] | +| WIN-05 | Il codice di `ConsoleHost` non fa nulla su Linux/macOS | U | `ConsoleHost_non_windows` | [ ] | + +| 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 (Claude: CL-02; Codex: CX-01, CX-02) | U | [ ] | +| EN-02 | Rilevamento nel PATH; assente ⇒ disabilitato e messaggio chiaro | U/P | [ ] | +| EN-03 | Lettura dell'id di sessione dall'output (Codex: CX-05) | 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 | [ ] | +| EN-10 | `Engines.Find` per id (`claude`, `codex`, sconosciuto ⇒ nullo); l'id salvato nei JSON resta stabile | U | [ ] | +| EN-11 | Cambiare CLI nelle Impostazioni apre una nuova chat con quel motore; modello ed effort tornano ai valori iniziali del nuovo motore (modello più piccolo, `low`); liste secondo il motore | U | [ ] | +| EN-12 | Motore non installato: etichetta "non installato", non selezionabile; il VM rifiuta la selezione | U/G | [ ] | +| EN-13 | L'interruttore CLI è disabilitato durante l'attesa | U/G | [ ] | +| EN-14 | Aprendo una chat salvata il selettore passa al motore della chat; sessione e `started` coerenti | U | [ ] | +| EN-15 | Etichetta autore, segnaposto, messaggio d'attesa ed elenco chat mostrano il nome del motore | U/G | [ ] | +| EN-16 | Dopo il primo invio con Codex il `SessionId` è quello letto dall'output e il secondo invio usa la ripresa; con errore al primo invio resta senza sessione | U | [ ] | +| EN-17 | CLI `--engine "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 | [ ] | + +## 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/0001-gui-multipiattaforma-avalonia.md b/docs/adr/0001-gui-multipiattaforma-avalonia.md new file mode 100644 index 0000000..4c464dd --- /dev/null +++ b/docs/adr/0001-gui-multipiattaforma-avalonia.md @@ -0,0 +1,61 @@ +# ADR-0001 — GUI multipiattaforma in solo C# con Avalonia + +- **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) + +## 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 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)). +- 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 `AGENT.md` (già `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") +- [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. +- 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..8bcacb0 --- /dev/null +++ b/docs/adr/0002-motori-cli-claude-codex-antigravity.md @@ -0,0 +1,63 @@ +# ADR-0002 — Più motori CLI (Claude Code, Codex, Antigravity) su Windows e Linux + +- **Stato**: Accettata per Claude Code e Codex (verificati con prove reali); Antigravity resta da verificare +- **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` è diventato `EngineRunner` con il motore come parametro. +- **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: interruttore **CLI** (motore attivo), modello, effort. Cambiare motore apre una nuova chat; modello ed effort tornano ai valori iniziali del motore (il modello più piccolo ed effort `low`; "Predefinito" resta selezionabile) e le liste sono quelle del motore. La persistenza su disco delle impostazioni è rinviata. La CLI ottiene `--engine ` (`claude`, `codex`). +- **Sessione**: per Claude Code l'id lo sceglie l'app; per Codex lo genera Codex e l'app lo legge dall'output (`thread_id`). Il JSON della chat ha quindi un campo `Engine` e un campo `SessionId` separato dall'id della chat (che resta il nome del file). I file scritti prima (senza questi campi) si leggono come chat di Claude Code con `SessionId` = id della chat. +- **Autore dei messaggi**: l'etichetta sulle risposte e i segnaposto del composer usano il nome del motore della chat; l'elenco chat indica il motore. +- **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 +Claude Code e Codex sono verificati con prove reali (Codex 0.144.3, 2026-10-15, Linux/WSL). Antigravity deriva da documentazione e ricerche: `agy` non è installato su questa macchina. + +| | Claude Code | Codex CLI | Antigravity CLI | +|---|---|---|---| +| Comando (GUI) | `claude -p ` | `codex exec --json --skip-git-repo-check -s read-only [opzioni] -- ` | `agy -p ` (da verificare) | +| Continuare | `--session-id `, poi `--resume ` | `codex exec resume --json --skip-git-repo-check -c 'sandbox_mode="read-only"' [opzioni] -- ` | da verificare | +| Modello | `--model` | `-m` / `--model` | da verificare | +| Effort | `--effort` | `-c model_reasoning_effort=` | non noto | +| Output usato | testo su stdout | JSONL su stdout (vedi sotto) | da verificare | + +**Codex: esito delle prove** +- L'id di sessione non si sceglie: compare nel primo evento JSONL `{"type":"thread.started","thread_id":""}`. Nel testo normale compare solo nell'intestazione su stderr. +- Con `--json`, stdout contiene solo eventi: `thread.started`, `turn.started`, `item.completed` (con `item.type = "agent_message"` e il testo della risposta; con `"error"` sono avvisi non fatali), `turn.completed`, e in caso di errore `error` e `turn.failed`. Il messaggio di errore è una stringa che contiene a sua volta un JSON (`error.message`): l'app estrae il messaggio interno. L'exit code è 1 se il turno fallisce. +- La risposta mostrata è l'**ultimo** `agent_message` del turno (come `--output-last-message`). +- `exec resume` **non accetta `-s/--sandbox`**: la sola lettura si ottiene con `-c 'sandbox_mode="read-only"'`, verificato (un file richiesto in scrittura non viene creato). Accetta `-m` e `-c`. +- `--skip-git-repo-check` serve perché la cartella dell'exe non è un repository Git. +- `--` prima del prompt evita che un prompt che inizia con `-` sia letto come opzione (verificato). +- Lo stdin va chiuso subito, come per Claude Code: altrimenti Codex stampa "Reading additional input from stdin…" e attende. +- Il modello predefinito è quello di `~/.codex/config.toml`. Se richiede una versione più recente di Codex l'errore è chiaro (`requires a newer version of Codex`): l'app lo mostra e [INSTALL.md](../INSTALL.md) indica `codex update`. +- Il modello più piccolo di Codex è assunto essere GPT-5.6 Luna (nome e supporto agli effort inferiori: non dichiarato da OpenAI nelle prove); da rivedere quando cambia il catalogo. +- Il catalogo modelli di Codex è dinamico (`~/.codex/models_cache.json`); l'app ne usa uno fisso, da aggiornare, con "Predefinito" e "Altro ID…" sempre disponibili. + +## 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 +- [x] **Codex**: l'id di sessione lo genera Codex; letto dall'evento `thread.started` (vedi sopra). +- [ ] **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. +- [x] Formato dell'output di Codex: JSONL, normalizzato in testo markdown. Antigravity ancora da verificare. +- [x] Mappatura effort e modelli per Codex (liste fisse, da aggiornare). Antigravity da verificare. + +## Riferimenti +- Passi: [ROADMAP.md](../ROADMAP.md), Fase 4. +- Installazione e accesso dei motori: [INSTALL.md](../INSTALL.md). diff --git a/docs/adr/0003-strategia-di-test.md b/docs/adr/0003-strategia-di-test.md new file mode 100644 index 0000000..fd52e41 --- /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 dei motori sta dietro un'interfaccia (`IEngineRunner`) iniettata nel ViewModel; l'implementazione reale resta in `Core/EngineRunner.cs`. I test del ViewModel usano un finto runner; quelli di `EngineRunner` usano `FakeClaude` (che con la configurazione giusta imita anche Codex). + - 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. diff --git a/docs/adr/0004-aggiornamento-dei-cli.md b/docs/adr/0004-aggiornamento-dei-cli.md new file mode 100644 index 0000000..a368dcb --- /dev/null +++ b/docs/adr/0004-aggiornamento-dei-cli.md @@ -0,0 +1,72 @@ +# ADR-0004 — Aggiornamento dei CLI dalle Impostazioni + +- **Stato**: Proposta +- **Data**: 2026-10-15 +- **Collegato a**: [ADR-0002](0002-motori-cli-claude-codex-antigravity.md) (motori), [INSTALL.md](../INSTALL.md) + +## Contesto +L'app usa i CLI già installati dall'utente. Un CLI vecchio può smettere di funzionare senza che l'app c'entri: su questa macchina Codex 0.144.3 ha come modello predefinito (`~/.codex/config.toml`) `gpt-6-astra`, che richiede un Codex più recente, e ogni messaggio con "Predefinito" fallisce con `requires a newer version of Codex`. L'unico rimedio è aggiornare il CLI, oggi da terminale, e l'utente non sa nemmeno che esiste una versione nuova. + +Sulla macchina di sviluppo il 2026-10-15 i due CLI sono stati poi aggiornati (Codex 0.144.3 → 0.162.1, Claude Code 2.1.252 → 2.1.300) e Codex con il modello predefinito risponde: il problema si risolve aggiornando. Resta il bisogno di saperlo e di farlo dall'app. + +Entrambi i CLI hanno un comando di aggiornamento che controlla e, se c'è una versione nuova, la installa: `claude update` (alias `upgrade`: "Check for updates and install if available") e `codex update` ("Update Codex to the latest version"). Verificato con `--help` il 2026-10-15; l'esecuzione reale non è ancora stata provata. + +## Criteri di decisione +1. L'app dice se c'è una versione nuova **prima** di proporre l'aggiornamento: evidenzia solo dove serve. +1. L'utente aggiorna un CLI alla volta, senza uscire dall'app. +2. L'app non tocca nulla senza un gesto esplicito (nessun aggiornamento automatico). +3. Si usa il comando ufficiale del CLI, non una procedura inventata dall'app. +4. Se l'aggiornamento non è possibile (installazione via gestore di pacchetti, permessi, rete) l'utente vede il motivo e il comando manuale giusto. +5. Funziona su Windows e Linux e non blocca la GUI. + +## Decisione +- Nel riquadro Impostazioni, sezione **Aggiornamenti**: una riga per ogni CLI installato con nome, versione e pulsante **Aggiorna**. Ogni pulsante agisce solo su quel CLI. +- **Controllo prima dell'aggiornamento** (sola lettura, nessuna installazione): l'app confronta la versione installata (` --version`) con l'ultima pubblicata. + - Claude Code: `GET https://downloads.claude.ai/claude-code-releases/latest`, testo con la versione (la stessa sorgente dell'installer nativo). + - Codex: `GET https://api.github.com/repos/openai/codex/releases/latest`, campo `tag_name` (es. `rust-v0.162.1`, si estrae `0.162.1`). + - Le versioni si ricavano con il primo numero `N.N.N` del testo e si confrontano come versioni. Verificate il 2026-10-15: restituivano 2.1.300 e 0.162.1. + - **Disponibile** (ultima > installata): la riga mostra "Aggiornamento disponibile: X", il pulsante Aggiorna è **evidenziato** (colore d'accento) e un puntino d'accento compare sul pulsante Impostazioni. **Aggiornato** (uguale o più nuova): riga neutra con "Aggiornato", pulsante normale, nessun puntino; resta cliccabile per forzare `update`. **Sconosciuto** (rete assente, risposta non valida, timeout di 10 s): "Controllo non riuscito", pulsante normale, nessun errore invasivo. + - Quando si controlla: all'avvio, in background, e ogni volta che si apre il riquadro Impostazioni se l'ultimo controllo ha più di 5 minuti; subito dopo un aggiornamento. Mai in modo da rallentare la chat. Le richieste non inviano identificativi; l'app contatta solo i due indirizzi sopra. +- **Flusso** di un clic: legge la versione (` --version`), lancia ` update` (stdin chiuso, output catturato, tempo massimo 10 minuti), rilegge la versione e mostra l'esito sulla riga: + - versione cambiata ⇒ "Aggiornato da X a Y"; + - exit 0 e versione invariata ⇒ "Già aggiornato (X)"; + - exit ≠ 0, tempo scaduto o eseguibile assente ⇒ errore con le ultime righe dell'output e il **comando manuale** adatto (sotto). +- Pulsanti disabilitati mentre una risposta è in corso o un altro aggiornamento è attivo; la chat non parte durante l'aggiornamento. +- **Comando manuale** scelto dal metodo di installazione, dedotto dal percorso reale dell'eseguibile (collegamenti simbolici risolti): + + | Percorso contiene | Metodo | Claude Code | Codex | + |---|---|---|---| + | `node_modules`, `.npm` | npm | `npm install -g @anthropic-ai/claude-code@latest` | `npm install -g @openai/codex@latest` | + | `Cellar`, `homebrew`, `linuxbrew` | Homebrew | `brew upgrade claude-code` (o `claude-code@latest`) | `brew upgrade --cask codex` | + | `WinGet`, `WindowsApps` | WinGet | `winget upgrade Anthropic.ClaudeCode` | script PowerShell di [INSTALL.md](../INSTALL.md) | + | altro | nativo/script | `claude update` da terminale | script di installazione di [INSTALL.md](../INSTALL.md) | + + L'app **mostra** il comando, non lo esegue: un'installazione globale con npm o un gestore di pacchetti può richiedere permessi o scelte che spettano all'utente. +- Dopo un aggiornamento riuscito le versioni mostrate si rileggono; la chat aperta non cambia. +- L'aggiornamento di un CLI non rilancia la chat in corso: l'eventuale errore di versione già mostrato resta nella cronologia. + +## Opzioni considerate +| Opzione | Esito | Motivo | +|---|---|---| +| Solo documentare `codex update` | Scartata | L'utente deve uscire dall'app e conoscere il comando. | +| Solo `update`, senza controllo preventivo | Scartata | Non dice se serve e non può evidenziare il pulsante: `update` controlla ma installa subito. | +| Controllo con `npm view` | Scartata | Richiede npm anche per installazioni native. | +| Aggiornamento automatico all'avvio | Scartata | Modifica programmi dell'utente senza un suo gesto. | +| Fallback automatico a npm/brew/winget | Scartata | Permessi (`sudo`), scelta del canale e rischi di installazioni doppie spettano all'utente. | +| **Controllo con le sorgenti ufficiali (annuncio delle versioni) + pulsante per CLI con `update` ufficiale + comando manuale suggerito** | **Scelta** | Ufficiale, sicura, uguale su Windows e Linux, evidenzia solo quando serve. | + +## Conseguenze +**Positive**: il caso "richiede un Codex più recente" si risolve dall'app; l'esito è comprensibile; nessuna azione nascosta. +**Negative / costi**: il controllo richiede rete e due indirizzi esterni (GitHub ha un limite di richieste anonime, per questo c'è la cache di 5 minuti); il canale scelto da Claude Code (`latest` o `stable`) può dare un "disponibile" che `claude update` non installa, e in quel caso l'esito dopo l'aggiornamento è "Già aggiornato"; l'esito dipende dal testo e dal comportamento dei comandi `update` dei CLI, che possono cambiare; su Windows l'aggiornamento di un eseguibile in uso da un'altra sessione può fallire; un controllo della versione senza installare non c'è. + +## Da verificare +- [ ] Il controllo evidenzia correttamente in tutti i casi (installato più vecchio, uguale, più nuovo di `latest`) e senza rete. +- [ ] Utente sul canale `stable` di Claude Code: il confronto con `latest` dà falsi "disponibile"; valutare di leggere anche `/stable` o `autoUpdatesChannel`. +- [ ] Esecuzione reale di `claude update` e `codex update` su Linux e Windows, con installazione nativa e con npm: esito, exit code e testo. (Su Linux/WSL i due CLI risultano aggiornati alle ultime versioni; non è stato registrato l'output.) +- [ ] Cosa stampano quando sono già aggiornati e quando falliscono per permessi. +- [ ] Che `--version` di entrambi si possa leggere in un secondo dopo l'aggiornamento (nessun file bloccato). +- [ ] Il tempo massimo di 10 minuti è adeguato (rete lenta). + +## Riferimenti +- Passi: [ROADMAP.md](../ROADMAP.md), Fase 4. +- Matrice dei test: [TESTING.md](../TESTING.md), righe UP. diff --git a/docs/adr/0005-windows-senza-console.md b/docs/adr/0005-windows-senza-console.md new file mode 100644 index 0000000..b8fdeca --- /dev/null +++ b/docs/adr/0005-windows-senza-console.md @@ -0,0 +1,45 @@ +# ADR-0005 — Windows: eseguibile senza finestra di console + +- **Stato**: Proposta +- **Data**: 2026-10-15 +- **Collegato a**: [ADR-0001](0001-gui-multipiattaforma-avalonia.md) (GUI e piattaforme) + +## Contesto +Su Windows l'eseguibile è di tipo "console" (`OutputType` `Exe`): con il doppio clic, oltre alla finestra della GUI si apre anche un terminale vuoto che resta aperto finché l'app gira. Il tipo console serve alla CLI (`agent-wiki-ui.exe "prompt"`), perché da terminale `cmd` e PowerShell aspettano la fine del programma, mostrano l'output e danno l'exit code. Un eseguibile "GUI" non ha console, ma allora la CLI perde queste proprietà. Su Linux il problema non esiste: nessuna finestra extra. + +## Criteri di decisione +1. Doppio clic: solo la finestra della GUI. +2. La CLI deve continuare a mostrare l'output nel terminale da cui parte, anche con reindirizzamenti (`> file`, `| altro`). +3. Un solo file da distribuire, come per Linux. +4. Nessun cambiamento per Linux. + +## Opzioni considerate +| Opzione | Esito | Motivo | +|---|---|---| +| Lasciare il tipo console e nascondere la finestra all'avvio (`FreeConsole`) | Scartata | La finestra compare comunque per un istante; con Windows Terminal come terminale predefinito può restare una scheda. | +| Due file: `agent-wiki-ui.exe` (senza console) e `agent-wiki-ui-cli.exe` (con console) | Alternativa valida | Nessuna perdita nella CLI (exit code corretto ovunque), ma due file da distribuire e da spiegare. Da riprendere se l'exit code della CLI diventa importante in Windows. | +| **Un solo `.exe` senza console; la CLI riaggancia il terminale padre** | **Scelta** | Un file, doppio clic pulito, output visibile nel terminale. | + +## Decisione +- Il progetto usa `OutputType` **`WinExe`** (sottosistema Windows). Su Linux non cambia nulla. +- Con **argomenti** (modalità CLI) su Windows, `Core/ConsoleHost.cs` chiama `AttachConsole(ATTACH_PARENT_PROCESS)`: se parte da `cmd`, PowerShell o Windows Terminal l'app usa quel terminale. Se l'output standard non è già valido (non è reindirizzato) lo collega alla console (`CONOUT$`); se è un file o una pipe ereditati li lascia stare, così `> file` e `|` funzionano. Il motore avviato dopo eredita gli stessi handle. +- Senza terminale padre (doppio clic, avvio da un altro programma) l'attacco fallisce in silenzio e l'app prosegue. +- Senza argomenti (GUI) non si tocca la console. + +## Conseguenze +**Positive**: il doppio clic mostra solo la finestra; un unico file; output e reindirizzamenti della CLI funzionano. +**Negative / costi — comportamento della CLI su Windows**: +- `cmd` e PowerShell **non aspettano** la fine di un programma a sottosistema Windows: il prompt torna subito e l'output compare dopo, mescolato al prompt. +- L'**exit code** non arriva a `%ERRORLEVEL%` né a `$LASTEXITCODE` in uso interattivo. Arriva se il chiamante aspetta il processo: `start /wait agent-wiki-ui.exe "prompt"` in `cmd`, `Start-Process -Wait -NoNewWindow` o `& .\agent-wiki-ui.exe "prompt" | Out-Host` in PowerShell, script e programmi che attendono il processo figlio. In pipeline (`| Out-Host`, `| more`) PowerShell attende. +- Su Linux e macOS il comportamento della CLI resta quello di sempre (exit code propagato, streaming). +- Il criterio di accettazione dell'MVP sull'exit code vale quindi, su Windows, solo se il chiamante attende il processo. + +## Da verificare +- [ ] Doppio clic su Windows nativo: nessuna finestra di console. +- [ ] Da `cmd`, PowerShell e Windows Terminal: output visibile, reindirizzamento su file e pipe, exit code con `start /wait` e con `Start-Process -Wait`. +- [ ] Output corretto quando il processo è avviato da un altro programma senza terminale. +- [ ] Il sottosistema dell'eseguibile pubblicato risulta "Windows GUI" (controllato sull'intestazione PE). + +## Riferimenti +- Passi: [ROADMAP.md](../ROADMAP.md), Fase 5. +- Righe di test: [TESTING.md](../TESTING.md), righe WIN. diff --git a/scripts/publish.ps1 b/scripts/publish.ps1 new file mode 100644 index 0000000..5994b18 --- /dev/null +++ b/scripts/publish.ps1 @@ -0,0 +1,20 @@ +# Crea gli eseguibili single-file self-contained in dist\\. +# Senza argomenti: win-x64 e linux-x64. Con argomenti: i rid indicati (es. .\scripts\publish.ps1 win-x64). +# Funziona su Windows PowerShell 5.1 e PowerShell 7 (la compilazione per un altro sistema non richiede quel sistema). +param([string[]]$Rids = @('win-x64', 'linux-x64')) + +$ErrorActionPreference = 'Stop' +Set-Location (Split-Path -Parent $PSScriptRoot) + +foreach ($rid in $Rids) { + Write-Host "== $rid" + dotnet publish 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" } + Get-ChildItem "dist/$rid" -Filter *.pdb -Recurse | Remove-Item # simboli delle librerie native: inutili a chi usa l'app +} + +Write-Host '' +Write-Host 'Eseguibili pronti:' +foreach ($rid in $Rids) { + Get-ChildItem "dist/$rid" -File | ForEach-Object { ' dist/{0}/{1} ({2:N0} MB)' -f $rid, $_.Name, ($_.Length / 1MB) } +} diff --git a/scripts/publish.sh b/scripts/publish.sh new file mode 100755 index 0000000..70e627a --- /dev/null +++ b/scripts/publish.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Crea gli eseguibili single-file self-contained in dist//. +# Senza argomenti: win-x64 e linux-x64. Con argomenti: i rid indicati (es. linux-arm64). +# Funziona da Linux, WSL e macOS (la compilazione per un altro sistema non richiede quel sistema). +set -euo pipefail +cd "$(dirname "$0")/.." + +rids=("$@") +[ ${#rids[@]} -eq 0 ] && rids=(win-x64 linux-x64) + +for rid in "${rids[@]}"; do + echo "== $rid" + dotnet publish 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 +done + +echo +echo "Eseguibili pronti:" +for rid in "${rids[@]}"; do ls -lh "dist/$rid" | awk -v r="$rid" 'NR>1 {print " dist/" r "/" $9 " (" $5 ")"}'; done 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
-
-
- - - -