diff --git a/CLAUDE.md b/CLAUDE.md index 1676e81..532eb6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,8 @@ 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): chat WinForms. WebView2 (conversazione, in alto) + pannello in basso (menu modello/effort, "Nuova chat", input, Invia). Ogni messaggio lancia `claude -p` con stdout/stderr rediretti; la risposta è convertita da markdown a HTML con Markdig (`DisableHtml`) e la pagina viene rigenerata per intero con `NavigateToString`. Contesto tra messaggi: `--session-id ` al primo, `--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. `--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 @@ -30,7 +31,7 @@ LlmRun: eseguibile C# (.NET 10, Windows) che invoca Claude Code (`claude -p`) da - 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. -- La GUI non mostra link né HTML della risposta nella finestra: i link `http(s)` vanno aperti nel browser di sistema. +- 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 diff --git a/Gui.cs b/Gui.cs index 88d702a..5fd0375 100644 --- a/Gui.cs +++ b/Gui.cs @@ -1,134 +1,106 @@ 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) { public override string ToString() => Label; } + record Model(string Label, string Id); static readonly Model[] Models = [ - new("Fable 5.1 (claude-fable-5-1)", "claude-fable-5-1"), - new("Opus 5.5 (claude-opus-5-5)", "claude-opus-5-5"), - new("Sonnet 5.5 (claude-sonnet-5-5)", "claude-sonnet-5-5"), - new("Haiku 5.5 (claude-haiku-5-5)", "claude-haiku-5-5"), + 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"), ]; - const string Default = "predefinito"; static readonly MarkdownPipeline Md = new MarkdownPipelineBuilder().UseAdvancedExtensions().DisableHtml().Build(); - const string Css = """ - :root { color-scheme: light dark; } - body { font: 15px/1.6 'Segoe UI', sans-serif; margin: 0; padding: 16px 0 32px; background: Canvas; color: CanvasText; } - .msg { max-width: 800px; margin: 0 auto 18px; padding: 0 16px; } - .user { text-align: right; } - .user div { display: inline-block; text-align: left; white-space: pre-wrap; background: color-mix(in srgb, CanvasText 10%, Canvas); padding: 10px 14px; border-radius: 18px; max-width: 85%; } - .err div { color: #c0392b; white-space: pre-wrap; } - .wait { opacity: .6; font-style: italic; } - pre { background: color-mix(in srgb, CanvasText 8%, Canvas); padding: 12px; border-radius: 8px; overflow-x: auto; } - code { font-family: Consolas, monospace; font-size: 13.5px; } - table { border-collapse: collapse; } th, td { border: 1px solid color-mix(in srgb, CanvasText 25%, Canvas); padding: 4px 10px; } - blockquote { margin-left: 0; padding-left: 12px; border-left: 3px solid color-mix(in srgb, CanvasText 30%, Canvas); } - """; - - // (ruolo, testo): "user" | "ai" | "err" | "wait" - readonly List<(string Role, string Text)> chat = []; - readonly WebView2 view = new() { Dock = DockStyle.Fill }; - readonly TextBox input = new() { Dock = DockStyle.Fill, Multiline = true, AcceptsReturn = true, ScrollBars = ScrollBars.Vertical, Font = new Font("Segoe UI", 11) }; - readonly Button send = new() { Text = "Invia", Dock = DockStyle.Fill }; - readonly Button reset = new() { Text = "Nuova chat", AutoSize = true }; - readonly ComboBox model = new() { DropDownStyle = ComboBoxStyle.DropDown, Width = 300 }; // editabile: ID completo - readonly ComboBox effort = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 100 }; + 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, + }; string sessionId = Guid.NewGuid().ToString(); bool started; // true dopo il primo messaggio riuscito: da lì in poi --resume + bool busy; public Gui() { Text = "LlmRun"; - Width = 1000; Height = 700; + 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); - model.Items.Add(Default); model.Items.AddRange(Models); model.SelectedIndex = 0; - effort.Items.AddRange([Default, "low", "medium", "high", "xhigh", "max"]); effort.SelectedIndex = 0; - - var settings = new FlowLayoutPanel { Dock = DockStyle.Fill, WrapContents = false }; - settings.Controls.AddRange([ - new Label { Text = "Modello", AutoSize = true, Margin = new Padding(0, 8, 0, 0) }, model, - new Label { Text = "Effort", AutoSize = true, Margin = new Padding(8, 8, 0, 0) }, effort, reset]); - - var bottom = new TableLayoutPanel { Dock = DockStyle.Bottom, Height = 140, Padding = new Padding(8), ColumnCount = 2, RowCount = 2 }; - bottom.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); - bottom.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 90)); - bottom.RowStyles.Add(new RowStyle(SizeType.Absolute, 34)); - bottom.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - bottom.Controls.Add(settings, 0, 0); bottom.SetColumnSpan(settings, 2); - bottom.Controls.Add(input, 0, 1); - bottom.Controls.Add(send, 1, 1); - - Controls.Add(view); Controls.Add(bottom); // Fill per primo: l'ordine inverso dei Dock - - send.Click += async (_, _) => await Send(); - reset.Click += (_, _) => { chat.Clear(); sessionId = Guid.NewGuid().ToString(); started = false; Render(); }; - input.KeyDown += async (_, e) => - { - if (e.KeyCode == Keys.Enter && !e.Shift) { e.SuppressKeyPress = true; await Send(); } - }; Load += async (_, _) => { var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "LlmRun"); - await view.EnsureCoreWebView2Async(await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync(null, dir)); - view.CoreWebView2.NavigationStarting += (_, e) => - { - if (e.Uri.StartsWith("http", StringComparison.OrdinalIgnoreCase)) - { - e.Cancel = true; - Process.Start(new ProcessStartInfo(e.Uri) { UseShellExecute = true }); - } - }; - view.CoreWebView2.Settings.AreDefaultContextMenusEnabled = true; - Render(); + 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()); }; } - void Render() + 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() { - if (view.CoreWebView2 == null) return; - var body = string.Concat(chat.Select(m => m.Role switch - { - "user" => $"
{WebUtility.HtmlEncode(m.Text)}
", - "ai" => $"
{Markdown.ToHtml(m.Text, Md)}
", - "err" => $"
{WebUtility.HtmlEncode(m.Text)}
", - _ => $"
{WebUtility.HtmlEncode(m.Text)}
", - })); - view.NavigateToString($"{body}"); + var asm = Assembly.GetExecutingAssembly(); + using var s = asm.GetManifestResourceStream(asm.GetManifestResourceNames().First(n => n.EndsWith("ui.html")))!; + var html = new StreamReader(s).ReadToEnd(); + var options = string.Concat(Models.Select(m => $"")); + return html.Replace("{{MODELS}}", "" + options); } - async Task Send() + void Post(object message) => view.CoreWebView2.PostWebMessageAsJson(JsonSerializer.Serialize(message)); + + void OnMessage(string json) { - var text = input.Text.Trim(); - if (text == "" || !send.Enabled) return; - send.Enabled = false; - input.Clear(); - chat.Add(("user", text)); - chat.Add(("wait", "Claude sta scrivendo…")); - Render(); + using var doc = JsonDocument.Parse(json); + var r = doc.RootElement; + switch (r.GetProperty("type").GetString()) + { + case "reset": + sessionId = Guid.NewGuid().ToString(); started = false; + 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; try { - var (code, stdout, stderr) = await RunClaude(text); - chat.RemoveAt(chat.Count - 1); - if (code == 0) { started = true; chat.Add(("ai", stdout)); } - else chat.Add(("err", $"claude è uscito con codice {code}\n{stderr}".Trim())); + 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() }); } - catch (System.ComponentModel.Win32Exception) - { - chat.RemoveAt(chat.Count - 1); - chat.Add(("err", "'claude' non trovato nel PATH.")); - } - finally { send.Enabled = true; Render(); input.Focus(); } + 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" }); } } - async Task<(int Code, string Out, string Err)> RunClaude(string prompt) + async Task<(int Code, string Out, string Err)> RunClaude(string prompt, string model, string effort) { var psi = new ProcessStartInfo("claude") { @@ -141,8 +113,8 @@ class Gui : Form psi.ArgumentList.Add(prompt); psi.ArgumentList.Add(started ? "--resume" : "--session-id"); psi.ArgumentList.Add(sessionId); - foreach (var (flag, value) in new[] { ("--model", model.SelectedItem is Model m ? m.Id : model.Text.Trim()), ("--effort", effort.Text) }) - if (value != "" && value != Default) { psi.ArgumentList.Add(flag); psi.ArgumentList.Add(value); } + 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 diff --git a/LlmRun.csproj b/LlmRun.csproj index 29baf52..d301037 100644 --- a/LlmRun.csproj +++ b/LlmRun.csproj @@ -1,4 +1,4 @@ - + Exe @@ -6,6 +6,7 @@ true enable enable + $(NoWarn);WFO5001 @@ -13,4 +14,8 @@ + + + + diff --git a/Program.cs b/Program.cs index 51117d6..44936f9 100644 --- a/Program.cs +++ b/Program.cs @@ -7,6 +7,7 @@ if (args.Length == 0) var ui = new Thread(() => { ApplicationConfiguration.Initialize(); + Application.SetColorMode(SystemColorMode.System); Application.Run(new Gui()); }); ui.SetApartmentState(ApartmentState.STA); diff --git a/docs/MVP.md b/docs/MVP.md index a72ccc7..f349b39 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -17,14 +17,29 @@ Tutti gli argomenti sono uniti in un unico prompt. 5. Se `claude` non è trovato: messaggio chiaro ed exit code 127. ## GUI (chat) -Senza argomenti si apre una finestra WinForms in stile claude.ai/ChatGPT. Con argomenti resta la CLI. -- **In alto**: la conversazione, a messaggi alternati utente/Claude. Le risposte di Claude sono renderizzate in markdown (titoli, liste, tabelle, blocchi di codice) con Markdig dentro un WebView2; i messaggi utente sono testo semplice. Il rendering segue il tema chiaro/scuro di Windows. -- **In basso**: campo di input multilinea e pulsante Invia. `Invio` invia, `Maiusc+Invio` va a capo. -- **Sopra l'input**: menu **Modello** (predefinito, Fable 5.1, Opus 5.5, Sonnet 5.5, Haiku 5.5: ogni voce mostra versione e ID completo, che è ciò che viene passato a `claude`; oppure ID digitato a mano), menu **Effort** (predefinito, low, medium, high, xhigh, max) e pulsante **Nuova chat**. Passati come `--model` / `--effort`; con "predefinito" il flag viene omesso. Solo GUI. -- **Contesto**: la conversazione prosegue con `claude -p --session-id ` al primo messaggio e `--resume ` ai successivi. "Nuova chat" svuota la vista e genera un nuovo guid. -- La risposta compare a fine generazione (nessuno streaming nella GUI); durante l'attesa si vede "Claude sta scrivendo…". Se `claude` esce con errore, lo stderr appare come messaggio di errore. -- I link nelle risposte si aprono nel browser di sistema, non nella finestra. L'HTML grezzo nel markdown è disabilitato. -- Finestra 1000x700. +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**. +- **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. +- 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"}`. + +### 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. +- **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. +- **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). ## 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). diff --git a/ui.html b/ui.html new file mode 100644 index 0000000..7a03eea --- /dev/null +++ b/ui.html @@ -0,0 +1,217 @@ + + + + + +LlmRun + + + +
+
LlmRun
+ +
+ +
+
+ +

Come posso aiutarti?

+

Scrivi un messaggio oppure parti da uno di questi spunti.

+
+ + + + +
+
+ +
+ +
+
+ +
+ + + + +
+
+
Invio per inviare · Maiusc+Invio per andare a capo
+
+ + + +