Documentazione: GUI Avalonia implementata e progetto rinominato agent-wiki-ui

MVP.md descrive ora la GUI Avalonia al posto di WinForms/WebView2 e il
protocollo pagina-C# sparisce. ADR-0001 passa ad Accettata con l'esito delle
verifiche (renderer markdown proprio, perché Markdown.Avalonia è per Avalonia
11). Il nome ha un'unica sorgente in docs/README.md, con la checklist di
rinomina e la migrazione della cartella dati. README e CLAUDE.md seguono la
nuova struttura Core/Gui e i due publish single-file.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-15 11:18:46 +02:00
1 parent c4c1ad4520
commit 9c6a679e6a
9 files changed
+80 -564

No files matched your search

+20 -14
View File
@@ -4,37 +4,43 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Progetto
LlmRun: eseguibile C# (.NET 10, Windows) che invoca Claude Code (`claude -p`) da terminale. Senza argomenti apre una GUI WinForms, con argomenti funziona da CLI. Non è un repo git.
agent-wiki-ui (già `LlmRun`; il nome ha un'unica sorgente, vedi [docs/README.md](docs/README.md#nome-del-progetto)): eseguibile C# (.NET 10, Windows e Linux) che invoca Claude Code (`claude -p`) da terminale. Senza argomenti apre una GUI di chat Avalonia, con argomenti funziona da CLI.
## Workflow doc-first (obbligatorio)
- Specifica e criteri di accettazione: [docs/MVP.md](docs/MVP.md). Fasi e stato: [docs/ROADMAP.md](docs/ROADMAP.md). Indice dei doc e regole (SSOT, ADR): [docs/README.md](docs/README.md); una decisione architetturale si scrive solo nel suo ADR, gli altri doc la richiamano con un link. Uso: [README.md](README.md).
- Ad ogni richiesta di modifica: aggiornare prima i doc interessati (MVP.md, ROADMAP.md con le spunte, README.md se cambia l'uso; un nuovo ADR in `docs/adr/` per ogni scelta architetturale), poi il codice, poi questo file se cambiano comandi, design o vincoli. Nessuna modifica deve lasciare i doc disallineati.
> In corso: migrazione a GUI Avalonia multipiattaforma ([ADR-0001](docs/adr/0001-gui-multipiattaforma-avalonia.md), ROADMAP Fase 5). Finché non è conclusa, le sezioni sotto descrivono lo stato attuale (WinForms + WebView2).
## Comandi
- Build: `dotnet build` (LlmRun.csproj, net10.0-windows, WinForms)
- Build: `dotnet build` (agent-wiki-ui.csproj, `net10.0`, Avalonia)
- GUI: `dotnet run`; CLI: `dotnet run -- "prompt"`
- Publish single-file: `dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true`
- Test: non esistono ancora.
- Publish single-file (uno per sistema): `dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true` e `-r linux-x64`
- Test: non esistono ancora. Per verificare la GUI senza schermo si può usare `Avalonia.Headless` con `CaptureRenderedFrame()` in un progetto di prova fuori dal repo.
## Architettura
- [Program.cs](Program.cs): top-level statements. Senza argomenti avvia `Gui`; con argomenti è la CLI (`claude -p <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.
Decisioni e motivazioni negli ADR ([docs/adr/](docs/adr/)); qui solo la mappa del codice.
- [Program.cs](Program.cs): top-level statements. Con argomenti è la CLI (`Claude.RunInherited`); senza avvia Avalonia (`App`).
- [Core/Claude.cs](Core/Claude.cs): **unico punto di avvio di `claude`** (cartella dell'exe, stdin chiuso, `ArgumentList`, `--session-id`/`--resume`, `--model`/`--effort` omessi se vuoti). Contiene anche l'elenco modelli `Catalog.Models`, da aggiornare quando escono nuovi modelli.
- [Core/ChatStore.cs](Core/ChatStore.cs): cronologia, un JSON per chat in `<LocalApplicationData>/<nome-app>/chats/<guid>.json` (nome file = id di sessione, validato come Guid prima dell'uso).
- [Core/AppInfo.cs](Core/AppInfo.cs): nome dell'app, letto dall'`AssemblyName` del csproj (unica sorgente).
- [Gui/MainViewModel.cs](Gui/MainViewModel.cs): stato e logica della chat (invio, apertura/eliminazione, salvataggio subito all'invio e di nuovo alla risposta, riapertura dell'ultima chat all'avvio).
- [Gui/MainWindow.axaml](Gui/MainWindow.axaml) (+ `.cs`): layout XAML con binding compilati. [Gui/App.axaml](Gui/App.axaml): token di colore come risorse di tema chiaro/scuro e stili; specifiche in `docs/MVP.md` → "Design (UI/UX)". Cambiando l'aspetto aggiornare quella sezione.
- [Gui/MarkdownView.cs](Gui/MarkdownView.cs): renderer markdown proprio (albero Markdig → controlli Avalonia, `DisableHtml`).
- Dipendenze NuGet: Avalonia (+ Desktop, Themes.Fluent), Markdig.
## Vincoli non ovvi
- La working directory di `claude` deve essere la cartella dell'exe: usare `AppContext.BaseDirectory`, mai `Environment.CurrentDirectory` né `Assembly.Location` (vuoto in single-file).
- Lo stdin di `claude` va redirezionato e chiuso subito, altrimenti aspetta 3s.
- `claude` è un .exe nativo (`~/.local/bin`), avviabile direttamente. Se fosse `claude.cmd` serve `cmd.exe /c`.
- La logica di avvio processo è duplicata tra CLI (`Program.cs`) e GUI (`Gui.cs`): se cambia (cartella di lavoro, stdin, flag), modificarla in entrambi i punti.
- Nella GUI i link `http(s)` non devono navigare nella finestra: si aprono nel browser di sistema (`NavigationStarting`/`NewWindowRequested`).
- La GUI deve girare su un thread STA (creato a mano in `Program.cs`, perché i top-level statements non hanno `[STAThread]`): senza, WebView2 fallisce con `RPC_E_CHANGED_MODE`.
- `claude` è un eseguibile nativo (`~/.local/bin`), avviabile direttamente. Se fosse `claude.cmd` (Windows) serve `cmd.exe /c`.
- Il nome dell'app non va scritto a mano nel codice: usare `AppInfo.Name`. Cambiare nome cambia la cartella dati (vedi checklist in docs/README.md; `ChatStore` migra le cartelle dei nomi precedenti).
- Avalonia 12: la clipboard si usa con `using Avalonia.Input.Platform;` (`SetTextAsync` è un'estensione); `TextBox.Watermark` è obsoleto, usare `PlaceholderText`.
- Nel renderer markdown i colori si risolvono al momento del rendering (`TryFindResource` con `ActualThemeVariant`) e la vista si ricostruisce al cambio di tema.
- I link nel markdown si aprono con hit-test sul testo (`TextLayout.HitTestPoint`) solo se `http(s)` e se non c'è una selezione in corso.
- Il publish single-file richiede `IncludeNativeLibrariesForSelfExtract` (nel csproj): Skia e HarfBuzz sono librerie native.
## Skill del repo
-211
View File
@@ -1,211 +0,0 @@
using System.Diagnostics;
using System.Net;
using System.Reflection;
using System.Text.Json;
using System.Windows.Forms;
using Markdig;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.WinForms;
// Host sottile: l'interfaccia vive in ui.html (embedded). Protocollo messaggi: docs/MVP.md.
class Gui : Form
{
record Model(string Label, string Id);
record Msg(string Role, string Text); // Role: user | ai | err
record Stored(string Id, string Title, DateTime Updated, bool Started, List<Msg> Messages);
static readonly Model[] Models =
[
new("Fable 5.1", "claude-fable-5-1"),
new("Opus 5.5", "claude-opus-5-5"),
new("Sonnet 5.5", "claude-sonnet-5-5"),
new("Haiku 5.5", "claude-haiku-5-5"),
];
static readonly MarkdownPipeline Md = new MarkdownPipelineBuilder().UseAdvancedExtensions().DisableHtml().Build();
readonly WebView2 view = new()
{
Dock = DockStyle.Fill,
// evita il lampo bianco all'avvio in tema scuro (stesso --bg di ui.html)
DefaultBackgroundColor = Application.IsDarkModeEnabled ? Color.FromArgb(0x0F, 0x17, 0x2A) : Color.White,
};
static readonly string ChatsDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "LlmRun", "chats");
string sessionId = Guid.NewGuid().ToString();
bool started; // true dopo il primo messaggio riuscito: da lì in poi --resume
bool busy;
bool restored; // la chat più recente si riapre solo al primo avvio
List<Msg> messages = [];
public Gui()
{
Text = "LlmRun";
var area = Screen.PrimaryScreen!.WorkingArea;
StartPosition = FormStartPosition.CenterScreen;
// proporzioni 16:10 orizzontali: 75% della larghezza utile (max 1440), altezza max 90%
var w = Math.Min(1440, area.Width * 3 / 4);
var h = Math.Min(w * 10 / 16, area.Height * 9 / 10);
Size = new Size(h * 16 / 10, h);
MinimumSize = new Size(900, 600);
Controls.Add(view);
Load += async (_, _) =>
{
var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "LlmRun");
await view.EnsureCoreWebView2Async(await CoreWebView2Environment.CreateAsync(null, dir));
var web = view.CoreWebView2;
web.Settings.AreDevToolsEnabled = Debugger.IsAttached;
web.NavigationStarting += (_, e) => { if (IsWeb(e.Uri)) { e.Cancel = true; Open(e.Uri); } };
web.NewWindowRequested += (_, e) => { e.Handled = true; if (IsWeb(e.Uri)) Open(e.Uri); };
web.WebMessageReceived += (_, e) => OnMessage(e.WebMessageAsJson);
view.NavigateToString(LoadPage());
};
}
static bool IsWeb(string uri) => uri.StartsWith("http", StringComparison.OrdinalIgnoreCase);
static void Open(string uri) => Process.Start(new ProcessStartInfo(uri) { UseShellExecute = true });
static string LoadPage()
{
var asm = Assembly.GetExecutingAssembly();
using var s = asm.GetManifestResourceStream(asm.GetManifestResourceNames().First(n => n.EndsWith("ui.html")))!;
var html = new StreamReader(s).ReadToEnd();
var options = string.Concat(Models.Select(m => $"<option value=\"{m.Id}\">{WebUtility.HtmlEncode(m.Label)}</option>"));
return html.Replace("{{MODELS}}", "<option value=\"\">Predefinito</option>" + options);
}
void Post(object message) => view.CoreWebView2.PostWebMessageAsJson(JsonSerializer.Serialize(message));
void OnMessage(string json)
{
using var doc = JsonDocument.Parse(json);
var r = doc.RootElement;
switch (r.GetProperty("type").GetString())
{
case "reset" when !busy:
NewSession(); PostChats();
break;
case "list":
if (!restored) { restored = true; if (LatestId() is { } last) { OpenChat(last); break; } }
PostChats();
break;
case "open" when !busy:
OpenChat(r.GetProperty("id").GetString()!);
break;
case "delete" when !busy:
Delete(r.GetProperty("id").GetString()!);
break;
case "send" when !busy:
_ = HandleSend(r.GetProperty("text").GetString()!, r.GetProperty("model").GetString()!, r.GetProperty("effort").GetString()!);
break;
}
}
async Task HandleSend(string text, string model, string effort)
{
busy = true;
messages.Add(new("user", text));
try { Save(); PostChats(); } catch (IOException) { } // il messaggio non va perso se l'app si chiude durante l'attesa
try
{
var (code, stdout, stderr) = await RunClaude(text, model, effort);
if (code == 0) { started = true; messages.Add(new("ai", stdout)); Post(new { type = "ai", html = Markdown.ToHtml(stdout, Md), md = stdout }); }
else Fail($"claude è uscito con codice {code}\n{stderr}".Trim());
}
catch (System.ComponentModel.Win32Exception) { Fail("'claude' non trovato nel PATH."); }
catch (Exception ex) { Fail(ex.Message); }
finally
{
busy = false;
try { Save(); } catch (IOException) { } // la chat resta a schermo anche se il salvataggio fallisce
PostChats();
Post(new { type = "done" });
}
}
void Fail(string text) { messages.Add(new("err", text)); Post(new { type = "err", text }); }
void NewSession() { sessionId = Guid.NewGuid().ToString(); started = false; messages = []; }
// L'id arriva dalla pagina e diventa un nome file: accettare solo Guid.
static string? PathFor(string id) => Guid.TryParse(id, out var g) ? Path.Combine(ChatsDir, g.ToString() + ".json") : null;
void Save()
{
if (messages.Count == 0) return;
Directory.CreateDirectory(ChatsDir);
var first = messages.First(m => m.Role == "user").Text.ReplaceLineEndings(" ").Trim();
var title = first.Length > 50 ? first[..50] + "…" : first;
File.WriteAllText(PathFor(sessionId)!, JsonSerializer.Serialize(new Stored(sessionId, title, DateTime.Now, started, messages)));
}
static Stored? Read(string file)
{
try { return JsonSerializer.Deserialize<Stored>(File.ReadAllText(file)); }
catch (Exception e) when (e is IOException or JsonException) { return null; } // file corrotto: ignorato
}
static string? LatestId() => Directory.Exists(ChatsDir)
? Directory.EnumerateFiles(ChatsDir, "*.json").Select(Read).OfType<Stored>().OrderByDescending(c => c.Updated).FirstOrDefault()?.Id
: null;
void PostChats()
{
var items = Directory.Exists(ChatsDir)
? Directory.EnumerateFiles(ChatsDir, "*.json").Select(Read).OfType<Stored>()
.OrderByDescending(c => c.Updated).Select(c => new { id = c.Id, title = c.Title, updated = c.Updated }).ToArray()
: [];
Post(new { type = "chats", current = sessionId, items });
}
void OpenChat(string id)
{
var file = PathFor(id);
var chat = file is not null && File.Exists(file) ? Read(file) : null;
if (chat is null) { PostChats(); return; }
sessionId = chat.Id; started = chat.Started; messages = chat.Messages;
Post(new
{
type = "load",
messages = messages.Select(m => new { role = m.Role, text = m.Text, html = m.Role == "ai" ? Markdown.ToHtml(m.Text, Md) : null }).ToArray(),
});
PostChats();
}
void Delete(string id)
{
var file = PathFor(id);
if (file is not null && File.Exists(file)) File.Delete(file);
if (id == sessionId)
{
NewSession();
Post(new { type = "load", messages = Array.Empty<object>() });
}
PostChats();
}
async Task<(int Code, string Out, string Err)> RunClaude(string prompt, string model, string effort)
{
var psi = new ProcessStartInfo("claude")
{
WorkingDirectory = AppContext.BaseDirectory,
UseShellExecute = false,
RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true,
CreateNoWindow = true,
};
psi.ArgumentList.Add("-p");
psi.ArgumentList.Add(prompt);
psi.ArgumentList.Add(started ? "--resume" : "--session-id");
psi.ArgumentList.Add(sessionId);
foreach (var (flag, value) in new[] { ("--model", model), ("--effort", effort) })
if (value != "") { psi.ArgumentList.Add(flag); psi.ArgumentList.Add(value); }
using var p = Process.Start(psi)!;
p.StandardInput.Close(); // evita l'attesa di stdin di claude
var o = p.StandardOutput.ReadToEndAsync();
var e = p.StandardError.ReadToEndAsync();
await p.WaitForExitAsync();
return (p.ExitCode, (await o).Trim(), (await e).Trim());
}
}
+13 -17
View File
@@ -1,37 +1,33 @@
# LlmRun
# agent-wiki-ui
Eseguibile C# (.NET 10, Windows) che lancia Claude Code (`claude -p`) nella cartella in cui risiede l'eseguibile.
Eseguibile C# (.NET 10, Windows e Linux) che lancia Claude Code (`claude -p`) nella cartella in cui risiede l'eseguibile. Senza argomenti apre una GUI di chat (Avalonia), con argomenti funziona da riga di comando.
## Requisiti
- .NET SDK 10
- `claude` (Claude Code) nel PATH
- Runtime WebView2 (incluso in Windows 11) per la GUI
- `claude` (Claude Code) installato e nel PATH, già autenticato
- Per sviluppare: .NET SDK 10
- Per usare l'eseguibile pubblicato: nessun runtime (è self-contained). Su Linux serve un desktop con X11 o XWayland per la GUI.
## Uso
GUI (nessun argomento): finestra di chat in stile claude.ai, con la conversazione in markdown in alto e il campo messaggio in basso (`Invio` invia, `Maiusc+Invio` va a capo, "Nuova chat" azzera il contesto).
GUI (nessun argomento): finestra di chat in stile claude.ai, con la conversazione in markdown in alto e il campo messaggio in basso (`Invio` invia, `Maiusc+Invio` va a capo, "Nuova chat" azzera il contesto). La barra in alto del composer permette di scegliere **modello** ed **effort** di ragionamento ("Predefinito" = nessun flag, vale la config di `claude`). Le chat si salvano su disco e l'ultima si riapre all'avvio.
```
dotnet run
```
Nella GUI, la barra in alto permette di scegliere **modello** (con versione) ed **effort** di ragionamento ("predefinito" = nessun flag, vale la config di `claude`).
CLI (con argomenti): il prompt viene stampato su terminale, l'exit code è quello di `claude`.
CLI (con argomenti): il prompt viene stampato su terminale, l'exit code è quello di `claude` (127 se `claude` non è nel PATH).
```
dotnet run -- "spiega questo progetto"
```
Eseguibile già compilato:
```
dotnet build
bin\Debug\net10.0-windows\LlmRun.exe
```
## Publish single-file
Un eseguibile per sistema, senza dipendenze:
```
dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true
dotnet publish -c Release -r linux-x64 --self-contained -p:PublishSingleFile=true
```
Il risultato è in `bin/Release/net10.0/<rid>/publish/` (`agent-wiki-ui.exe` o `agent-wiki-ui`, circa 48 MB). Al primo avvio le librerie native vengono estratte in una cartella temporanea.
> **Piattaforme**: oggi solo Windows. È pianificato un eseguibile standalone per Windows e Linux con GUI in solo C# (Avalonia): vedi [ADR-0001](docs/adr/0001-gui-multipiattaforma-avalonia.md). La CLI è già portabile, la GUI attuale no. Pianificato anche l'uso di Codex CLI e Antigravity CLI già installati da terminale, oltre a Claude Code: [ADR-0002](docs/adr/0002-motori-cli-claude-codex-antigravity.md).
## Piattaforme
Windows e Linux con lo stesso codice ([ADR-0001](docs/adr/0001-gui-multipiattaforma-avalonia.md)). Pianificato l'uso di Codex CLI e Antigravity CLI già installati da terminale, oltre a Claude Code: [ADR-0002](docs/adr/0002-motori-cli-claude-codex-antigravity.md).
Documentazione: [docs/MVP.md](docs/MVP.md), [docs/ROADMAP.md](docs/ROADMAP.md), [indice e decisioni architetturali](docs/README.md).
Documentazione: [docs/README.md](docs/README.md) (indice, regole, decisioni), [docs/MVP.md](docs/MVP.md), [docs/ROADMAP.md](docs/ROADMAP.md).
File renamed without changes.
+21 -26
View File
@@ -17,45 +17,41 @@ Tutti gli argomenti sono uniti in un unico prompt.
5. Se `claude` non è trovato: messaggio chiaro ed exit code 127.
## GUI (chat)
> **Stato**: questa sezione descrive la GUI attuale (WinForms + WebView2 + `ui.html`, solo Windows). La destinazione è una GUI Avalonia in solo C# per Windows e Linux: vedi [ADR-0001](adr/0001-gui-multipiattaforma-avalonia.md) e [ROADMAP.md](ROADMAP.md), Fase 5. Il comportamento funzionale (chat, sessioni, cronologia, selettori) resta valido; cambiano tecnologia, protocollo pagina↔C# e percorso della cronologia.
Senza argomenti si apre una finestra Avalonia (C# + XAML, nessun HTML) in stile claude.ai/ChatGPT, identica su Windows e Linux. Con argomenti resta la CLI. Decisione e motivazioni: [ADR-0001](adr/0001-gui-multipiattaforma-avalonia.md).
Senza argomenti si apre una finestra WinForms che contiene solo un WebView2: l'intera interfaccia (header, conversazione, composer) è HTML/CSS in `ui.html`, in stile claude.ai/ChatGPT. Con argomenti resta la CLI.
**Struttura della pagina**
- **Barra laterale** (sinistra, comprimibile): logo e nome, pulsante **Nuova chat**, elenco delle chat salvate (titolo = inizio del primo messaggio, data/ora dell'ultimo scambio; la chat aperta è evidenziata). Un clic su una chat la riapre; la ✕ (con conferma) la elimina. Elenco vuoto: "Nessuna chat salvata".
- **Conversazione**: messaggi alternati utente/Claude, aggiunti al DOM uno alla volta (nessun ricaricamento, nessuno sfarfallio). Le risposte sono renderizzate in markdown (Markdig, lato C#): titoli, liste, tabelle, citazioni, blocchi di codice. Sulle risposte il pulsante **Copia** copia il markdown; sui blocchi di codice copia il codice.
**Struttura della finestra**
- **Barra laterale** (sinistra, comprimibile con il pulsante a tre linee): logo e nome dell'app, pulsante **Nuova chat**, elenco delle chat salvate (titolo = inizio del primo messaggio, data/ora dell'ultimo scambio; la chat aperta è evidenziata). Un clic su una chat la riapre; la ✕ (con conferma) la elimina. Elenco vuoto: "Nessuna chat salvata".
- **Conversazione**: messaggi alternati utente/Claude. Le risposte sono renderizzate in markdown: titoli, liste, tabelle, citazioni, blocchi di codice, grassetto/corsivo/barrato, codice inline, link. Il rendering è un renderer proprio ([MarkdownView.cs](../Gui/MarkdownView.cs)) che trasforma l'albero di Markdig in controlli Avalonia; l'HTML grezzo è disabilitato. Il testo è selezionabile. Sulle risposte il pulsante **Copia** copia il markdown; sui blocchi di codice copia il codice.
- **Schermata iniziale** (chat vuota): titolo, sottotitolo e quattro suggerimenti cliccabili che precompilano il campo.
- **Composer** (in basso, scheda arrotondata): textarea che cresce fino a ~8 righe, selettori **Modello** (Predefinito, Fable 5.1, Opus 5.5, Sonnet 5.5, Haiku 5.5, "Altro ID…" con campo libero) ed **Effort** (Predefinito, low, medium, high, xhigh, max), pulsante Invia circolare con icona. `Invio` invia, `Maiusc+Invio` va a capo. Hint sulla tastiera sotto la scheda.
- **Composer** (in basso, scheda arrotondata): campo di testo che cresce fino a ~8 righe, selettori **Modello** (Predefinito, Fable 5.1, Opus 5.5, Sonnet 5.5, Haiku 5.5, "Altro ID…" con campo libero) ed **Effort** (Predefinito, low, medium, high, xhigh, max), pulsante Invia circolare con icona. `Invio` invia, `Maiusc+Invio` va a capo. Hint sulla tastiera sotto la scheda.
- Passati come `--model` / `--effort`; con "Predefinito" il flag viene omesso. Solo GUI.
- **Contesto**: `claude -p --session-id <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 `<cartella dati>\<nome-app>\chats\<guid>.json` (oggi `%LOCALAPPDATA%`; vedi [Nome del progetto](README.md#nome-del-progetto)) (guid = id di sessione di `claude`, titolo, data, flag `started`, messaggi con ruolo e testo). Riaprendo una chat si rimostra la conversazione e si prosegue con `--resume <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. 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
Le scelte architetturali sono negli [ADR](README.md#indice-adr). Quanto segue descrive lo stato attuale.
- .NET 10 (WinForms). `Program.cs` (CLI + avvio) e `Gui.cs` (chat). Dipendenze NuGet solo per la GUI: `Markdig` (markdown→HTML) e `Microsoft.Web.WebView2` (richiede il runtime WebView2, già presente su Windows 11).
Le scelte architetturali sono negli [ADR](README.md#indice-adr). Sintesi:
- .NET 10, un solo progetto `net10.0` per Windows e Linux. Avalonia (GUI), Markdig (parsing markdown). Il codice è diviso in `Core/` (avvio di `claude`, cronologia, nome app) e `Gui/` (finestra, ViewModel, renderer markdown); `Program.cs` sceglie CLI o GUI.
- `ProcessStartInfo.ArgumentList` per passare il prompt (niente problemi di quoting).
- `claude` è un .exe nativo (`~/.local/bin`): si avvia direttamente, senza `cmd /c`. Lo stdin è chiuso subito per evitare l'attesa di 3s di claude.
- `claude` è un eseguibile nativo (`~/.local/bin`): si avvia direttamente, senza `cmd /c`. Lo stdin è chiuso subito per evitare l'attesa di 3s di claude.
- Working dir: `AppContext.BaseDirectory`, non la cwd del chiamante.
- L'avvio di `claude` è in un solo punto ([Core/Claude.cs](../Core/Claude.cs)), usato da CLI e GUI.
## Fuori scope (MVP)
Streaming token-per-token nella GUI, annullamento di una risposta in corso, rinomina/ricerca delle chat, config file, altri motori oltre a Claude Code (Codex, Antigravity: vedi [ADR-0002](adr/0002-motori-cli-claude-codex-antigravity.md)), logging su file, test automatici, supporto macOS. Il supporto Linux è pianificato: [ADR-0001](adr/0001-gui-multipiattaforma-avalonia.md), Fase 5.
Streaming token-per-token nella GUI, annullamento di una risposta in corso, rinomina/ricerca delle chat, config file, altri motori oltre a Claude Code (Codex, Antigravity: vedi [ADR-0002](adr/0002-motori-cli-claude-codex-antigravity.md)), logging su file, test automatici, supporto macOS.
## Criteri di accettazione
- [ ] Eseguito da un'altra cartella, `claude` parte nella cartella dell'exe (verificabile chiedendo "pwd").
@@ -64,6 +60,5 @@ Streaming token-per-token nella GUI, annullamento di una risposta in corso, rino
- [ ] Un prompt con virgolette e spazi arriva intatto.
## Domande aperte
- Nome definitivo del progetto (oggi provvisorio: vedi [Nome del progetto](README.md#nome-del-progetto)).
- Permessi: serve `--allowedTools` o `--permission-mode` per l'uso non interattivo?
- Formato output: testo semplice o `--output-format stream-json`?
+6 -6
View File
@@ -11,12 +11,12 @@ Indice e regole. Ogni informazione vive in **un solo documento**; gli altri la r
| [../CLAUDE.md](../CLAUDE.md) | Guida per Claude Code: comandi, architettura del codice, vincoli |
## Nome del progetto
Il nome è **provvisorio** (oggi `LlmRun`) e non è ancora deciso. Questa sezione è l'unica sorgente: gli altri doc scrivono "l'app" o `<nome-app>`.
Il nome è **`agent-wiki-ui`** (prima `LlmRun`). Questa sezione è l'unica sorgente: gli altri doc scrivono "l'app" o `<nome-app>`.
Se il nome cambia, aggiornare solo questi punti:
- [ ] `AssemblyName`/nome del csproj e dell'eseguibile (`<nome-app>.exe`, `<nome-app>`)
- [ ] Costante del nome nel codice (titolo finestra, intestazione della barra laterale, cartella dati)
- [ ] **Cartella dati** `<LocalApplicationData>/<nome-app>/chats`: cambiando nome le chat salvate non si trovano più; migrare o leggere anche la vecchia cartella
Se il nome cambia di nuovo, aggiornare solo questi punti:
- [ ] `AssemblyName` in [../agent-wiki-ui.csproj](../agent-wiki-ui.csproj) (e il nome del file csproj): da lì il codice legge il nome (`Core/AppInfo.cs`) per titolo finestra, intestazione della barra laterale e cartella dati; l'eseguibile diventa `<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 [../CLAUDE.md](../CLAUDE.md) (nome del csproj nei comandi)
## Flusso (doc-first, doc-as-code)
@@ -33,5 +33,5 @@ Se il nome cambia, aggiornare solo questi punti:
## Indice ADR
| ADR | Titolo | Stato |
|---|---|---|
| [0001](adr/0001-gui-multipiattaforma-avalonia.md) | GUI multipiattaforma in solo C# con Avalonia | Proposta |
| [0001](adr/0001-gui-multipiattaforma-avalonia.md) | GUI multipiattaforma in solo C# con Avalonia | Accettata |
| [0002](adr/0002-motori-cli-claude-codex-antigravity.md) | Più motori CLI (Claude Code, Codex, Antigravity) su Windows e Linux | Proposta |
+12 -11
View File
@@ -41,14 +41,15 @@ Decisione, tabella dei comandi e rischi: [ADR-0002](adr/0002-motori-cli-claude-c
## Fase 5 — Multipiattaforma (Windows + Linux), GUI in solo C#
Decisione e motivazioni: [ADR-0001](adr/0001-gui-multipiattaforma-avalonia.md). Qui solo i passi.
- [ ] Chiudere le verifiche dell'ADR-0001 con un prototipo e portarlo a "Accettata"
- [ ] Nome dell'app in un solo punto del codice (costante usata per titolo finestra, cartella dati; `AssemblyName` nel csproj), così la rinomina segue la checklist di docs/README.md
- [ ] Target `net10.0` (non più `net10.0-windows`); rimuovere WinForms e WebView2
- [ ] Estrarre in un solo punto la logica di avvio di `claude` (cartella dell'exe, stdin chiuso), oggi duplicata tra `Program.cs` e `Gui.cs`
- [ ] Cronologia in `Environment.SpecialFolder.LocalApplicationData` (stesso formato JSON, chat esistenti ancora leggibili)
- [ ] GUI Avalonia: finestra, barra laterale delle chat, conversazione, composer con modello ed effort
- [ ] Rendering markdown in Avalonia (titoli, liste, tabelle, citazioni, codice), pulsante Copia, link nel browser di sistema
- [ ] Tema chiaro/scuro che segue il sistema, con i token di MVP.md → "Design (UI/UX)"
- [ ] Eliminare `ui.html` e il protocollo `WebMessage`
- [ ] Publish single-file self-contained `win-x64` e `linux-x64`, provati su entrambi i sistemi
- [ ] Aggiornare README.md e CLAUDE.md (comandi, architettura, vincoli) a migrazione conclusa
- [x] Chiudere le verifiche dell'ADR-0001 con un prototipo e portarlo a "Accettata" (restano due verifiche indicate nell'ADR)
- [x] Nome dell'app in un solo punto: `AssemblyName` nel csproj, letto da `Core/AppInfo.cs`; la rinomina segue la checklist di docs/README.md
- [x] Rinomina in `agent-wiki-ui` (csproj, namespace) e migrazione automatica della cartella dati `LlmRun` → `agent-wiki-ui`
- [x] Target `net10.0` (non più `net10.0-windows`); rimossi WinForms e WebView2
- [x] Estrarre in un solo punto la logica di avvio di `claude` (`Core/Claude.cs`) (cartella dell'exe, stdin chiuso), oggi duplicata tra `Program.cs` e `Gui.cs`
- [x] Cronologia in `Environment.SpecialFolder.LocalApplicationData` (stesso formato JSON, chat esistenti ancora leggibili)
- [x] GUI Avalonia: finestra, barra laterale delle chat, conversazione, composer con modello ed effort
- [x] Rendering markdown in Avalonia (titoli, liste, tabelle, citazioni, codice), pulsante Copia, link nel browser di sistema
- [x] Tema chiaro/scuro che segue il sistema, con i token di MVP.md → "Design (UI/UX)" (da provare su un desktop Linux reale)
- [x] Eliminare `ui.html` e il protocollo `WebMessage`
- [ ] Publish single-file self-contained `win-x64` e `linux-x64`: compilano e partono (Linux: CLI e GUI su WSLg; Windows: solo CLI, via WSL); da provare la GUI su Windows nativo e su una distribuzione Linux pulita
- [x] Aggiornare README.md e CLAUDE.md (comandi, architettura, vincoli) a migrazione conclusa
@@ -1,6 +1,6 @@
# ADR-0001 — GUI multipiattaforma in solo C# con Avalonia
- **Stato**: Proposta
- **Stato**: Accettata (implementata; restano due verifiche, vedi sotto)
- **Data**: 2026-10-15
- **Sostituisce**: la parte "GUI WinForms + WebView2 + `ui.html`" di [MVP.md](../MVP.md) (le sezioni interessate rimandano a questo ADR)
@@ -29,7 +29,7 @@ Oggi non è possibile: WinForms e WebView2 sono solo Windows, il csproj punta a
## Decisione
La GUI viene riscritta con **Avalonia UI**:
- Layout in XAML, logica in C# con `ViewModel` e binding. `ui.html`, il protocollo `WebMessage` e le dipendenze WinForms/WebView2 spariscono.
- Rendering markdown con una libreria Avalonia basata su Markdig, al posto della conversione in HTML. I link `http(s)` si aprono nel browser di sistema.
- Rendering markdown con un **renderer proprio** che trasforma l'albero di Markdig in controlli Avalonia (`Gui/MarkdownView.cs`), al posto della conversione in HTML. Si è scelto di non usare `Markdown.Avalonia` perché l'ultima versione pubblicata (11.0.x) è per Avalonia 11, mentre si usa Avalonia 12. I link `http(s)` si aprono nel browser di sistema.
- Un solo progetto con target `net10.0` (non più `net10.0-windows`). Due publish single-file self-contained: `win-x64` e `linux-x64`.
- La CLI (`Program.cs` con argomenti) resta com'è e funziona su entrambi i sistemi.
- La cronologia usa `Environment.SpecialFolder.LocalApplicationData` (`%LOCALAPPDATA%` su Windows, `~/.local/share` su Linux), sottocartella `<nome-app>/chats`. Il nome viene da un'unica costante (vedi [Nome del progetto](../README.md#nome-del-progetto)).
@@ -49,11 +49,12 @@ La GUI viene riscritta con **Avalonia UI**:
- Su Linux serve un desktop con X11 o XWayland e le librerie di base (fontconfig ecc.).
## Da verificare (prima di passare a "Accettata")
- [ ] La libreria markdown per Avalonia scelta è compatibile con la versione di Avalonia usata e con `net10.0`, e rende tabelle, citazioni e blocchi di codice in modo accettabile.
- [ ] Il publish single-file `linux-x64` parte su una distribuzione pulita (nessuna libreria nativa mancante oltre a quelle di desktop).
- [ ] `claude` viene trovato nel PATH su Linux e la CLI mantiene exit code e codice 127.
- [ ] Il tema chiaro/scuro segue il sistema anche su Linux.
- [ ] Le chat esistenti restano leggibili (stesso formato JSON). Se il nome cambia cambia anche la cartella dati: prevedere la migrazione della vecchia cartella.
- [x] Rendering markdown: tabelle, citazioni, liste, codice, link ed enfasi verificati con uno screenshot headless in tema chiaro e scuro (libreria `Markdown.Avalonia` scartata, vedi Decisione).
- [x] Il publish single-file `linux-x64` (48 MB con compressione) parte; verificato su WSL2 con WSLg. **Da verificare** su una distribuzione desktop pulita.
- [x] Il publish single-file `win-x64` si compila e la CLI parte (provata da WSL tramite interoperabilità). **Da verificare** la GUI su Windows nativo.
- [x] `claude` viene trovato nel PATH su Linux e la CLI mantiene exit code e codice 127 (provato con un `claude` finto).
- [ ] Il tema chiaro/scuro segue il sistema anche su Linux (impostato con `RequestedThemeVariant="Default"`; non verificato su un desktop reale).
- [x] Le chat esistenti restano leggibili (stesso formato JSON). Se il nome cambia cambia anche la cartella dati: la vecchia cartella viene migrata (fatto per `LlmRun` → `agent-wiki-ui`).
## Riferimenti
- Piano di lavoro: [ROADMAP.md](../ROADMAP.md), Fase 5.
-272
View File
@@ -1,272 +0,0 @@
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LlmRun</title>
<style>
/* Token di design: vedi docs/MVP.md → "Design (UI/UX)" */
:root {
color-scheme: light dark;
--bg: #FFFFFF; --surface: #FFFFFF; --fg: #0F172A; --muted: #F1F5F9; --muted-fg: #475569;
--border: #CBD5E1; --accent: #15803D; --on-accent: #FFFFFF; --accent-soft: #DCFCE7; --err: #B91C1C;
--shadow: 0 1px 2px rgba(15,23,42,.06), 0 8px 24px rgba(15,23,42,.08);
--font: 'Segoe UI Variable Text', 'Segoe UI', system-ui, sans-serif;
--mono: 'Cascadia Code', Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root { --bg: #0F172A; --surface: #1E293B; --fg: #F8FAFC; --muted: #1E293B; --muted-fg: #94A3B8;
--border: #334155; --accent: #22C55E; --on-accent: #0F172A; --accent-soft: #14301F; --err: #F87171;
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35); }
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body { display: flex; font: 15.5px/1.65 var(--font); background: var(--bg); color: var(--fg); overflow: hidden; }
button, select, textarea, input { font: inherit; color: inherit; }
button { cursor: pointer; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
/* barra laterale */
#side { width: 272px; flex: none; display: flex; flex-direction: column; background: var(--muted); border-right: 1px solid var(--border); transition: margin-left .2s; }
#side.closed { margin-left: -272px; }
.side-top { padding: 16px 14px 10px; display: flex; flex-direction: column; gap: 14px; }
.brand { display: flex; align-items: center; gap: 10px; font-weight: 600; letter-spacing: -.01em; padding: 0 4px; }
.logo { width: 28px; height: 28px; border-radius: 8px; background: var(--accent); color: var(--on-accent); display: grid; place-items: center; }
.logo svg { width: 16px; height: 16px; fill: currentColor; stroke: none; }
.ghost { display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 14px; border-radius: 10px; border: 1px solid var(--border); background: transparent; transition: background .15s, border-color .15s; }
.ghost:hover { background: var(--bg); }
#reset { justify-content: center; background: var(--bg); }
#reset:hover { border-color: var(--accent); }
.side-h { padding: 8px 18px 4px; font-size: 12px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: var(--muted-fg); }
#list { flex: 1; overflow-y: auto; padding: 4px 8px 12px; }
.item { position: relative; border-radius: 10px; padding: 8px 38px 8px 12px; transition: background .15s; }
.item:hover { background: var(--bg); }
.item.active { background: var(--bg); box-shadow: inset 3px 0 0 var(--accent); }
.item .open { position: absolute; inset: 0; width: 100%; border: 0; background: transparent; border-radius: 10px; }
.item .t, .item .d { position: relative; display: block; pointer-events: none; }
.item .t { font-size: 14px; line-height: 1.35; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.item .d { font-size: 12px; color: var(--muted-fg); }
.item .x { position: absolute; top: 50%; right: 6px; transform: translateY(-50%); width: 26px; height: 26px; display: grid; place-items: center; border: 0; border-radius: 7px; background: transparent; color: var(--muted-fg); opacity: 0; }
.item:hover .x, .item:focus-within .x { opacity: 1; }
.item .x:hover { background: var(--muted); color: var(--err); }
.item .x svg { width: 14px; height: 14px; }
.none { padding: 10px 14px; font-size: 13px; color: var(--muted-fg); }
#main { flex: 1; min-width: 0; display: flex; flex-direction: column; position: relative; }
#toggle { position: absolute; top: 12px; left: 12px; z-index: 2; width: 36px; height: 36px; padding: 0; justify-content: center; }
/* conversazione */
#scroll { flex: 1; overflow-y: auto; scroll-behavior: smooth; }
#chat { max-width: 780px; margin: 0 auto; padding: 28px 20px 12px; }
.msg { display: flex; gap: 14px; margin-bottom: 26px; animation: in .18s ease-out; }
@keyframes in { from { opacity: 0; transform: translateY(6px); } }
.msg.user { justify-content: flex-end; }
.bubble { max-width: 85%; padding: 10px 16px; white-space: pre-wrap; overflow-wrap: anywhere; background: var(--muted); border: 1px solid var(--border); border-radius: 18px 18px 4px 18px; }
.avatar { width: 30px; height: 30px; border-radius: 50%; background: var(--accent); color: var(--on-accent); display: grid; place-items: center; flex: none; margin-top: 2px; }
.avatar svg { width: 16px; height: 16px; fill: currentColor; stroke: none; }
.body { min-width: 0; flex: 1; position: relative; }
.who { font-size: 12px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: var(--muted-fg); margin-bottom: 2px; }
.content > :first-child { margin-top: 0; } .content > :last-child { margin-bottom: 0; }
.content a { color: var(--accent); }
.content h1, .content h2, .content h3 { line-height: 1.3; margin: 1.4em 0 .5em; }
.content p, .content ul, .content ol { margin: .7em 0; }
.content code { font-family: var(--mono); font-size: 13.5px; font-variant-ligatures: none; }
.content :not(pre) > code { background: var(--muted); border: 1px solid var(--border); padding: 1px 6px; border-radius: 6px; }
.content pre { position: relative; margin: 1em 0; padding: 16px; background: var(--muted); border: 1px solid var(--border); border-radius: 12px; overflow-x: auto; }
.content table { border-collapse: collapse; display: block; overflow-x: auto; margin: 1em 0; }
.content th, .content td { border: 1px solid var(--border); padding: 7px 14px; text-align: left; }
.content th { background: var(--muted); font-weight: 600; }
.content blockquote { margin: 1em 0; padding: 2px 16px; border-left: 3px solid var(--accent); color: var(--muted-fg); }
.content hr { border: 0; border-top: 1px solid var(--border); margin: 1.5em 0; }
.mini { position: absolute; top: 8px; right: 8px; height: 28px; padding: 0 10px; font-size: 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface); opacity: 0; transition: opacity .15s; }
pre:hover > .mini, .mini:focus-visible { opacity: 1; }
.actions { margin-top: 8px; opacity: 0; transition: opacity .15s; }
.msg:hover .actions, .actions:focus-within { opacity: 1; }
.actions .ghost { height: 30px; padding: 0 10px; font-size: 13px; color: var(--muted-fg); border-color: transparent; }
.actions .ghost svg { width: 15px; height: 15px; }
.err .content { color: var(--err); border: 1px solid var(--err); border-radius: 12px; padding: 10px 14px; white-space: pre-wrap; background: color-mix(in srgb, var(--err) 8%, var(--bg)); }
.wait { color: var(--muted-fg); align-items: center; }
.dots i { display: inline-block; width: 7px; height: 7px; margin-right: 5px; border-radius: 50%; background: var(--accent); animation: pulse 1.2s infinite ease-in-out; }
.dots i:nth-child(2) { animation-delay: .15s; } .dots i:nth-child(3) { animation-delay: .3s; }
@keyframes pulse { 0%, 80%, 100% { opacity: .25 } 40% { opacity: 1 } }
/* schermata iniziale */
#empty { max-width: 640px; margin: 0 auto; padding: 12vh 20px 0; text-align: center; }
#empty .logo { width: 52px; height: 52px; border-radius: 16px; margin: 0 auto 18px; }
#empty .logo svg { width: 28px; height: 28px; }
#empty h1 { font-size: 30px; letter-spacing: -.02em; margin: 0 0 6px; }
#empty p { color: var(--muted-fg); margin: 0 0 28px; }
.chips { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.chip { text-align: left; padding: 12px 16px; border-radius: 14px; border: 1px solid var(--border); background: var(--surface); transition: border-color .15s, transform .15s, background .15s; }
.chip:hover { border-color: var(--accent); background: var(--muted); transform: translateY(-1px); }
.chip small { display: block; color: var(--muted-fg); font-size: 12.5px; }
/* composer */
footer { flex: none; padding: 8px 20px 12px; }
.composer { max-width: 780px; margin: 0 auto; background: var(--surface); border: 1px solid var(--border); border-radius: 20px; box-shadow: var(--shadow); padding: 12px 12px 10px 18px; transition: border-color .15s; }
.composer:focus-within { border-color: var(--accent); }
textarea { display: block; width: 100%; resize: none; border: 0; outline: 0; background: transparent; padding: 4px 0; max-height: 200px; line-height: 1.55; }
textarea:focus-visible { outline: 0; } /* il focus è mostrato dal bordo del .composer */
textarea::placeholder { color: var(--muted-fg); }
.bar { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.field { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted-fg); }
.field select, .field input { height: 32px; border-radius: 9px; border: 1px solid var(--border); background: var(--bg); padding: 0 8px; font-size: 13px; color: var(--fg); }
.field input { width: 190px; }
.spacer { flex: 1; }
#send { width: 40px; height: 40px; border-radius: 50%; border: 0; background: var(--accent); color: var(--on-accent); display: grid; place-items: center; transition: transform .15s, opacity .15s; }
#send:hover:not(:disabled) { transform: scale(1.06); }
#send:disabled { opacity: .35; cursor: default; }
#send svg { width: 20px; height: 20px; stroke-width: 2.4; }
.hint { max-width: 780px; margin: 8px auto 0; text-align: center; font-size: 12px; color: var(--muted-fg); }
kbd { font: 11.5px var(--mono); padding: 1px 5px; border: 1px solid var(--border); border-radius: 5px; background: var(--muted); }
::-webkit-scrollbar { width: 12px; height: 12px; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 8px; border: 3px solid var(--bg); }
@media (max-width: 720px) { .chips { grid-template-columns: 1fr; } .field input { width: 120px; } }
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; scroll-behavior: auto !important; } .dots i { opacity: .7; } }
</style>
</head>
<body>
<aside id="side" aria-label="Chat salvate">
<div class="side-top">
<div class="brand"><span class="logo"><svg viewBox="0 0 24 24"><path d="M12 2l2.4 6.6L21 11l-6.6 2.4L12 20l-2.4-6.6L3 11l6.6-2.4z"/></svg></span>LlmRun</div>
<button class="ghost" id="reset" aria-label="Nuova chat"><svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>Nuova chat</button>
</div>
<div class="side-h">Chat</div>
<div id="list"></div>
</aside>
<main id="main">
<button class="ghost" id="toggle" aria-label="Mostra o nascondi le chat" aria-expanded="true"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></svg></button>
<div id="scroll">
<div id="empty">
<span class="logo"><svg viewBox="0 0 24 24"><path d="M12 2l2.4 6.6L21 11l-6.6 2.4L12 20l-2.4-6.6L3 11l6.6-2.4z"/></svg></span>
<h1>Come posso aiutarti?</h1>
<p>Scrivi un messaggio oppure parti da uno di questi spunti.</p>
<div class="chips">
<button class="chip" data-p="Spiega cosa fa questo progetto e come è organizzato.">Spiega il progetto<small>Panoramica e struttura</small></button>
<button class="chip" data-p="Scrivi uno script PowerShell che ">Scrivi uno script<small>PowerShell, Python, C#…</small></button>
<button class="chip" data-p="Rivedi questo codice e suggerisci miglioramenti:&#10;&#10;">Rivedi del codice<small>Bug, leggibilità, prestazioni</small></button>
<button class="chip" data-p="Riassumi in modo chiaro questo testo:&#10;&#10;">Riassumi un testo<small>Punti chiave in breve</small></button>
</div>
</div>
<div id="chat" role="log" aria-live="polite" aria-label="Conversazione" hidden></div>
</div>
<footer>
<div class="composer">
<textarea id="input" rows="1" placeholder="Scrivi un messaggio a Claude…" aria-label="Messaggio"></textarea>
<div class="bar">
<label class="field">Modello
<select id="model" aria-label="Modello">{{MODELS}}<option value="__custom">Altro ID…</option></select>
<input id="custom" placeholder="es. claude-opus-5-5" aria-label="ID modello personalizzato" hidden>
</label>
<label class="field">Effort
<select id="effort" aria-label="Effort di ragionamento">
<option value="">Predefinito</option><option>low</option><option>medium</option><option>high</option><option>xhigh</option><option>max</option>
</select>
</label>
<span class="spacer"></span>
<button id="send" aria-label="Invia messaggio" disabled><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button>
</div>
</div>
<div class="hint"><kbd>Invio</kbd> per inviare · <kbd>Maiusc</kbd>+<kbd>Invio</kbd> per andare a capo</div>
</footer>
</main>
<script>
const $ = s => document.querySelector(s);
const chat = $('#chat'), empty = $('#empty'), input = $('#input'), send = $('#send'), scroller = $('#scroll');
const host = window.chrome && window.chrome.webview;
let busy = false;
const ICON_COPY = '<svg viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h9"/></svg>';
const STAR = '<svg viewBox="0 0 24 24"><path d="M12 2l2.4 6.6L21 11l-6.6 2.4L12 20l-2.4-6.6L3 11l6.6-2.4z"/></svg>';
function post(m) { host && host.postMessage(m); }
function copy(text) {
const t = document.createElement('textarea'); t.value = text; document.body.appendChild(t); t.select();
document.execCommand('copy'); t.remove();
}
function flash(btn, label) { const old = btn.innerHTML; btn.textContent = label; setTimeout(() => btn.innerHTML = old, 1400); }
function bottom() { scroller.scrollTop = scroller.scrollHeight; }
function el(html) { const d = document.createElement('div'); d.innerHTML = html.trim(); return d.firstChild; }
function esc(s) { return s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
function show(node) {
empty.hidden = true; chat.hidden = false;
chat.appendChild(node); bottom();
}
function addUser(text) { show(el(`<div class="msg user"><div class="bubble">${esc(text)}</div></div>`)); }
function addErr(text) { show(el(`<div class="msg err"><div class="body"><div class="content">${esc(text)}</div></div></div>`)); }
function addAi(html, md) {
const m = el(`<div class="msg ai"><span class="avatar">${STAR}</span><div class="body"><div class="who">Claude</div><div class="content">${html}</div>
<div class="actions"><button class="ghost" aria-label="Copia la risposta">${ICON_COPY}Copia</button></div></div></div>`);
m.querySelectorAll('pre').forEach(p => {
const b = el('<button class="mini" aria-label="Copia il codice">Copia</button>');
b.onclick = () => { copy(p.querySelector('code').innerText); flash(b, 'Copiato'); };
p.appendChild(b);
});
const a = m.querySelector('.actions button'); a.onclick = () => { copy(md); flash(a, 'Copiato'); };
show(m);
}
function setBusy(v) {
busy = v; refresh();
if (v) show(el(`<div class="msg wait" id="typing" role="status"><span class="avatar">${STAR}</span><span class="dots"><i></i><i></i><i></i></span>Claude sta scrivendo…</div>`));
else { const t = $('#typing'); t && t.remove(); input.focus(); }
}
function refresh() { send.disabled = busy || !input.value.trim(); }
function grow() { input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 200) + 'px'; refresh(); }
function submit() {
const text = input.value.trim();
if (!text || busy) return;
const sel = $('#model').value;
const model = sel === '__custom' ? $('#custom').value.trim() : sel;
addUser(text); input.value = ''; grow(); setBusy(true);
post({ type: 'send', text, model, effort: $('#effort').value });
}
input.addEventListener('input', grow);
input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); submit(); } });
send.onclick = submit;
$('#model').onchange = e => { const c = $('#custom'); c.hidden = e.target.value !== '__custom'; if (!c.hidden) c.focus(); };
function clearView() { chat.replaceChildren(); chat.hidden = true; empty.hidden = false; }
$('#reset').onclick = () => { if (busy) return; clearView(); refresh(); post({ type: 'reset' }); input.focus(); };
$('#toggle').onclick = () => { const c = $('#side').classList.toggle('closed'); $('#toggle').setAttribute('aria-expanded', String(!c)); };
const ICON_X = '<svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg>';
function renderList(items, current) {
const list = $('#list');
list.replaceChildren();
if (!items.length) { list.append(el('<div class="none">Nessuna chat salvata</div>')); return; }
for (const c of items) {
const when = new Date(c.updated).toLocaleString('it-IT', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
const row = el(`<div class="item${c.id === current ? ' active' : ''}"><button class="open" aria-label="Apri chat: ${esc(c.title)}"></button><span class="t">${esc(c.title)}</span><span class="d">${esc(when)}</span><button class="x" aria-label="Elimina chat">${ICON_X}</button></div>`);
row.querySelector('.open').onclick = () => { if (!busy && c.id !== current) post({ type: 'open', id: c.id }); };
row.querySelector('.x').onclick = () => { if (!busy && confirm('Eliminare questa chat?')) post({ type: 'delete', id: c.id }); };
list.append(row);
}
}
function load(messages) {
clearView();
for (const m of messages) {
if (m.role === 'user') addUser(m.text);
else if (m.role === 'ai') addAi(m.html, m.text);
else addErr(m.text);
}
bottom();
}
document.querySelectorAll('.chip').forEach(c => c.onclick = () => { input.value = c.dataset.p; grow(); input.focus(); input.setSelectionRange(input.value.length, input.value.length); });
host && host.addEventListener('message', e => {
const m = e.data;
if (m.type === 'ai') addAi(m.html, m.md);
else if (m.type === 'err') addErr(m.text);
else if (m.type === 'done') setBusy(false);
else if (m.type === 'chats') renderList(m.items, m.current);
else if (m.type === 'load') load(m.messages);
});
input.focus();
post({ type: 'list' });
</script>
</body>
</html>