Interfaccia riscritta in HTML/CSS e finestra 16:10
Tutta la UI passa in ui.html (WebView2): header, schermata iniziale con suggerimenti, composer a scheda con selettori modello/effort, messaggi aggiunti al DOM senza ricaricare la pagina, pulsanti Copia, attesa animata, tema chiaro/scuro di Windows. Gui.cs diventa un host sottile che dialoga con la pagina via WebMessage. La finestra ha proporzioni 16:10 calcolate sull'area utile dello schermo. Documentazione aggiornata. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ba2c189750
commit
0d1784c0f4
6 files changed
+318
-107
No files matched your search
@@ -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 <prompt>` 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 <guid>` al primo, `--resume <guid>` 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 <guid>` al primo messaggio, `--resume <guid>` 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
|
||||
|
||||
@@ -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" => $"<div class='msg user'><div>{WebUtility.HtmlEncode(m.Text)}</div></div>",
|
||||
"ai" => $"<div class='msg'>{Markdown.ToHtml(m.Text, Md)}</div>",
|
||||
"err" => $"<div class='msg err'><div>{WebUtility.HtmlEncode(m.Text)}</div></div>",
|
||||
_ => $"<div class='msg wait'>{WebUtility.HtmlEncode(m.Text)}</div>",
|
||||
}));
|
||||
view.NavigateToString($"<!doctype html><meta charset='utf-8'><style>{Css}</style><body>{body}<script>scrollTo(0,document.body.scrollHeight)</script></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 => $"<option value=\"{m.Id}\">{WebUtility.HtmlEncode(m.Label)}</option>"));
|
||||
return html.Replace("{{MODELS}}", "<option value=\"\">Predefinito</option>" + 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
|
||||
|
||||
+6
-1
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
@@ -6,6 +6,7 @@
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<NoWarn>$(NoWarn);WFO5001</NoWarn> <!-- SetColorMode è sperimentale -->
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -13,4 +14,8 @@
|
||||
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4258.31" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="ui.html" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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);
|
||||
|
||||
+23
-8
@@ -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 <guid>` al primo messaggio e `--resume <guid>` 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 <guid>` al primo messaggio, `--resume <guid>` 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).
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
<!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; flex-direction: column; 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; }
|
||||
.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); }
|
||||
|
||||
/* 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>
|
||||
<header>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<div id="scroll">
|
||||
<div id="empty">
|
||||
<span class="logo"><svg viewBox="0 0 24 24"><path d="M12 2l2.4 6.6L21 11l-6.6 2.4L12 20l-2.4-6.6L3 11l6.6-2.4z"/></svg></span>
|
||||
<h1>Come posso aiutarti?</h1>
|
||||
<p>Scrivi un messaggio oppure parti da uno di questi spunti.</p>
|
||||
<div class="chips">
|
||||
<button class="chip" data-p="Spiega cosa fa questo progetto e come è organizzato.">Spiega il progetto<small>Panoramica e struttura</small></button>
|
||||
<button class="chip" data-p="Scrivi uno script PowerShell che ">Scrivi uno script<small>PowerShell, Python, C#…</small></button>
|
||||
<button class="chip" data-p="Rivedi questo codice e suggerisci miglioramenti: ">Rivedi del codice<small>Bug, leggibilità, prestazioni</small></button>
|
||||
<button class="chip" data-p="Riassumi in modo chiaro questo testo: ">Riassumi un testo<small>Punti chiave in breve</small></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="chat" role="log" aria-live="polite" aria-label="Conversazione" hidden></div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div class="composer">
|
||||
<textarea id="input" rows="1" placeholder="Scrivi un messaggio a Claude…" aria-label="Messaggio"></textarea>
|
||||
<div class="bar">
|
||||
<label class="field">Modello
|
||||
<select id="model" aria-label="Modello">{{MODELS}}<option value="__custom">Altro ID…</option></select>
|
||||
<input id="custom" placeholder="es. claude-opus-5-5" aria-label="ID modello personalizzato" hidden>
|
||||
</label>
|
||||
<label class="field">Effort
|
||||
<select id="effort" aria-label="Effort di ragionamento">
|
||||
<option value="">Predefinito</option><option>low</option><option>medium</option><option>high</option><option>xhigh</option><option>max</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="spacer"></span>
|
||||
<button id="send" aria-label="Invia messaggio" disabled><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint"><kbd>Invio</kbd> per inviare · <kbd>Maiusc</kbd>+<kbd>Invio</kbd> per andare a capo</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const $ = s => document.querySelector(s);
|
||||
const chat = $('#chat'), empty = $('#empty'), input = $('#input'), send = $('#send'), scroller = $('#scroll');
|
||||
const host = window.chrome && window.chrome.webview;
|
||||
let busy = false;
|
||||
const ICON_COPY = '<svg viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h9"/></svg>';
|
||||
const STAR = '<svg viewBox="0 0 24 24"><path d="M12 2l2.4 6.6L21 11l-6.6 2.4L12 20l-2.4-6.6L3 11l6.6-2.4z"/></svg>';
|
||||
|
||||
function post(m) { host && host.postMessage(m); }
|
||||
function copy(text) {
|
||||
const t = document.createElement('textarea'); t.value = text; document.body.appendChild(t); t.select();
|
||||
document.execCommand('copy'); t.remove();
|
||||
}
|
||||
function flash(btn, label) { const old = btn.innerHTML; btn.textContent = label; setTimeout(() => btn.innerHTML = old, 1400); }
|
||||
function bottom() { scroller.scrollTop = scroller.scrollHeight; }
|
||||
function el(html) { const d = document.createElement('div'); d.innerHTML = html.trim(); return d.firstChild; }
|
||||
function esc(s) { return s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
||||
|
||||
function show(node) {
|
||||
empty.hidden = true; chat.hidden = false;
|
||||
chat.appendChild(node); bottom();
|
||||
}
|
||||
function addUser(text) { show(el(`<div class="msg user"><div class="bubble">${esc(text)}</div></div>`)); }
|
||||
function addErr(text) { show(el(`<div class="msg err"><div class="body"><div class="content">${esc(text)}</div></div></div>`)); }
|
||||
function addAi(html, md) {
|
||||
const m = el(`<div class="msg ai"><span class="avatar">${STAR}</span><div class="body"><div class="who">Claude</div><div class="content">${html}</div>
|
||||
<div class="actions"><button class="ghost" aria-label="Copia la risposta">${ICON_COPY}Copia</button></div></div></div>`);
|
||||
m.querySelectorAll('pre').forEach(p => {
|
||||
const b = el('<button class="mini" aria-label="Copia il codice">Copia</button>');
|
||||
b.onclick = () => { copy(p.querySelector('code').innerText); flash(b, 'Copiato'); };
|
||||
p.appendChild(b);
|
||||
});
|
||||
const a = m.querySelector('.actions button'); a.onclick = () => { copy(md); flash(a, 'Copiato'); };
|
||||
show(m);
|
||||
}
|
||||
function setBusy(v) {
|
||||
busy = v; refresh();
|
||||
if (v) show(el(`<div class="msg wait" id="typing" role="status"><span class="avatar">${STAR}</span><span class="dots"><i></i><i></i><i></i></span>Claude sta scrivendo…</div>`));
|
||||
else { const t = $('#typing'); t && t.remove(); input.focus(); }
|
||||
}
|
||||
function refresh() { send.disabled = busy || !input.value.trim(); }
|
||||
function grow() { input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 200) + 'px'; refresh(); }
|
||||
|
||||
function submit() {
|
||||
const text = input.value.trim();
|
||||
if (!text || busy) return;
|
||||
const sel = $('#model').value;
|
||||
const model = sel === '__custom' ? $('#custom').value.trim() : sel;
|
||||
addUser(text); input.value = ''; grow(); setBusy(true);
|
||||
post({ type: 'send', text, model, effort: $('#effort').value });
|
||||
}
|
||||
|
||||
input.addEventListener('input', grow);
|
||||
input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); submit(); } });
|
||||
send.onclick = submit;
|
||||
$('#model').onchange = e => { const c = $('#custom'); c.hidden = e.target.value !== '__custom'; if (!c.hidden) c.focus(); };
|
||||
$('#reset').onclick = () => { chat.replaceChildren(); chat.hidden = true; empty.hidden = false; busy = false; refresh(); post({ type: 'reset' }); input.focus(); };
|
||||
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);
|
||||
});
|
||||
input.focus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user