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:
commit
67d91e6a1f
37 files changed
+2536
-657
No files matched your search
@@ -1,2 +1,3 @@
|
||||
bin/
|
||||
obj/
|
||||
dist/
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
@@ -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).
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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) }
|
||||
}
|
||||
Executable
+19
@@ -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
|
||||
@@ -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: ">Rivedi del codice<small>Bug, leggibilità, prestazioni</small></button>
|
||||
<button class="chip" data-p="Riassumi in modo chiaro questo testo: ">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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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>
|
||||
Reference in new issue
Block a user