Merge del branch agent-wiki-ui: GUI Avalonia, motori Claude Code e Codex, documentazione

Porta su main il lavoro del branch agent-wiki-ui:
- GUI Avalonia in solo C# per Windows e Linux, al posto di WinForms e WebView2
- Claude Code e Codex con interruttore CLI, aggiornamento dei CLI con
  controllo preventivo, modello più piccolo con effort low come predefiniti
- eseguibili single-file per win-x64 e linux-x64 (script bash e PowerShell),
  Windows senza finestra di console
- documentazione doc-first: ADR 0001-0005, piano dei test, stato del progetto
  (stadio MVP), AGENT.md con puntatori CLAUDE.md e GEMINI.md

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-15 12:23:24 +02:00
commit 67d91e6a1f
37 files changed
+2536 -657

No files matched your search

+1
View File
@@ -1,2 +1,3 @@
bin/
obj/
dist/
+58
View File
@@ -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 <id>` come primo argomento, default Claude Code); senza avvia Avalonia (`App`).
- [Core/Engine.cs](Core/Engine.cs): astrazione "motore" (ADR-0002): `Engine` (id, nome, eseguibile, modelli, effort, `BuildArgs`, `Parse`, `FetchLatestVersionAsync` per il controllo delle versioni), `ClaudeEngine`, `CodexEngine`, registro `Engines` e ricerca nel PATH (`PathSearch`). Gli elenchi modelli sono fissi, da aggiornare quando ne escono di nuovi; `DefaultModelId` indica il modello più piccolo, scelto all'avvio insieme a effort `low` (se esce un modello più piccolo, aggiornarlo).
- [Core/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 `<LocalApplicationData>/<nome-app>/chats/<guid>.json` (nome file = id della chat, validato come Guid prima dell'uso; campi `Engine` e `SessionId` opzionali: i file vecchi sono chat di Claude Code).
- [Core/AppInfo.cs](Core/AppInfo.cs): nome dell'app, letto dall'`AssemblyName` del csproj (unica sorgente).
- [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.
+2 -40
View File
@@ -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 <prompt>` 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 <guid>` al primo messaggio, `--resume <guid>` poi. Cronologia: un JSON per chat in `%LOCALAPPDATA%\LlmRun\chats\<guid>.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
+7
View File
@@ -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!;
}
+71
View File
@@ -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<Msg> 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<Msg> 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<Stored> List() => Directory.Exists(Dir)
? Directory.EnumerateFiles(Dir, "*.json").Select(Read).OfType<Stored>().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<Stored>(File.ReadAllText(file)); }
catch (Exception e) when (e is IOException or JsonException) { return null; }
}
}
+40
View File
@@ -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);
}
}
+188
View File
@@ -0,0 +1,188 @@
using System.Text.Json;
using System.Net.Http.Headers;
namespace AgentWikiUi.Core;
record ModelOption(string Label, string Id);
/// <summary>Esito di un messaggio: testo, id di sessione appreso dall'output (se il motore lo sceglie lui) o errore.</summary>
record EngineReply(string Text, string? SessionId, string? Error);
/// <summary>Un motore CLI (Claude Code, Codex): come si avvia, cosa si può scegliere, come si legge l'output. Vedi ADR-0002.</summary>
abstract class Engine
{
/// <summary>Identificatore stabile, salvato nei JSON delle chat e usato da <c>--engine</c>.</summary>
public abstract string Id { get; }
public abstract string Name { get; }
/// <summary>Etichetta sulle risposte ("Claude", "Codex").</summary>
public abstract string Author { get; }
public abstract string Executable { get; }
public abstract ModelOption[] Models { get; }
public abstract string[] Efforts { get; }
/// <summary>Modello scelto all'avvio e a ogni cambio di CLI: il più piccolo, per risposte rapide ed economiche.</summary>
public abstract string DefaultModelId { get; }
public string DefaultEffort => "low";
/// <summary>True se l'id di sessione lo genera il motore e l'app lo legge dall'output.</summary>
public abstract bool OwnsSessionId { get; }
/// <param name="structured">true = GUI (output catturato e da interpretare); false = CLI (output ereditato).</param>
public abstract List<string> BuildArgs(string prompt, string? sessionId, bool resume, string model, string effort, bool structured);
public abstract EngineReply Parse(int code, string stdout, string stderr);
/// <summary>Testo che annuncia l'ultima versione pubblicata (sola lettura, vedi ADR-0004). Eccezioni di rete/formato: le gestisce il chiamante.</summary>
public abstract Task<string?> 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<string> BuildArgs(string prompt, string? sessionId, bool resume, string model, string effort, bool structured)
{
var args = new List<string> { "-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<string?> 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<string> BuildArgs(string prompt, string? sessionId, bool resume, string model, string effort, bool structured)
{
var args = new List<string> { "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<string?> 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;
}
}
+72
View File
@@ -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<string> 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<EngineReply> 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<string> 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);
}
}
+131
View File
@@ -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 }
/// <summary>Esito del controllo (sola lettura): versione installata (riga di `--version`), ultima pubblicata e se è più nuova. Available null = sconosciuto.</summary>
record UpdateCheck(string? InstalledText, string? Latest, bool? Available);
/// <summary>Esito di un aggiornamento: messaggio da mostrare e, se riuscito, le versioni prima e dopo.</summary>
record UpdateOutcome(bool Ok, string Message, string? Before, string? After);
// Aggiornamento di un CLI con il suo comando ufficiale (`<cli> 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;
}
/// <summary>Primo numero N.N.N del testo ("2.1.300 (Claude Code)", "codex-cli 0.162.1", "rust-v0.162.1").</summary>
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;
}
/// <summary>true se <paramref name="latest"/> è più nuova di <paramref name="installed"/>; null se una delle due non si legge.</summary>
public static bool? IsNewer(string? latest, string? installed) =>
ParseVersion(latest) is { } l && ParseVersion(installed) is { } i ? l > i : null;
/// <summary>Controlla se c'è una versione più nuova senza installare nulla. Mai eccezioni: rete o formato inattesi ⇒ sconosciuto.</summary>
public static async Task<UpdateCheck> 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));
}
/// <summary>Prima riga di `--version` ripulita; null se l'eseguibile manca o fallisce.</summary>
public static async Task<string?> 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<UpdateOutcome> 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));
}
/// <summary>Metodo di installazione dedotto dal percorso dell'eseguibile, anche dopo aver risolto i collegamenti simbolici.</summary>
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; }
}
/// <summary>Comando da eseguire a mano quando l'aggiornamento automatico non basta (tabella dell'ADR-0004).</summary>
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",
};
}
+5
View File
@@ -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
-211
View File
@@ -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<Msg> 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<Msg> 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 => $"<option value=\"{m.Id}\">{WebUtility.HtmlEncode(m.Label)}</option>"));
return html.Replace("{{MODELS}}", "<option value=\"\">Predefinito</option>" + 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<Stored>(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<Stored>().OrderByDescending(c => c.Updated).FirstOrDefault()?.Id
: null;
void PostChats()
{
var items = Directory.Exists(ChatsDir)
? Directory.EnumerateFiles(ChatsDir, "*.json").Select(Read).OfType<Stored>()
.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<object>() });
}
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());
}
}
+135
View File
@@ -0,0 +1,135 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="AgentWikiUi.Gui.App"
RequestedThemeVariant="Default">
<!-- Token di design: vedi docs/MVP.md → "Design (UI/UX)". Il tema segue il sistema. -->
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Light">
<SolidColorBrush x:Key="Bg">#FFFFFF</SolidColorBrush>
<SolidColorBrush x:Key="Surface">#FFFFFF</SolidColorBrush>
<SolidColorBrush x:Key="Fg">#0F172A</SolidColorBrush>
<SolidColorBrush x:Key="Muted">#F1F5F9</SolidColorBrush>
<SolidColorBrush x:Key="FgMuted">#475569</SolidColorBrush>
<SolidColorBrush x:Key="Border">#CBD5E1</SolidColorBrush>
<SolidColorBrush x:Key="Accent">#15803D</SolidColorBrush>
<SolidColorBrush x:Key="OnAccent">#FFFFFF</SolidColorBrush>
<SolidColorBrush x:Key="AccentSoft">#DCFCE7</SolidColorBrush>
<SolidColorBrush x:Key="Err">#B91C1C</SolidColorBrush>
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="Bg">#0F172A</SolidColorBrush>
<SolidColorBrush x:Key="Surface">#1E293B</SolidColorBrush>
<SolidColorBrush x:Key="Fg">#F8FAFC</SolidColorBrush>
<SolidColorBrush x:Key="Muted">#1E293B</SolidColorBrush>
<SolidColorBrush x:Key="FgMuted">#94A3B8</SolidColorBrush>
<SolidColorBrush x:Key="Border">#334155</SolidColorBrush>
<SolidColorBrush x:Key="Accent">#22C55E</SolidColorBrush>
<SolidColorBrush x:Key="OnAccent">#0F172A</SolidColorBrush>
<SolidColorBrush x:Key="AccentSoft">#14301F</SolidColorBrush>
<SolidColorBrush x:Key="Err">#F87171</SolidColorBrush>
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
</Application.Resources>
<Application.Styles>
<FluentTheme />
<Style Selector="Window">
<Setter Property="Background" Value="{DynamicResource Bg}" />
<Setter Property="Foreground" Value="{DynamicResource Fg}" />
<Setter Property="FontFamily" Value="Segoe UI Variable Text, Segoe UI, Inter, Ubuntu, Cantarell, Noto Sans, DejaVu Sans, sans-serif" />
<Setter Property="FontSize" Value="15.5" />
</Style>
<Style Selector="Button.ghost">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{DynamicResource Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="MinHeight" Value="36" />
<Setter Property="Padding" Value="14,0" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.ghost:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource Bg}" />
<Setter Property="BorderBrush" Value="{DynamicResource Accent}" />
</Style>
<Style Selector="Button.ghost.primary">
<Setter Property="Background" Value="{DynamicResource Accent}" />
<Setter Property="Foreground" Value="{DynamicResource OnAccent}" />
<Setter Property="BorderBrush" Value="{DynamicResource Accent}" />
</Style>
<Style Selector="Button.ghost.primary:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource Accent}" />
<Setter Property="Foreground" Value="{DynamicResource OnAccent}" />
<Setter Property="BorderBrush" Value="{DynamicResource Accent}" />
<Setter Property="Opacity" Value="0.88" />
</Style>
<Style Selector="TextBlock.check">
<Setter Property="Foreground" Value="{DynamicResource FgMuted}" />
</Style>
<Style Selector="TextBlock.check.avail">
<Setter Property="Foreground" Value="{DynamicResource Accent}" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="Button.mini">
<Setter Property="FontSize" Value="12" />
<Setter Property="Padding" Value="8,2" />
<Setter Property="MinHeight" Value="24" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Background" Value="{DynamicResource Surface}" />
<Setter Property="BorderBrush" Value="{DynamicResource Border}" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="Button.send">
<Setter Property="Width" Value="36" />
<Setter Property="Height" Value="36" />
<Setter Property="CornerRadius" Value="18" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="{DynamicResource Accent}" />
<Setter Property="Foreground" Value="{DynamicResource OnAccent}" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.send:disabled /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource Border}" />
<Setter Property="Foreground" Value="{DynamicResource FgMuted}" />
</Style>
<Style Selector="Button.send:pointerover:not(:disabled) /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource Accent}" />
<Setter Property="Opacity" Value="0.88" />
</Style>
<Style Selector="TextBox.bare /template/ Border#PART_BorderElement">
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Background" Value="Transparent" />
</Style>
<Style Selector="Border.composer:focus-within">
<Setter Property="BorderBrush" Value="{DynamicResource Accent}" />
</Style>
<Style Selector="SelectableTextBlock.status">
<Setter Property="Foreground" Value="{DynamicResource FgMuted}" />
</Style>
<Style Selector="SelectableTextBlock.status.err">
<Setter Property="Foreground" Value="{DynamicResource Err}" />
</Style>
<Style Selector="Border.item">
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Margin" Value="8,1" />
<Setter Property="BorderThickness" Value="3,0,0,0" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
<Style Selector="Border.item.active">
<Setter Property="Background" Value="{DynamicResource Bg}" />
<Setter Property="BorderBrush" Value="{DynamicResource Accent}" />
</Style>
<Style Selector="Border.item:pointerover">
<Setter Property="Background" Value="{DynamicResource Bg}" />
</Style>
</Application.Styles>
</Application>
+17
View File
@@ -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();
}
}
+31
View File
@@ -0,0 +1,31 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
namespace AgentWikiUi.Gui;
static class ConfirmDialog
{
public static Task<bool> 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<bool>(owner);
}
}
+323
View File
@@ -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;
}
/// <summary>Voce dell'interruttore CLI: un motore non installato è mostrato ma non selezionabile. Porta anche versione ed esito dell'ultimo aggiornamento.</summary>
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); }
/// <summary>Esito del controllo: true = c'è una versione nuova, false = aggiornato, null = non controllato o sconosciuto.</summary>
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<MessageVm> Messages { get; } = [];
public ObservableCollection<ChatItem> Chats { get; } = [];
public EngineChoice[] EngineChoices { get; } = Engines.All.Select(e => new EngineChoice(e)).ToArray();
/// <summary>Righe della sezione Aggiornamenti: solo i CLI installati.</summary>
public EngineChoice[] InstalledEngines => EngineChoices.Where(c => c.Installed).ToArray();
/// <summary>Puntino sul pulsante Impostazioni quando almeno un CLI ha una versione nuova.</summary>
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; }
/// <summary>Conferma di eliminazione, fornita dalla finestra.</summary>
public Func<Task<bool>> ConfirmDelete { get; set; } = () => Task.FromResult(true);
/// <summary>Copia negli appunti, fornita dalla finestra.</summary>
public Func<string, Task> CopyText { get; set; } = _ => Task.CompletedTask;
/// <summary>Chiesto alla finestra di rimettere il fuoco sul campo messaggio.</summary>
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<Msg> history = [];
EngineChoice selectedEngine;
ModelOption[] modelChoices = [];
string[] effortChoices = [];
string input = "";
bool busy, updating, sidebarOpen = true;
ModelOption selectedModel = Default;
string customModel = "";
string selectedEffort = NoEffort;
readonly RelayCommand<object> 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<string>(t => { if (t is not null) _ = CopyText(t); });
OpenCommand = new RelayCommand<ChatItem>(c => { if (c is not null) OpenChat(c.Id); });
DeleteCommand = new RelayCommand<ChatItem>(c => { if (c is not null) _ = DeleteAsync(c); });
ToggleSidebarCommand = new RelayCommand<object>(_ => SidebarOpen = !SidebarOpen);
UpdateCommand = new RelayCommand<EngineChoice>(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);
/// <summary>I pulsanti Aggiorna sono attivi solo se non c'è una risposta né un altro aggiornamento in corso.</summary>
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;
/// <summary>Interruttore CLI: cambiare motore apre una nuova chat (il contesto non passa da un motore all'altro).</summary>
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<Msg> 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;
/// <summary>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.</summary>
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
}
}
+205
View File
@@ -0,0 +1,205 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:gui="using:AgentWikiUi.Gui"
xmlns:core="using:AgentWikiUi.Core"
x:Class="AgentWikiUi.Gui.MainWindow"
x:DataType="gui:MainViewModel"
Title="{x:Static core:AppInfo.Name}"
MinWidth="900" MinHeight="600">
<Grid ColumnDefinitions="Auto,*">
<!-- Barra laterale -->
<Border Grid.Column="0" Width="272" IsVisible="{Binding SidebarOpen}"
Background="{DynamicResource Muted}" BorderBrush="{DynamicResource Border}" BorderThickness="0,0,1,0">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Margin="14,16,14,10" Spacing="14">
<StackPanel Orientation="Horizontal" Spacing="10" Margin="4,0">
<Border Width="28" Height="28" CornerRadius="8" Background="{DynamicResource Accent}">
<Viewbox Width="16" Height="16">
<Path Data="M12 2L14.4 8.6L21 11L14.4 13.4L12 20L9.6 13.4L3 11L9.6 8.6Z" Fill="{DynamicResource OnAccent}" />
</Viewbox>
</Border>
<TextBlock Text="{x:Static core:AppInfo.Name}" FontWeight="SemiBold" VerticalAlignment="Center" />
</StackPanel>
<Button Classes="ghost" Command="{Binding NewChatCommand}" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Background="{DynamicResource Bg}" AutomationProperties.Name="Nuova chat">
<StackPanel Orientation="Horizontal" Spacing="8">
<Viewbox Width="16" Height="16"><Path Data="M12 5V19M5 12H19" Stroke="{DynamicResource Fg}" StrokeThickness="2" StrokeLineCap="Round" /></Viewbox>
<TextBlock Text="Nuova chat" />
</StackPanel>
</Button>
</StackPanel>
<TextBlock DockPanel.Dock="Top" Text="CHAT" FontSize="11.5" FontWeight="SemiBold" Foreground="{DynamicResource FgMuted}" Margin="22,8,14,4" />
<TextBlock DockPanel.Dock="Top" Text="Nessuna chat salvata" Foreground="{DynamicResource FgMuted}" FontSize="13.5" Margin="22,6"
IsVisible="{Binding !Chats.Count}" />
<ScrollViewer>
<ItemsControl ItemsSource="{Binding Chats}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="gui:ChatItem">
<Border Classes="item" Classes.active="{Binding IsActive}">
<Grid ColumnDefinitions="*,Auto">
<Button Grid.ColumnSpan="2" Background="Transparent" BorderThickness="0" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" Padding="12,8,40,8"
Command="{Binding $parent[ItemsControl].((gui:MainViewModel)DataContext).OpenCommand}" CommandParameter="{Binding}"
AutomationProperties.Name="{Binding Title, StringFormat='Apri chat: {0}'}">
<StackPanel>
<TextBlock Text="{Binding Title}" TextTrimming="CharacterEllipsis" FontSize="14" />
<TextBlock Text="{Binding When}" FontSize="12" Foreground="{DynamicResource FgMuted}" />
</StackPanel>
</Button>
<Button Grid.Column="1" Background="Transparent" BorderThickness="0" Width="32" Height="32" Margin="0,0,4,0"
VerticalAlignment="Center" Padding="0" HorizontalContentAlignment="Center"
Command="{Binding $parent[ItemsControl].((gui:MainViewModel)DataContext).DeleteCommand}" CommandParameter="{Binding}"
AutomationProperties.Name="Elimina chat">
<Viewbox Width="12" Height="12"><Path Data="M6 6L18 18M18 6L6 18" Stroke="{DynamicResource FgMuted}" StrokeThickness="2.4" StrokeLineCap="Round" /></Viewbox>
</Button>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Border>
<!-- Area principale -->
<Grid Grid.Column="1" RowDefinitions="*,Auto">
<ScrollViewer Grid.Row="0" x:Name="Scroll">
<StackPanel MaxWidth="780" Margin="24,56,24,16" Spacing="20">
<!-- Schermata iniziale -->
<StackPanel IsVisible="{Binding ShowEmpty}" Spacing="10" Margin="0,40,0,0" HorizontalAlignment="Center">
<Border Width="44" Height="44" CornerRadius="12" Background="{DynamicResource Accent}" HorizontalAlignment="Center">
<Viewbox Width="24" Height="24">
<Path Data="M12 2L14.4 8.6L21 11L14.4 13.4L12 20L9.6 13.4L3 11L9.6 8.6Z" Fill="{DynamicResource OnAccent}" />
</Viewbox>
</Border>
<TextBlock Text="Come posso aiutarti?" FontSize="28" FontWeight="SemiBold" HorizontalAlignment="Center" />
<TextBlock Text="Scrivi un messaggio per iniziare." Foreground="{DynamicResource FgMuted}" HorizontalAlignment="Center" />
</StackPanel>
<!-- Conversazione -->
<ItemsControl ItemsSource="{Binding Messages}" AutomationProperties.Name="Conversazione">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Spacing="22" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="gui:MessageVm">
<Panel>
<Border IsVisible="{Binding IsUser}" HorizontalAlignment="Right" MaxWidth="620" CornerRadius="18,18,4,18"
Background="{DynamicResource AccentSoft}" Padding="16,10">
<SelectableTextBlock Text="{Binding Text}" TextWrapping="Wrap" />
</Border>
<StackPanel IsVisible="{Binding IsAi}" Spacing="6">
<TextBlock Text="{Binding Author}" FontWeight="SemiBold" FontSize="13.5" Foreground="{DynamicResource Accent}" />
<gui:MarkdownView Source="{Binding Text}" />
<Button Classes="ghost" HorizontalAlignment="Left" MinHeight="30" Padding="10,0" FontSize="13" Content="Copia"
Command="{Binding $parent[ItemsControl].((gui:MainViewModel)DataContext).CopyCommand}" CommandParameter="{Binding Text}"
AutomationProperties.Name="Copia la risposta" />
</StackPanel>
<SelectableTextBlock IsVisible="{Binding IsErr}" Text="{Binding Text}" TextWrapping="Wrap" Foreground="{DynamicResource Err}" />
</Panel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Attesa -->
<StackPanel IsVisible="{Binding IsBusy}" Orientation="Horizontal" Spacing="12">
<ProgressBar IsIndeterminate="True" Width="80" Height="4" Foreground="{DynamicResource Accent}" VerticalAlignment="Center" />
<TextBlock Text="{Binding BusyText}" Foreground="{DynamicResource FgMuted}" />
</StackPanel>
</StackPanel>
</ScrollViewer>
<Button Grid.Row="0" Classes="ghost" HorizontalAlignment="Left" VerticalAlignment="Top" Margin="12" Padding="0" Width="36"
HorizontalContentAlignment="Center" Command="{Binding ToggleSidebarCommand}" AutomationProperties.Name="Mostra o nascondi le chat">
<Viewbox Width="16" Height="16"><Path Data="M4 6H20M4 12H20M4 18H20" Stroke="{DynamicResource Fg}" StrokeThickness="2" StrokeLineCap="Round" /></Viewbox>
</Button>
<!-- Impostazioni: modello ed effort -->
<Button Grid.Row="0" Classes="ghost" HorizontalAlignment="Right" VerticalAlignment="Top" Margin="12" Padding="0" Width="36"
HorizontalContentAlignment="Center" AutomationProperties.Name="Impostazioni" ToolTip.Tip="Impostazioni">
<Button.Flyout>
<Flyout Placement="BottomEdgeAlignedRight" Opening="OnSettingsOpening">
<StackPanel Width="340" Spacing="6" Margin="4">
<TextBlock Text="Impostazioni" FontWeight="SemiBold" Margin="0,0,0,6" />
<TextBlock Text="CLI" FontSize="13" Foreground="{DynamicResource FgMuted}" />
<ComboBox ItemsSource="{Binding EngineChoices}" SelectedItem="{Binding SelectedEngine}" IsEnabled="{Binding CanSwitchEngine}"
HorizontalAlignment="Stretch" AutomationProperties.Name="CLI (motore)">
<ComboBox.Styles>
<Style Selector="ComboBoxItem" x:DataType="gui:EngineChoice">
<Setter Property="IsEnabled" Value="{Binding Installed}" />
</Style>
</ComboBox.Styles>
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="gui:EngineChoice"><TextBlock Text="{Binding Label}" /></DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="Modello" FontSize="13" Foreground="{DynamicResource FgMuted}" Margin="0,8,0,0" />
<ComboBox ItemsSource="{Binding ModelChoices}" SelectedItem="{Binding SelectedModel}" HorizontalAlignment="Stretch"
AutomationProperties.Name="Modello">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="core:ModelOption"><TextBlock Text="{Binding Label}" /></DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBox Text="{Binding CustomModel}" IsVisible="{Binding IsCustomModel}" PlaceholderText="es. claude-opus-5-5"
AutomationProperties.Name="ID modello personalizzato" />
<TextBlock Text="Effort" FontSize="13" Foreground="{DynamicResource FgMuted}" Margin="0,8,0,0" />
<ComboBox ItemsSource="{Binding EffortChoices}" SelectedItem="{Binding SelectedEffort}" HorizontalAlignment="Stretch"
AutomationProperties.Name="Effort di ragionamento" />
<Border Height="1" Background="{DynamicResource Border}" Margin="0,12,0,4" />
<TextBlock Text="Aggiornamenti" FontSize="13" Foreground="{DynamicResource FgMuted}" />
<ItemsControl ItemsSource="{Binding InstalledEngines}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="gui:EngineChoice">
<StackPanel Spacing="4" Margin="0,8,0,0">
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" />
<TextBlock Text="{Binding Version}" FontSize="12" Foreground="{DynamicResource FgMuted}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="check" Classes.avail="{Binding UpdateAvailable}" Text="{Binding CheckText}" FontSize="12" />
</StackPanel>
<Button Grid.Column="1" Classes="ghost" Classes.primary="{Binding UpdateAvailable}" MinHeight="30" Padding="12,0" FontSize="13" Content="Aggiorna"
IsEnabled="{Binding $parent[ItemsControl].((gui:MainViewModel)DataContext).CanUpdate}"
Command="{Binding $parent[ItemsControl].((gui:MainViewModel)DataContext).UpdateCommand}" CommandParameter="{Binding}"
AutomationProperties.Name="{Binding Name, StringFormat='Aggiorna {0}'}" />
</Grid>
<SelectableTextBlock Classes="status" Classes.err="{Binding StatusIsError}" Text="{Binding Status}" IsVisible="{Binding HasStatus}"
TextWrapping="Wrap" FontSize="12.5" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Flyout>
</Button.Flyout>
<Panel>
<Viewbox Width="16" Height="16">
<Path Data="M4 7H11M17 7H20M4 17H7M13 17H20 M11 7A3 3 0 1 0 17 7A3 3 0 1 0 11 7Z M7 17A3 3 0 1 0 13 17A3 3 0 1 0 7 17Z"
Stroke="{DynamicResource Fg}" StrokeThickness="2" StrokeLineCap="Round" />
</Viewbox>
<Ellipse Width="9" Height="9" Fill="{DynamicResource Accent}" HorizontalAlignment="Right" VerticalAlignment="Top" Margin="0,-5,-6,0"
IsVisible="{Binding AnyUpdateAvailable}" />
</Panel>
</Button>
<!-- Composer -->
<StackPanel Grid.Row="1" MaxWidth="780" Margin="24,0,24,12">
<Border Classes="composer" Background="{DynamicResource Surface}" BorderBrush="{DynamicResource Border}" BorderThickness="1" CornerRadius="20" Padding="14,10">
<Grid ColumnDefinitions="*,Auto">
<TextBox x:Name="InputBox" Classes="bare" Text="{Binding Input, Mode=TwoWay}" AcceptsReturn="True" TextWrapping="Wrap" MaxHeight="200"
PlaceholderText="{Binding InputPlaceholder}" BorderThickness="0" Background="Transparent"
VerticalContentAlignment="Center" AutomationProperties.Name="Messaggio" />
<Button Grid.Column="1" Classes="send" VerticalAlignment="Bottom" Margin="8,0,0,0" Command="{Binding SendCommand}" AutomationProperties.Name="Invia messaggio">
<Viewbox Width="18" Height="18"><Path Data="M12 19V5M5 12L12 5L19 12" Stroke="{Binding $parent[Button].Foreground}" StrokeThickness="2.2" StrokeLineCap="Round" StrokeJoin="Round" /></Viewbox>
</Button>
</Grid>
</Border>
<TextBlock Text="Invio per inviare · Maiusc+Invio per andare a capo" FontSize="12" Foreground="{DynamicResource FgMuted}"
HorizontalAlignment="Center" Margin="0,8,0,0" />
</StackPanel>
</Grid>
</Grid>
</Window>
+51
View File
@@ -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();
}
+204
View File
@@ -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;
/// <summary>Mostra markdown come controlli Avalonia (nessun HTML): il testo è Markdig, il rendering è qui.</summary>
class MarkdownView : Decorator
{
public static readonly StyledProperty<string?> SourceProperty = AvaloniaProperty.Register<MarkdownView, string?>(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<Block> 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<ListItemBlock>())
{
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<TableRow>().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<TableCell>().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<LiteralInline>().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);
};
}
}
+28
View File
@@ -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<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
if (EqualityComparer<T>.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<T>(Action<T?> run, Func<bool>? 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);
}
-21
View File
@@ -1,21 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<NoWarn>$(NoWarn);WFO5001</NoWarn> <!-- SetColorMode è sperimentale -->
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Markdig" Version="1.4.0" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4258.31" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="ui.html" />
</ItemGroup>
</Project>
+20 -39
View File
@@ -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 <id>` 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: <app> [--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<App>().UsePlatformDetect().StartWithClassicDesktopLifetime(args);
+53 -19
View File
@@ -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 `<rid>` con `win-x64` o `linux-x64`):
```
dotnet publish -c Release -r <rid> --self-contained -p:PublishSingleFile=true -o dist/<rid>
```
Senza `-o` il risultato è in `bin/Release/net10.0/<rid>/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).
+26
View File
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- WinExe: su Windows nessuna finestra di console al doppio clic; la CLI riaggancia il terminale (ADR-0005). Su Linux è identico a Exe. -->
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<!-- Unica sorgente del nome dell'app (vedi docs/README.md → "Nome del progetto") -->
<AssemblyName>agent-wiki-ui</AssemblyName>
<RootNamespace>AgentWikiUi</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<!-- publish single-file: le librerie native (Skia, HarfBuzz) vengono estratte al primo avvio -->
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<DebugType>none</DebugType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.1.4" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.4" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.4" />
<PackageReference Include="Markdig" Version="1.4.0" />
</ItemGroup>
</Project>
+104
View File
@@ -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.
+33 -29
View File
@@ -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"
<nome-app> "spiega questo progetto"
```
Tutti gli argomenti sono uniti in un unico prompt.
Tutti gli argomenti sono uniti in un unico prompt. Con `--engine <id>` come primo argomento (`claude` o `codex`) si sceglie il motore, per esempio `<nome-app> --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 <prompt>` (modalità non interattiva) con `WorkingDirectory = AppContext.BaseDirectory`.
2. Avvia il motore scelto in modalità non interattiva (`claude -p <prompt>` o `codex exec … -- <prompt>`) 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 `<cli> 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 <guid>` al primo messaggio, `--resume <guid>` ai successivi. "Nuova chat" svuota la vista e genera un nuovo guid.
- **Cronologia**: dopo ogni scambio la chat è salvata in `%LOCALAPPDATA%\LlmRun\chats\<guid>.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 <guid>`, 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 `<cartella dati>/<nome-app>/chats/<guid>.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 <guid>`, 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
+56
View File
@@ -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.
+54
View File
@@ -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 `<nome-app>`.
Se il nome cambia di nuovo, aggiornare solo questi punti:
- [ ] `AssemblyName` in [../agent-wiki-ui.csproj](../agent-wiki-ui.csproj) (e il nome del file csproj): da lì il codice legge il nome (`Core/AppInfo.cs`) per titolo finestra, intestazione della barra laterale e cartella dati; l'eseguibile diventa `<nome-app>.exe` / `<nome-app>`
- [ ] `RootNamespace` e namespace C# (`AgentWikiUi.*`, anche in `x:Class` e `xmlns` dei file `.axaml`): il nome del namespace non può avere trattini
- [ ] **Cartella dati** `<LocalApplicationData>/<nome-app>/chats`: cambiando nome le chat salvate non si trovano più. `ChatStore` sposta una vecchia cartella (`LlmRun`) in quella nuova al primo avvio; per un nuovo cambio aggiungere il nome precedente all'elenco `LegacyNames`
- [ ] 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 |
+44 -26
View File
@@ -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 <prompt>` | `codex exec <prompt>` | `agy -p <prompt>` |
| Continuare | `--session-id <guid>` poi `--resume <guid>` | `codex exec resume <id> <prompt>` | 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
+245
View File
@@ -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 <guid>`; successivi: `--resume <guid>` | 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 … -- <prompt>` | U | `Codex_primo_messaggio` | [ ] |
| CX-02 | Ripresa: `exec resume --json … -c sandbox_mode="read-only" <id> -- <prompt>`, 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=<livello>`; 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 <id> "prompt"`; id sconosciuto o prompt mancante ⇒ uso su stderr ed exit 2; senza `--engine` ⇒ Claude Code | E | [ ] |
| EN-18 | Rilevamento nel PATH: nome esatto su Linux, `.exe` su Windows; cartella inesistente nel PATH ignorata | U | [ ] |
## 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
@@ -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 `<nome-app>/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).
@@ -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 <id>` (`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 <prompt>` | `codex exec --json --skip-git-repo-check -s read-only [opzioni] -- <prompt>` | `agy -p <prompt>` (da verificare) |
| Continuare | `--session-id <guid>`, poi `--resume <guid>` | `codex exec resume --json --skip-git-repo-check -c 'sandbox_mode="read-only"' [opzioni] <id> -- <prompt>` | da verificare |
| Modello | `--model` | `-m` / `--model` | da verificare |
| Effort | `--effort` | `-c model_reasoning_effort=<livello>` | 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":"<uuid>"}`. 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).
+52
View File
@@ -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.
+72
View File
@@ -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 (`<cli> --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 (`<cli> --version`), lancia `<cli> 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.
+45
View File
@@ -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.
+20
View File
@@ -0,0 +1,20 @@
# Crea gli eseguibili single-file self-contained in dist\<rid>\.
# 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) }
}
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Crea gli eseguibili single-file self-contained in dist/<rid>/.
# 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
-272
View File
@@ -1,272 +0,0 @@
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LlmRun</title>
<style>
/* Token di design: vedi docs/MVP.md → "Design (UI/UX)" */
:root {
color-scheme: light dark;
--bg: #FFFFFF; --surface: #FFFFFF; --fg: #0F172A; --muted: #F1F5F9; --muted-fg: #475569;
--border: #CBD5E1; --accent: #15803D; --on-accent: #FFFFFF; --accent-soft: #DCFCE7; --err: #B91C1C;
--shadow: 0 1px 2px rgba(15,23,42,.06), 0 8px 24px rgba(15,23,42,.08);
--font: 'Segoe UI Variable Text', 'Segoe UI', system-ui, sans-serif;
--mono: 'Cascadia Code', Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root { --bg: #0F172A; --surface: #1E293B; --fg: #F8FAFC; --muted: #1E293B; --muted-fg: #94A3B8;
--border: #334155; --accent: #22C55E; --on-accent: #0F172A; --accent-soft: #14301F; --err: #F87171;
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35); }
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body { display: flex; font: 15.5px/1.65 var(--font); background: var(--bg); color: var(--fg); overflow: hidden; }
button, select, textarea, input { font: inherit; color: inherit; }
button { cursor: pointer; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
/* barra laterale */
#side { width: 272px; flex: none; display: flex; flex-direction: column; background: var(--muted); border-right: 1px solid var(--border); transition: margin-left .2s; }
#side.closed { margin-left: -272px; }
.side-top { padding: 16px 14px 10px; display: flex; flex-direction: column; gap: 14px; }
.brand { display: flex; align-items: center; gap: 10px; font-weight: 600; letter-spacing: -.01em; padding: 0 4px; }
.logo { width: 28px; height: 28px; border-radius: 8px; background: var(--accent); color: var(--on-accent); display: grid; place-items: center; }
.logo svg { width: 16px; height: 16px; fill: currentColor; stroke: none; }
.ghost { display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 14px; border-radius: 10px; border: 1px solid var(--border); background: transparent; transition: background .15s, border-color .15s; }
.ghost:hover { background: var(--bg); }
#reset { justify-content: center; background: var(--bg); }
#reset:hover { border-color: var(--accent); }
.side-h { padding: 8px 18px 4px; font-size: 12px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: var(--muted-fg); }
#list { flex: 1; overflow-y: auto; padding: 4px 8px 12px; }
.item { position: relative; border-radius: 10px; padding: 8px 38px 8px 12px; transition: background .15s; }
.item:hover { background: var(--bg); }
.item.active { background: var(--bg); box-shadow: inset 3px 0 0 var(--accent); }
.item .open { position: absolute; inset: 0; width: 100%; border: 0; background: transparent; border-radius: 10px; }
.item .t, .item .d { position: relative; display: block; pointer-events: none; }
.item .t { font-size: 14px; line-height: 1.35; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.item .d { font-size: 12px; color: var(--muted-fg); }
.item .x { position: absolute; top: 50%; right: 6px; transform: translateY(-50%); width: 26px; height: 26px; display: grid; place-items: center; border: 0; border-radius: 7px; background: transparent; color: var(--muted-fg); opacity: 0; }
.item:hover .x, .item:focus-within .x { opacity: 1; }
.item .x:hover { background: var(--muted); color: var(--err); }
.item .x svg { width: 14px; height: 14px; }
.none { padding: 10px 14px; font-size: 13px; color: var(--muted-fg); }
#main { flex: 1; min-width: 0; display: flex; flex-direction: column; position: relative; }
#toggle { position: absolute; top: 12px; left: 12px; z-index: 2; width: 36px; height: 36px; padding: 0; justify-content: center; }
/* conversazione */
#scroll { flex: 1; overflow-y: auto; scroll-behavior: smooth; }
#chat { max-width: 780px; margin: 0 auto; padding: 28px 20px 12px; }
.msg { display: flex; gap: 14px; margin-bottom: 26px; animation: in .18s ease-out; }
@keyframes in { from { opacity: 0; transform: translateY(6px); } }
.msg.user { justify-content: flex-end; }
.bubble { max-width: 85%; padding: 10px 16px; white-space: pre-wrap; overflow-wrap: anywhere; background: var(--muted); border: 1px solid var(--border); border-radius: 18px 18px 4px 18px; }
.avatar { width: 30px; height: 30px; border-radius: 50%; background: var(--accent); color: var(--on-accent); display: grid; place-items: center; flex: none; margin-top: 2px; }
.avatar svg { width: 16px; height: 16px; fill: currentColor; stroke: none; }
.body { min-width: 0; flex: 1; position: relative; }
.who { font-size: 12px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: var(--muted-fg); margin-bottom: 2px; }
.content > :first-child { margin-top: 0; } .content > :last-child { margin-bottom: 0; }
.content a { color: var(--accent); }
.content h1, .content h2, .content h3 { line-height: 1.3; margin: 1.4em 0 .5em; }
.content p, .content ul, .content ol { margin: .7em 0; }
.content code { font-family: var(--mono); font-size: 13.5px; font-variant-ligatures: none; }
.content :not(pre) > code { background: var(--muted); border: 1px solid var(--border); padding: 1px 6px; border-radius: 6px; }
.content pre { position: relative; margin: 1em 0; padding: 16px; background: var(--muted); border: 1px solid var(--border); border-radius: 12px; overflow-x: auto; }
.content table { border-collapse: collapse; display: block; overflow-x: auto; margin: 1em 0; }
.content th, .content td { border: 1px solid var(--border); padding: 7px 14px; text-align: left; }
.content th { background: var(--muted); font-weight: 600; }
.content blockquote { margin: 1em 0; padding: 2px 16px; border-left: 3px solid var(--accent); color: var(--muted-fg); }
.content hr { border: 0; border-top: 1px solid var(--border); margin: 1.5em 0; }
.mini { position: absolute; top: 8px; right: 8px; height: 28px; padding: 0 10px; font-size: 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface); opacity: 0; transition: opacity .15s; }
pre:hover > .mini, .mini:focus-visible { opacity: 1; }
.actions { margin-top: 8px; opacity: 0; transition: opacity .15s; }
.msg:hover .actions, .actions:focus-within { opacity: 1; }
.actions .ghost { height: 30px; padding: 0 10px; font-size: 13px; color: var(--muted-fg); border-color: transparent; }
.actions .ghost svg { width: 15px; height: 15px; }
.err .content { color: var(--err); border: 1px solid var(--err); border-radius: 12px; padding: 10px 14px; white-space: pre-wrap; background: color-mix(in srgb, var(--err) 8%, var(--bg)); }
.wait { color: var(--muted-fg); align-items: center; }
.dots i { display: inline-block; width: 7px; height: 7px; margin-right: 5px; border-radius: 50%; background: var(--accent); animation: pulse 1.2s infinite ease-in-out; }
.dots i:nth-child(2) { animation-delay: .15s; } .dots i:nth-child(3) { animation-delay: .3s; }
@keyframes pulse { 0%, 80%, 100% { opacity: .25 } 40% { opacity: 1 } }
/* schermata iniziale */
#empty { max-width: 640px; margin: 0 auto; padding: 12vh 20px 0; text-align: center; }
#empty .logo { width: 52px; height: 52px; border-radius: 16px; margin: 0 auto 18px; }
#empty .logo svg { width: 28px; height: 28px; }
#empty h1 { font-size: 30px; letter-spacing: -.02em; margin: 0 0 6px; }
#empty p { color: var(--muted-fg); margin: 0 0 28px; }
.chips { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.chip { text-align: left; padding: 12px 16px; border-radius: 14px; border: 1px solid var(--border); background: var(--surface); transition: border-color .15s, transform .15s, background .15s; }
.chip:hover { border-color: var(--accent); background: var(--muted); transform: translateY(-1px); }
.chip small { display: block; color: var(--muted-fg); font-size: 12.5px; }
/* composer */
footer { flex: none; padding: 8px 20px 12px; }
.composer { max-width: 780px; margin: 0 auto; background: var(--surface); border: 1px solid var(--border); border-radius: 20px; box-shadow: var(--shadow); padding: 12px 12px 10px 18px; transition: border-color .15s; }
.composer:focus-within { border-color: var(--accent); }
textarea { display: block; width: 100%; resize: none; border: 0; outline: 0; background: transparent; padding: 4px 0; max-height: 200px; line-height: 1.55; }
textarea:focus-visible { outline: 0; } /* il focus è mostrato dal bordo del .composer */
textarea::placeholder { color: var(--muted-fg); }
.bar { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.field { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted-fg); }
.field select, .field input { height: 32px; border-radius: 9px; border: 1px solid var(--border); background: var(--bg); padding: 0 8px; font-size: 13px; color: var(--fg); }
.field input { width: 190px; }
.spacer { flex: 1; }
#send { width: 40px; height: 40px; border-radius: 50%; border: 0; background: var(--accent); color: var(--on-accent); display: grid; place-items: center; transition: transform .15s, opacity .15s; }
#send:hover:not(:disabled) { transform: scale(1.06); }
#send:disabled { opacity: .35; cursor: default; }
#send svg { width: 20px; height: 20px; stroke-width: 2.4; }
.hint { max-width: 780px; margin: 8px auto 0; text-align: center; font-size: 12px; color: var(--muted-fg); }
kbd { font: 11.5px var(--mono); padding: 1px 5px; border: 1px solid var(--border); border-radius: 5px; background: var(--muted); }
::-webkit-scrollbar { width: 12px; height: 12px; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 8px; border: 3px solid var(--bg); }
@media (max-width: 720px) { .chips { grid-template-columns: 1fr; } .field input { width: 120px; } }
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; scroll-behavior: auto !important; } .dots i { opacity: .7; } }
</style>
</head>
<body>
<aside id="side" aria-label="Chat salvate">
<div class="side-top">
<div class="brand"><span class="logo"><svg viewBox="0 0 24 24"><path d="M12 2l2.4 6.6L21 11l-6.6 2.4L12 20l-2.4-6.6L3 11l6.6-2.4z"/></svg></span>LlmRun</div>
<button class="ghost" id="reset" aria-label="Nuova chat"><svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>Nuova chat</button>
</div>
<div class="side-h">Chat</div>
<div id="list"></div>
</aside>
<main id="main">
<button class="ghost" id="toggle" aria-label="Mostra o nascondi le chat" aria-expanded="true"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></svg></button>
<div id="scroll">
<div id="empty">
<span class="logo"><svg viewBox="0 0 24 24"><path d="M12 2l2.4 6.6L21 11l-6.6 2.4L12 20l-2.4-6.6L3 11l6.6-2.4z"/></svg></span>
<h1>Come posso aiutarti?</h1>
<p>Scrivi un messaggio oppure parti da uno di questi spunti.</p>
<div class="chips">
<button class="chip" data-p="Spiega cosa fa questo progetto e come è organizzato.">Spiega il progetto<small>Panoramica e struttura</small></button>
<button class="chip" data-p="Scrivi uno script PowerShell che ">Scrivi uno script<small>PowerShell, Python, C#…</small></button>
<button class="chip" data-p="Rivedi questo codice e suggerisci miglioramenti:&#10;&#10;">Rivedi del codice<small>Bug, leggibilità, prestazioni</small></button>
<button class="chip" data-p="Riassumi in modo chiaro questo testo:&#10;&#10;">Riassumi un testo<small>Punti chiave in breve</small></button>
</div>
</div>
<div id="chat" role="log" aria-live="polite" aria-label="Conversazione" hidden></div>
</div>
<footer>
<div class="composer">
<textarea id="input" rows="1" placeholder="Scrivi un messaggio a Claude…" aria-label="Messaggio"></textarea>
<div class="bar">
<label class="field">Modello
<select id="model" aria-label="Modello">{{MODELS}}<option value="__custom">Altro ID…</option></select>
<input id="custom" placeholder="es. claude-opus-5-5" aria-label="ID modello personalizzato" hidden>
</label>
<label class="field">Effort
<select id="effort" aria-label="Effort di ragionamento">
<option value="">Predefinito</option><option>low</option><option>medium</option><option>high</option><option>xhigh</option><option>max</option>
</select>
</label>
<span class="spacer"></span>
<button id="send" aria-label="Invia messaggio" disabled><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button>
</div>
</div>
<div class="hint"><kbd>Invio</kbd> per inviare · <kbd>Maiusc</kbd>+<kbd>Invio</kbd> per andare a capo</div>
</footer>
</main>
<script>
const $ = s => document.querySelector(s);
const chat = $('#chat'), empty = $('#empty'), input = $('#input'), send = $('#send'), scroller = $('#scroll');
const host = window.chrome && window.chrome.webview;
let busy = false;
const ICON_COPY = '<svg viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h9"/></svg>';
const STAR = '<svg viewBox="0 0 24 24"><path d="M12 2l2.4 6.6L21 11l-6.6 2.4L12 20l-2.4-6.6L3 11l6.6-2.4z"/></svg>';
function post(m) { host && host.postMessage(m); }
function copy(text) {
const t = document.createElement('textarea'); t.value = text; document.body.appendChild(t); t.select();
document.execCommand('copy'); t.remove();
}
function flash(btn, label) { const old = btn.innerHTML; btn.textContent = label; setTimeout(() => btn.innerHTML = old, 1400); }
function bottom() { scroller.scrollTop = scroller.scrollHeight; }
function el(html) { const d = document.createElement('div'); d.innerHTML = html.trim(); return d.firstChild; }
function esc(s) { return s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
function show(node) {
empty.hidden = true; chat.hidden = false;
chat.appendChild(node); bottom();
}
function addUser(text) { show(el(`<div class="msg user"><div class="bubble">${esc(text)}</div></div>`)); }
function addErr(text) { show(el(`<div class="msg err"><div class="body"><div class="content">${esc(text)}</div></div></div>`)); }
function addAi(html, md) {
const m = el(`<div class="msg ai"><span class="avatar">${STAR}</span><div class="body"><div class="who">Claude</div><div class="content">${html}</div>
<div class="actions"><button class="ghost" aria-label="Copia la risposta">${ICON_COPY}Copia</button></div></div></div>`);
m.querySelectorAll('pre').forEach(p => {
const b = el('<button class="mini" aria-label="Copia il codice">Copia</button>');
b.onclick = () => { copy(p.querySelector('code').innerText); flash(b, 'Copiato'); };
p.appendChild(b);
});
const a = m.querySelector('.actions button'); a.onclick = () => { copy(md); flash(a, 'Copiato'); };
show(m);
}
function setBusy(v) {
busy = v; refresh();
if (v) show(el(`<div class="msg wait" id="typing" role="status"><span class="avatar">${STAR}</span><span class="dots"><i></i><i></i><i></i></span>Claude sta scrivendo…</div>`));
else { const t = $('#typing'); t && t.remove(); input.focus(); }
}
function refresh() { send.disabled = busy || !input.value.trim(); }
function grow() { input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 200) + 'px'; refresh(); }
function submit() {
const text = input.value.trim();
if (!text || busy) return;
const sel = $('#model').value;
const model = sel === '__custom' ? $('#custom').value.trim() : sel;
addUser(text); input.value = ''; grow(); setBusy(true);
post({ type: 'send', text, model, effort: $('#effort').value });
}
input.addEventListener('input', grow);
input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); submit(); } });
send.onclick = submit;
$('#model').onchange = e => { const c = $('#custom'); c.hidden = e.target.value !== '__custom'; if (!c.hidden) c.focus(); };
function clearView() { chat.replaceChildren(); chat.hidden = true; empty.hidden = false; }
$('#reset').onclick = () => { if (busy) return; clearView(); refresh(); post({ type: 'reset' }); input.focus(); };
$('#toggle').onclick = () => { const c = $('#side').classList.toggle('closed'); $('#toggle').setAttribute('aria-expanded', String(!c)); };
const ICON_X = '<svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg>';
function renderList(items, current) {
const list = $('#list');
list.replaceChildren();
if (!items.length) { list.append(el('<div class="none">Nessuna chat salvata</div>')); return; }
for (const c of items) {
const when = new Date(c.updated).toLocaleString('it-IT', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
const row = el(`<div class="item${c.id === current ? ' active' : ''}"><button class="open" aria-label="Apri chat: ${esc(c.title)}"></button><span class="t">${esc(c.title)}</span><span class="d">${esc(when)}</span><button class="x" aria-label="Elimina chat">${ICON_X}</button></div>`);
row.querySelector('.open').onclick = () => { if (!busy && c.id !== current) post({ type: 'open', id: c.id }); };
row.querySelector('.x').onclick = () => { if (!busy && confirm('Eliminare questa chat?')) post({ type: 'delete', id: c.id }); };
list.append(row);
}
}
function load(messages) {
clearView();
for (const m of messages) {
if (m.role === 'user') addUser(m.text);
else if (m.role === 'ai') addAi(m.html, m.text);
else addErr(m.text);
}
bottom();
}
document.querySelectorAll('.chip').forEach(c => c.onclick = () => { input.value = c.dataset.p; grow(); input.focus(); input.setSelectionRange(input.value.length, input.value.length); });
host && host.addEventListener('message', e => {
const m = e.data;
if (m.type === 'ai') addAi(m.html, m.md);
else if (m.type === 'err') addErr(m.text);
else if (m.type === 'done') setBusy(false);
else if (m.type === 'chats') renderList(m.items, m.current);
else if (m.type === 'load') load(m.messages);
});
input.focus();
post({ type: 'list' });
</script>
</body>
</html>