diff --git a/CLAUDE.md b/CLAUDE.md index 532eb6b..7edc027 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ LlmRun: eseguibile C# (.NET 10, Windows) che invoca Claude Code (`claude -p`) da ## Architettura - [Program.cs](Program.cs): top-level statements. Senza argomenti avvia `Gui`; con argomenti è la CLI (`claude -p ` con `ArgumentList`, stdout/stderr ereditati, exit code propagato, 127 se `claude` non è nel PATH). -- [Gui.cs](Gui.cs): host WinForms sottile. Un solo WebView2 che carica [ui.html](ui.html) (embedded resource; `{{MODELS}}` è sostituito con le opzioni dei modelli). Pagina e C# dialogano con `WebMessage` JSON (protocollo in `docs/MVP.md`). Per ogni `send` C# lancia `claude -p` con stdout/stderr rediretti, converte la risposta da markdown a HTML con Markdig (`DisableHtml`) e la rimanda alla pagina. Contesto: `--session-id ` al primo messaggio, `--resume ` poi. `--model`/`--effort` omessi se "Predefinito". Lista modelli hardcoded in `Gui.Models`, da aggiornare quando escono nuovi modelli. +- [Gui.cs](Gui.cs): host WinForms sottile. Un solo WebView2 che carica [ui.html](ui.html) (embedded resource; `{{MODELS}}` è sostituito con le opzioni dei modelli). Pagina e C# dialogano con `WebMessage` JSON (protocollo in `docs/MVP.md`). Per ogni `send` C# lancia `claude -p` con stdout/stderr rediretti, converte la risposta da markdown a HTML con Markdig (`DisableHtml`) e la rimanda alla pagina. Contesto: `--session-id ` al primo messaggio, `--resume ` poi. Cronologia: un JSON per chat in `%LOCALAPPDATA%\LlmRun\chats\.json` (il nome file è l'id di sessione, validato come Guid prima dell'uso), salvato appena l'utente invia e di nuovo alla risposta; all'avvio (primo `list` dalla pagina) viene riaperta la chat più recente. `--model`/`--effort` omessi se "Predefinito". Lista modelli hardcoded in `Gui.Models`, da aggiornare quando escono nuovi modelli. - [ui.html](ui.html): tutta la UI (HTML+CSS+JS, nessuna libreria). Token di colore/tipografia come variabili CSS in cima; specifiche in `docs/MVP.md` → "Design (UI/UX)". Cambiando l'aspetto aggiornare quella sezione. - Dipendenze NuGet (solo GUI): Markdig, Microsoft.Web.WebView2. diff --git a/Gui.cs b/Gui.cs index 5fd0375..045f399 100644 --- a/Gui.cs +++ b/Gui.cs @@ -11,6 +11,8 @@ using Microsoft.Web.WebView2.WinForms; class Gui : Form { record Model(string Label, string Id); + record Msg(string Role, string Text); // Role: user | ai | err + record Stored(string Id, string Title, DateTime Updated, bool Started, List Messages); static readonly Model[] Models = [ new("Fable 5.1", "claude-fable-5-1"), @@ -28,9 +30,13 @@ class Gui : Form DefaultBackgroundColor = Application.IsDarkModeEnabled ? Color.FromArgb(0x0F, 0x17, 0x2A) : Color.White, }; + static readonly string ChatsDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "LlmRun", "chats"); + string sessionId = Guid.NewGuid().ToString(); bool started; // true dopo il primo messaggio riuscito: da lì in poi --resume bool busy; + bool restored; // la chat più recente si riapre solo al primo avvio + List messages = []; public Gui() { @@ -77,8 +83,18 @@ class Gui : Form var r = doc.RootElement; switch (r.GetProperty("type").GetString()) { - case "reset": - sessionId = Guid.NewGuid().ToString(); started = false; + 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()!); @@ -89,15 +105,84 @@ class Gui : Form 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; Post(new { type = "ai", html = Markdown.ToHtml(stdout, Md), md = stdout }); } - else Post(new { type = "err", text = $"claude è uscito con codice {code}\n{stderr}".Trim() }); + 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) { Post(new { type = "err", text = "'claude' non trovato nel PATH." }); } - catch (Exception ex) { Post(new { type = "err", text = ex.Message }); } - finally { busy = false; Post(new { type = "done" }); } + catch (System.ComponentModel.Win32Exception) { Fail("'claude' non trovato nel PATH."); } + catch (Exception ex) { Fail(ex.Message); } + finally + { + busy = false; + try { Save(); } catch (IOException) { } // la chat resta a schermo anche se il salvataggio fallisce + PostChats(); + Post(new { type = "done" }); + } + } + + void Fail(string text) { messages.Add(new("err", text)); Post(new { type = "err", text }); } + + void NewSession() { sessionId = Guid.NewGuid().ToString(); started = false; messages = []; } + + // L'id arriva dalla pagina e diventa un nome file: accettare solo Guid. + static string? PathFor(string id) => Guid.TryParse(id, out var g) ? Path.Combine(ChatsDir, g.ToString() + ".json") : null; + + void Save() + { + if (messages.Count == 0) return; + Directory.CreateDirectory(ChatsDir); + var first = messages.First(m => m.Role == "user").Text.ReplaceLineEndings(" ").Trim(); + var title = first.Length > 50 ? first[..50] + "…" : first; + File.WriteAllText(PathFor(sessionId)!, JsonSerializer.Serialize(new Stored(sessionId, title, DateTime.Now, started, messages))); + } + + static Stored? Read(string file) + { + try { return JsonSerializer.Deserialize(File.ReadAllText(file)); } + catch (Exception e) when (e is IOException or JsonException) { return null; } // file corrotto: ignorato + } + + static string? LatestId() => Directory.Exists(ChatsDir) + ? Directory.EnumerateFiles(ChatsDir, "*.json").Select(Read).OfType().OrderByDescending(c => c.Updated).FirstOrDefault()?.Id + : null; + + void PostChats() + { + var items = Directory.Exists(ChatsDir) + ? Directory.EnumerateFiles(ChatsDir, "*.json").Select(Read).OfType() + .OrderByDescending(c => c.Updated).Select(c => new { id = c.Id, title = c.Title, updated = c.Updated }).ToArray() + : []; + Post(new { type = "chats", current = sessionId, items }); + } + + void OpenChat(string id) + { + var file = PathFor(id); + var chat = file is not null && File.Exists(file) ? Read(file) : null; + if (chat is null) { PostChats(); return; } + sessionId = chat.Id; started = chat.Started; messages = chat.Messages; + Post(new + { + type = "load", + messages = messages.Select(m => new { role = m.Role, text = m.Text, html = m.Role == "ai" ? Markdown.ToHtml(m.Text, Md) : null }).ToArray(), + }); + PostChats(); + } + + void Delete(string id) + { + var file = PathFor(id); + if (file is not null && File.Exists(file)) File.Delete(file); + if (id == sessionId) + { + NewSession(); + Post(new { type = "load", messages = Array.Empty() }); + } + PostChats(); } async Task<(int Code, string Out, string Err)> RunClaude(string prompt, string model, string effort) diff --git a/docs/MVP.md b/docs/MVP.md index f349b39..8d75eff 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -20,17 +20,19 @@ Tutti gli argomenti sono uniti in un unico prompt. 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** -- **Header**: logo e nome, pulsante **Nuova chat**. +- **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. - **Contesto**: `claude -p --session-id ` al primo messaggio, `--resume ` ai successivi. "Nuova chat" svuota la vista e genera un nuovo guid. +- **Cronologia**: dopo ogni scambio la chat è salvata in `%LOCALAPPDATA%\LlmRun\chats\.json` (guid = id di sessione di `claude`, titolo, data, flag `started`, messaggi con ruolo e testo). Riaprendo una chat si rimostra la conversazione e si prosegue con `--resume `, quindi `claude` ricorda il contesto. Il messaggio dell'utente viene salvato subito, prima della risposta (così non si perde se l'app si chiude mentre Claude risponde); la risposta si aggiunge appena arriva. Una chat nuova e vuota non viene salvata. **All'avvio l'app riapre automaticamente l'ultima conversazione** (la più recente); se non ce ne sono parte con la schermata iniziale. "Nuova chat" resta sempre disponibile. Eliminare una chat cancella solo il file JSON, non la sessione di `claude`. Non si può aprire/eliminare una chat mentre Claude sta rispondendo. +- **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"}`. C#→pagina: `{type:"ai", html, md}`, `{type:"err", text}`, `{type:"done"}`. +**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). ### Design (UI/UX) Sorgente di verità per l'aspetto, derivato dalla skill `ui-ux-pro-max` (stile "strumento per sviluppatori": slate + accento verde). @@ -38,6 +40,7 @@ Sorgente di verità per l'aspetto, derivato dalla skill `ui-ux-pro-max` (stile " - **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). @@ -48,7 +51,7 @@ Sorgente di verità per l'aspetto, derivato dalla skill `ui-ux-pro-max` (stile " - Working dir: `AppContext.BaseDirectory`, non la cwd del chiamante. ## Fuori scope (MVP) -Streaming token-per-token nella GUI, annullamento di una risposta in corso, salvataggio/riapertura delle chat, config file, 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, logging su file, test automatici, supporto Linux/macOS. ## Criteri di accettazione - [ ] Eseguito da un'altra cartella, `claude` parte nella cartella dell'exe (verificabile chiedendo "pwd"). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 397a1c8..1150288 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -20,6 +20,7 @@ Dettagli dell'MVP in [MVP.md](MVP.md). - [ ] CLI: stesse opzioni; inoltre `--allowedTools`, `--output-format` ## Fase 3 — Oltre -- [ ] GUI: streaming della risposta (`--output-format stream-json`), stop, elenco chat salvate +- [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 diff --git a/ui.html b/ui.html index 7a03eea..cf3ae99 100644 --- a/ui.html +++ b/ui.html @@ -21,19 +21,39 @@ } * { box-sizing: border-box; } html, body { height: 100%; margin: 0; } -body { display: flex; flex-direction: column; font: 15.5px/1.65 var(--font); background: var(--bg); color: var(--fg); overflow: hidden; } +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; } -/* header */ -header { display: flex; align-items: center; justify-content: space-between; height: 54px; padding: 0 20px; border-bottom: 1px solid var(--border); flex: none; } -.brand { display: flex; align-items: center; gap: 10px; font-weight: 600; letter-spacing: -.01em; } +/* 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(--muted); } +.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; } @@ -106,10 +126,16 @@ kbd { font: 11.5px var(--mono); padding: 1px 5px; border: 1px solid var(--border -
-
LlmRun
- -
+ +
+
@@ -145,6 +171,7 @@ kbd { font: 11.5px var(--mono); padding: 1px 5px; border: 1px solid var(--border
Invio per inviare · Maiusc+Invio per andare a capo
+