diff --git a/CLAUDE.md b/CLAUDE.md index 19a75cf..604d0d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,17 +20,18 @@ LlmRun: eseguibile C# (.NET 10, Windows) che invoca Claude Code (`claude -p`) da ## Architettura -Tutto in [Program.cs](Program.cs): top-level statements per la CLI e la classe `Gui` (Form) per la GUI. Entrambe costruiscono un `ProcessStartInfo` per `claude -p ` con `ArgumentList` (niente quoting manuale). Nessuna dipendenza esterna. - -- CLI: stdout/stderr ereditati (streaming), exit code propagato, 127 se `claude` non è nel PATH. -- GUI: output rediretto e mostrato riga per riga. Barra impostazioni con ComboBox modello (editabile, voci "Nome versione (id)"; lista hardcoded in `Gui.Models`, da aggiornare quando escono nuovi modelli) ed effort → `--model` / `--effort`, omessi se "predefinito". +- [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. +- Dipendenze NuGet (solo GUI): Markdig, Microsoft.Web.WebView2. ## Vincoli non ovvi - La working directory di `claude` deve essere la cartella dell'exe: usare `AppContext.BaseDirectory`, mai `Environment.CurrentDirectory` né `Assembly.Location` (vuoto in single-file). - Lo stdin di `claude` va redirezionato e chiuso subito, altrimenti aspetta 3s. - `claude` è un .exe nativo (`~/.local/bin`), avviabile direttamente. Se fosse `claude.cmd` serve `cmd.exe /c`. -- La logica di avvio processo è duplicata tra CLI e GUI (volutamente, per restare in un file solo): se cambia, modificarla in entrambi i punti. +- 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. +- 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 new file mode 100644 index 0000000..88d702a --- /dev/null +++ b/Gui.cs @@ -0,0 +1,154 @@ +using System.Diagnostics; +using System.Net; +using System.Windows.Forms; +using Markdig; +using Microsoft.Web.WebView2.WinForms; + +class Gui : Form +{ + record Model(string Label, string Id) { public override string ToString() => Label; } + 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"), + ]; + 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 }; + + string sessionId = Guid.NewGuid().ToString(); + bool started; // true dopo il primo messaggio riuscito: da lì in poi --resume + + public Gui() + { + Text = "LlmRun"; + Width = 1000; Height = 700; + + 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(); + }; + } + + void Render() + { + 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}"); + } + + async Task Send() + { + 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(); + 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())); + } + catch (System.ComponentModel.Win32Exception) + { + chat.RemoveAt(chat.Count - 1); + chat.Add(("err", "'claude' non trovato nel PATH.")); + } + finally { send.Enabled = true; Render(); input.Focus(); } + } + + async Task<(int Code, string Out, string Err)> RunClaude(string prompt) + { + 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.SelectedItem is Model m ? m.Id : model.Text.Trim()), ("--effort", effort.Text) }) + if (value != "" && value != Default) { 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()); + } +} diff --git a/LlmRun.csproj b/LlmRun.csproj index b1adacc..29baf52 100644 --- a/LlmRun.csproj +++ b/LlmRun.csproj @@ -8,4 +8,9 @@ enable + + + + + diff --git a/Program.cs b/Program.cs index 4ad786a..51117d6 100644 --- a/Program.cs +++ b/Program.cs @@ -3,8 +3,15 @@ using System.Windows.Forms; if (args.Length == 0) { - ApplicationConfiguration.Initialize(); - Application.Run(new Gui()); + // WebView2 richiede un thread STA, che i top-level statements non danno + var ui = new Thread(() => + { + ApplicationConfiguration.Initialize(); + Application.Run(new Gui()); + }); + ui.SetApartmentState(ApartmentState.STA); + ui.Start(); + ui.Join(); return 0; } @@ -34,72 +41,3 @@ static ProcessStartInfo NewClaude(string prompt) psi.ArgumentList.Add(prompt); return psi; } - -class Gui : Form -{ - readonly TextBox prompt = new() { Dock = DockStyle.Top, Multiline = true, Height = 120, AcceptsReturn = true }; - readonly Button send = new() { Dock = DockStyle.Top, Text = "Invia a Claude", Height = 32 }; - readonly TextBox output = new() { Dock = DockStyle.Fill, Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Vertical }; - - record Model(string Label, string Id) { public override string ToString() => Label; } - 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"), - ]; - const string Default = "predefinito"; - readonly ComboBox model = new() { DropDownStyle = ComboBoxStyle.DropDown, Width = 300 }; // editabile: ID completo - readonly ComboBox effort = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 100 }; - - public Gui() - { - Text = "LlmRun"; - Width = 1000; Height = 700; - 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.Top, Height = 32, Padding = new Padding(4) }; - settings.Controls.AddRange([new Label { Text = "Modello", AutoSize = true, Margin = new Padding(0, 6, 0, 0) }, model, - new Label { Text = "Effort", AutoSize = true, Margin = new Padding(8, 6, 0, 0) }, effort]); - Controls.Add(output); Controls.Add(send); Controls.Add(prompt); Controls.Add(settings); // Fill per primo: l'ordine inverso dei Dock - send.Click += async (_, _) => await Run(); - } - - async Task Run() - { - if (string.IsNullOrWhiteSpace(prompt.Text)) return; - send.Enabled = false; - output.Clear(); - try - { - var psi = NewClaude(prompt.Text); - using var p = Process.Start(psi)!; - p.StandardInput.Close(); - void Append(string? l) { if (l != null) BeginInvoke(() => output.AppendText(l + Environment.NewLine)); } - p.OutputDataReceived += (_, e) => Append(e.Data); - p.ErrorDataReceived += (_, e) => Append(e.Data); - p.BeginOutputReadLine(); p.BeginErrorReadLine(); - await p.WaitForExitAsync(); - output.AppendText($"[exit {p.ExitCode}]"); - } - catch (System.ComponentModel.Win32Exception) { output.Text = "'claude' non trovato nel PATH."; } - finally { send.Enabled = true; } - } - - ProcessStartInfo NewClaude(string prompt) - { - 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); - 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); } - return psi; - } -} diff --git a/README.md b/README.md index 5c7f85d..95f9c81 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,11 @@ Eseguibile C# (.NET 10, Windows) che lancia Claude Code (`claude -p`) nella cart ## Requisiti - .NET SDK 10 - `claude` (Claude Code) nel PATH +- Runtime WebView2 (incluso in Windows 11) per la GUI ## Uso -GUI (nessun argomento): si apre una finestra con campo prompt, pulsante e output. +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). ``` dotnet run ``` diff --git a/docs/MVP.md b/docs/MVP.md index 9d7373d..a72ccc7 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -16,20 +16,24 @@ Tutti gli argomenti sono uniti in un unico prompt. 4. Esce con lo stesso exit code di `claude`. 5. Se `claude` non è trovato: messaggio chiaro ed exit code 127. -## GUI -Senza argomenti si apre una finestra WinForms (prompt, pulsante, output) che usa lo stesso lancio di `claude -p`. Con argomenti resta la CLI. -La GUI ha una barra impostazioni con due 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) e **Effort** (predefinito, low, medium, high, xhigh, max). Passati a `claude` come `--model` / `--effort`; con "predefinito" il flag viene omesso. Solo GUI: la CLI non li espone ancora. - -La finestra si apre a 1000x700 e output e prompt si ridimensionano con essa. +## 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. ## Scelte tecniche -- .NET 8+, un solo `Program.cs`, nessuna dipendenza esterna. +- .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). - `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. - Working dir: `AppContext.BaseDirectory`, non la cwd del chiamante. ## Fuori scope (MVP) -Modalità interattiva (chat), sessioni/`--resume`, config file, logging su file, test automatici, supporto Linux/macOS. +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. ## 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 cf686e2..397a1c8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -11,6 +11,8 @@ Dettagli dell'MVP in [MVP.md](MVP.md). - [x] Lancio di `claude -p` nella cartella dell'exe, streaming, exit code - [ ] Publish single-file win-x64 e verifica dei criteri di accettazione +- [x] GUI a chat: conversazione markdown in alto, input in basso, sessione con `--session-id`/`--resume` + ## Fase 2 — Robustezza - [ ] Input del prompt da stdin e da file - [ ] Timeout e cancellazione (Ctrl+C propagato al figlio) @@ -18,6 +20,6 @@ Dettagli dell'MVP in [MVP.md](MVP.md). - [ ] CLI: stesse opzioni; inoltre `--allowedTools`, `--output-format` ## Fase 3 — Oltre -- [ ] Modalità sessione (`--resume` / `--continue`) +- [ ] GUI: streaming della risposta (`--output-format stream-json`), stop, elenco chat salvate - [ ] Config file accanto all'exe - [ ] Test automatici e CI