diff --git a/Core/AppInfo.cs b/Core/AppInfo.cs new file mode 100644 index 0000000..9810aca --- /dev/null +++ b/Core/AppInfo.cs @@ -0,0 +1,7 @@ +namespace AgentWikiUi.Core; + +static class AppInfo +{ + // Letto dall'AssemblyName del csproj: rinominare l'app = cambiare solo quello. + public static readonly string Name = typeof(AppInfo).Assembly.GetName().Name!; +} diff --git a/Core/ChatStore.cs b/Core/ChatStore.cs new file mode 100644 index 0000000..2908c15 --- /dev/null +++ b/Core/ChatStore.cs @@ -0,0 +1,66 @@ +using System.Text.Json; + +namespace AgentWikiUi.Core; + +record Msg(string Role, string Text); // Role: user | ai | err +record Stored(string Id, string Title, DateTime Updated, bool Started, List Messages); + +// Cronologia: un JSON per chat, il nome file è l'id di sessione di `claude`. +static class ChatStore +{ + // Nomi precedenti dell'app: le loro chat vengono spostate nella cartella attuale al primo avvio. + static readonly string[] LegacyNames = ["LlmRun"]; + + static readonly string Dir = Prepare(); + + static string Prepare() + { + var data = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var dir = Path.Combine(data, AppInfo.Name, "chats"); + if (Directory.Exists(dir)) return dir; + foreach (var name in LegacyNames) + { + var old = Path.Combine(data, name, "chats"); + if (!Directory.Exists(old)) continue; + try { Directory.CreateDirectory(Path.GetDirectoryName(dir)!); Directory.Move(old, dir); } + catch (IOException) { } // se non si riesce a spostare, si riparte da una cartella vuota + break; + } + return dir; + } + + // L'id può arrivare dalla UI e diventa un nome file: accettare solo Guid. + static string? PathFor(string id) => Guid.TryParse(id, out var g) ? Path.Combine(Dir, g.ToString() + ".json") : null; + + public static void Save(string id, bool started, List messages) + { + if (messages.Count == 0) return; + Directory.CreateDirectory(Dir); + var first = messages.First(m => m.Role == "user").Text.ReplaceLineEndings(" ").Trim(); + var title = first.Length > 50 ? first[..50] + "…" : first; + File.WriteAllText(PathFor(id)!, JsonSerializer.Serialize(new Stored(id, title, DateTime.Now, started, messages))); + } + + public static Stored? Load(string id) + { + var file = PathFor(id); + return file is not null && File.Exists(file) ? Read(file) : null; + } + + // Dalla più recente; i file corrotti sono ignorati. + public static List List() => Directory.Exists(Dir) + ? Directory.EnumerateFiles(Dir, "*.json").Select(Read).OfType().OrderByDescending(c => c.Updated).ToList() + : []; + + public static void Delete(string id) + { + var file = PathFor(id); + if (file is not null && File.Exists(file)) File.Delete(file); + } + + static Stored? Read(string file) + { + try { return JsonSerializer.Deserialize(File.ReadAllText(file)); } + catch (Exception e) when (e is IOException or JsonException) { return null; } + } +} diff --git a/Core/Claude.cs b/Core/Claude.cs new file mode 100644 index 0000000..51f93dc --- /dev/null +++ b/Core/Claude.cs @@ -0,0 +1,77 @@ +using System.Diagnostics; + +namespace AgentWikiUi.Core; + +record ModelOption(string Label, string Id); + +static class Catalog +{ + // Da aggiornare quando escono nuovi modelli. + public static readonly ModelOption[] 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"), + ]; + + public static readonly string[] Efforts = ["low", "medium", "high", "xhigh", "max"]; +} + +record RunResult(int Code, string Out, string Err); + +// Unico punto di avvio di `claude`, condiviso da CLI e GUI. +static class Claude +{ + // sessionId null = nessun contesto (CLI). Model/effort vuoti = flag omessi. + public static ProcessStartInfo CreateStart(string prompt, bool redirectOutput, string? sessionId = null, bool resume = false, string model = "", string effort = "") + { + var psi = new ProcessStartInfo("claude") + { + WorkingDirectory = AppContext.BaseDirectory, // mai la cwd del chiamante né Assembly.Location (vuoto in single-file) + UseShellExecute = false, + RedirectStandardInput = true, // chiuso subito dal chiamante: evita l'attesa di stdin di claude + RedirectStandardOutput = redirectOutput, + RedirectStandardError = redirectOutput, + CreateNoWindow = true, + }; + psi.ArgumentList.Add("-p"); + psi.ArgumentList.Add(prompt); + if (sessionId is not null) + { + psi.ArgumentList.Add(resume ? "--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); } + return psi; + } + + // CLI: stdout/stderr ereditati (streaming), exit code propagato, 127 se `claude` manca. + public static int RunInherited(string prompt) + { + try + { + using var p = Process.Start(CreateStart(prompt, redirectOutput: false))!; + p.StandardInput.Close(); + p.WaitForExit(); + return p.ExitCode; + } + catch (System.ComponentModel.Win32Exception) + { + Console.Error.WriteLine("'claude' non trovato nel PATH."); + return 127; + } + } + + // GUI: output catturato. Win32Exception se `claude` non è nel PATH. + public static async Task RunCapturedAsync(string prompt, string sessionId, bool resume, string model, string effort) + { + using var p = Process.Start(CreateStart(prompt, redirectOutput: true, sessionId, resume, model, effort))!; + p.StandardInput.Close(); + var o = p.StandardOutput.ReadToEndAsync(); + var e = p.StandardError.ReadToEndAsync(); + await p.WaitForExitAsync(); + return new(p.ExitCode, (await o).Trim(), (await e).Trim()); + } +} diff --git a/Gui/App.axaml b/Gui/App.axaml new file mode 100644 index 0000000..cd21281 --- /dev/null +++ b/Gui/App.axaml @@ -0,0 +1,123 @@ + + + + + + + #FFFFFF + #FFFFFF + #0F172A + #F1F5F9 + #475569 + #CBD5E1 + #15803D + #FFFFFF + #DCFCE7 + #B91C1C + + + #0F172A + #1E293B + #F8FAFC + #1E293B + #94A3B8 + #334155 + #22C55E + #0F172A + #14301F + #F87171 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Gui/App.axaml.cs b/Gui/App.axaml.cs new file mode 100644 index 0000000..4c61ed0 --- /dev/null +++ b/Gui/App.axaml.cs @@ -0,0 +1,17 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; + +namespace AgentWikiUi.Gui; + +class App : Application +{ + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + desktop.MainWindow = new MainWindow(); + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/Gui/ConfirmDialog.cs b/Gui/ConfirmDialog.cs new file mode 100644 index 0000000..7ad40ac --- /dev/null +++ b/Gui/ConfirmDialog.cs @@ -0,0 +1,31 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; + +namespace AgentWikiUi.Gui; + +static class ConfirmDialog +{ + public static Task Ask(Window owner, string question) + { + var yes = new Button { Content = "Elimina", MinWidth = 90 }; + var no = new Button { Content = "Annulla", MinWidth = 90 }; + var dlg = new Window + { + Title = question, Width = 340, SizeToContent = SizeToContent.Height, CanResize = false, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + Content = new StackPanel + { + Margin = new(20), Spacing = 18, + Children = + { + new TextBlock { Text = question, TextWrapping = Avalonia.Media.TextWrapping.Wrap }, + new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Right, Children = { no, yes } }, + }, + }, + }; + yes.Click += (_, _) => dlg.Close(true); + no.Click += (_, _) => dlg.Close(false); + return dlg.ShowDialog(owner); + } +} diff --git a/Gui/MainViewModel.cs b/Gui/MainViewModel.cs new file mode 100644 index 0000000..fdf69ad --- /dev/null +++ b/Gui/MainViewModel.cs @@ -0,0 +1,180 @@ +using System.Collections.ObjectModel; +using System.Windows.Input; +using AgentWikiUi.Core; + +namespace AgentWikiUi.Gui; + +class MessageVm(string role, string text) +{ + public string Role { get; } = role; + public string Text { get; } = text; + public bool IsUser => Role == "user"; + public bool IsAi => Role == "ai"; + public bool IsErr => Role == "err"; +} + +class ChatItem(string id, string title, DateTime updated, bool active) +{ + public string Id { get; } = id; + public string Title { get; } = title; + public string When { get; } = updated.ToString("d MMM HH:mm"); + public bool IsActive { get; } = active; +} + +record Suggestion(string Title, string Subtitle, string Prompt); + +class MainViewModel : ObservableObject +{ + static readonly ModelOption Default = new("Predefinito", ""); + static readonly ModelOption Custom = new("Altro ID…", "__custom"); + + public ObservableCollection Messages { get; } = []; + public ObservableCollection Chats { get; } = []; + public ModelOption[] ModelChoices { get; } = [Default, .. Catalog.Models, Custom]; + public string[] EffortChoices { get; } = ["Predefinito", .. Catalog.Efforts]; + + public Suggestion[] Suggestions { get; } = + [ + new("Spiega il progetto", "Panoramica e struttura", "Spiega cosa fa questo progetto e come è organizzato."), + new("Scrivi uno script", "PowerShell, Python, C#…", "Scrivi uno script PowerShell che "), + new("Rivedi del codice", "Bug, leggibilità, prestazioni", "Rivedi questo codice e suggerisci miglioramenti:\n\n"), + new("Riassumi un testo", "Punti chiave in breve", "Riassumi in modo chiaro questo testo:\n\n"), + ]; + + public ICommand NewChatCommand => newChat; + public ICommand CopyCommand { get; } + public ICommand SendCommand { get; } + public ICommand OpenCommand { get; } + public ICommand DeleteCommand { get; } + public ICommand SuggestCommand { get; } + public ICommand ToggleSidebarCommand { get; } + + /// Conferma di eliminazione, fornita dalla finestra. + /// Copia negli appunti, fornita dalla finestra. + public Func CopyText { get; set; } = _ => Task.CompletedTask; + public Func> ConfirmDelete { get; set; } = () => Task.FromResult(true); + /// Chiesto alla finestra di rimettere il fuoco sul campo messaggio. + public event Action? FocusInput; + + string sessionId = Guid.NewGuid().ToString(); + bool started; // true dopo il primo messaggio riuscito: da lì in poi --resume + List history = []; + + string input = ""; + bool busy, sidebarOpen = true; + ModelOption selectedModel = Default; + string customModel = ""; + string selectedEffort = "Predefinito"; + readonly RelayCommand send, newChat; + + public MainViewModel() + { + send = new(_ => _ = SendAsync(), () => CanSend); + SendCommand = send; + newChat = new(_ => NewChat(), () => !busy); + CopyCommand = new RelayCommand(t => { if (t is not null) _ = CopyText(t); }); + OpenCommand = new RelayCommand(c => { if (c is not null) OpenChat(c.Id); }); + DeleteCommand = new RelayCommand(c => { if (c is not null) _ = DeleteAsync(c); }); + SuggestCommand = new RelayCommand(s => { if (s is not null) { Input = s.Prompt; FocusInput?.Invoke(); } }); + ToggleSidebarCommand = new RelayCommand(_ => SidebarOpen = !SidebarOpen); + Messages.CollectionChanged += (_, _) => { Raise(nameof(HasMessages)); Raise(nameof(ShowEmpty)); }; + + // all'avvio riapre la chat più recente + if (ChatStore.List().FirstOrDefault() is { } last) OpenChat(last.Id); else RefreshChats(); + } + + public bool HasMessages => Messages.Count > 0; + public bool ShowEmpty => Messages.Count == 0; + public bool CanSend => !busy && !string.IsNullOrWhiteSpace(input); + + public string Input + { + get => input; + set { if (Set(ref input, value)) { Raise(nameof(CanSend)); send.Refresh(); } } + } + + public bool IsBusy + { + get => busy; + private set { if (Set(ref busy, value)) { Raise(nameof(CanSend)); send.Refresh(); newChat.Refresh(); } } + } + + public bool SidebarOpen { get => sidebarOpen; set => Set(ref sidebarOpen, value); } + public ModelOption SelectedModel + { + get => selectedModel; + set { if (Set(ref selectedModel, value)) Raise(nameof(IsCustomModel)); } + } + public bool IsCustomModel => selectedModel == Custom; + public string CustomModel { get => customModel; set => Set(ref customModel, value); } + public string SelectedEffort { get => selectedEffort; set => Set(ref selectedEffort, value); } + + void RefreshChats() + { + Chats.Clear(); + foreach (var c in ChatStore.List()) Chats.Add(new(c.Id, c.Title, c.Updated, c.Id == sessionId)); + } + + void Show(List msgs) + { + Messages.Clear(); + foreach (var m in msgs) Messages.Add(new(m.Role, m.Text)); + } + + void NewChat() + { + sessionId = Guid.NewGuid().ToString(); started = false; history = []; + Show(history); RefreshChats(); FocusInput?.Invoke(); + } + + void OpenChat(string id) + { + if (busy) return; + if (ChatStore.Load(id) is not { } chat) { RefreshChats(); return; } + sessionId = chat.Id; started = chat.Started; history = chat.Messages; + Show(history); RefreshChats(); + } + + async Task DeleteAsync(ChatItem c) + { + if (busy || !await ConfirmDelete()) return; + ChatStore.Delete(c.Id); + if (c.Id == sessionId) NewChat(); else RefreshChats(); + } + + async Task SendAsync() + { + if (!CanSend) return; + var text = input.Trim(); + var model = IsCustomModel ? customModel.Trim() : selectedModel.Id; + var effort = selectedEffort == "Predefinito" ? "" : selectedEffort; + + Input = ""; IsBusy = true; + Add("user", text); + Persist(); // il messaggio non va perso se l'app si chiude durante l'attesa + RefreshChats(); + try + { + var r = await Claude.RunCapturedAsync(text, sessionId, started, model, effort); + if (r.Code == 0) { started = true; Add("ai", r.Out); } + else Add("err", $"claude è uscito con codice {r.Code}\n{r.Err}".Trim()); + } + catch (System.ComponentModel.Win32Exception) { Add("err", "'claude' non trovato nel PATH."); } + catch (Exception ex) { Add("err", ex.Message); } + finally + { + IsBusy = false; + Persist(); + RefreshChats(); + FocusInput?.Invoke(); + } + } + + void Add(string role, string text) { history.Add(new(role, text)); Messages.Add(new(role, text)); } + + void Persist() + { + try { ChatStore.Save(sessionId, started, history); } + catch (IOException) { } // la chat resta a schermo anche se il salvataggio fallisce + } +} diff --git a/Gui/MainWindow.axaml b/Gui/MainWindow.axaml new file mode 100644 index 0000000..3786513 --- /dev/null +++ b/Gui/MainWindow.axaml @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Gui/MainWindow.axaml.cs b/Gui/MainWindow.axaml.cs new file mode 100644 index 0000000..ae0f0b6 --- /dev/null +++ b/Gui/MainWindow.axaml.cs @@ -0,0 +1,48 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Interactivity; +using Avalonia.Threading; +using AgentWikiUi.Core; + +namespace AgentWikiUi.Gui; + +partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + var vm = new MainViewModel(); + DataContext = vm; + + // proporzioni 16:10 orizzontali: 75% della larghezza utile (max 1440), altezza max 90% + var screen = Screens.Primary; + if (screen is not null) + { + var area = screen.WorkingArea; + double w = Math.Min(1440, area.Width / screen.Scaling * 3 / 4); + var h = Math.Min(w * 10 / 16, area.Height / screen.Scaling * 9 / 10); + Width = h * 16 / 10; Height = h; + } + else { Width = 1280; Height = 800; } + WindowStartupLocation = WindowStartupLocation.CenterScreen; + + vm.CopyText = async text => { if (Clipboard is { } cb) await cb.SetTextAsync(text); }; + vm.ConfirmDelete = () => ConfirmDialog.Ask(this, "Eliminare questa chat?"); + vm.FocusInput += () => Dispatcher.UIThread.Post(() => InputBox.Focus()); + vm.Messages.CollectionChanged += (_, _) => Dispatcher.UIThread.Post(() => Scroll.ScrollToEnd(), DispatcherPriority.Background); + + // Invio invia, Maiusc+Invio va a capo (tunnel: prima che il TextBox inserisca l'a capo) + InputBox.AddHandler(KeyDownEvent, (_, e) => + { + if (e.Key == Key.Enter && !e.KeyModifiers.HasFlag(KeyModifiers.Shift)) + { + e.Handled = true; + if (vm.SendCommand.CanExecute(null)) vm.SendCommand.Execute(null); + } + }, RoutingStrategies.Tunnel); + + Opened += (_, _) => InputBox.Focus(); + } +} diff --git a/Gui/MarkdownView.cs b/Gui/MarkdownView.cs new file mode 100644 index 0000000..5e12cc8 --- /dev/null +++ b/Gui/MarkdownView.cs @@ -0,0 +1,204 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Layout; +using Avalonia.Media; +using Markdig; +using Markdig.Extensions.Tables; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; + +namespace AgentWikiUi.Gui; + +/// Mostra markdown come controlli Avalonia (nessun HTML): il testo è Markdig, il rendering è qui. +class MarkdownView : Decorator +{ + public static readonly StyledProperty SourceProperty = AvaloniaProperty.Register(nameof(Source)); + static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().DisableHtml().Build(); + static readonly FontFamily Mono = new("Cascadia Code, Consolas, DejaVu Sans Mono, Liberation Mono, monospace"); + + public string? Source { get => GetValue(SourceProperty); set => SetValue(SourceProperty, value); } + + public MarkdownView() + { + // i colori si risolvono in fase di rendering: al cambio di tema si ricostruisce + ActualThemeVariantChanged += (_, _) => Rebuild(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == SourceProperty) Rebuild(); + } + + void Rebuild() => Child = string.IsNullOrEmpty(Source) ? null : RenderBlocks(Markdig.Markdown.Parse(Source, Pipeline), 10); + + IBrush Brush(string key) => this.TryFindResource(key, ActualThemeVariant, out var v) && v is IBrush b ? b : Brushes.Gray; + + Control RenderBlocks(IEnumerable blocks, double spacing) + { + var panel = new StackPanel { Spacing = spacing }; + foreach (var b in blocks) panel.Children.Add(RenderBlock(b)); + return panel; + } + + Control RenderBlock(Block block) => block switch + { + HeadingBlock h => Text(h.Inline, bold: true, size: h.Level switch { 1 => 24, 2 => 20, 3 => 18, 4 => 16, _ => 15.5 }), + ParagraphBlock p => Text(p.Inline), + FencedCodeBlock or CodeBlock => Code(string.Join('\n', ((LeafBlock)block).Lines.Lines.Take(((LeafBlock)block).Lines.Count).Select(l => l.ToString()))), + QuoteBlock q => new Border { BorderBrush = Brush("Border"), BorderThickness = new(3, 0, 0, 0), Padding = new(12, 0, 0, 0), Child = RenderBlocks(q, 8) }, + ListBlock l => List(l), + Table t => TableOf(t), + ThematicBreakBlock => new Border { Height = 1, Background = Brush("Border"), Margin = new(0, 6) }, + LeafBlock leaf => Text(leaf.Inline), + ContainerBlock c => RenderBlocks(c, 8), + _ => new Panel(), + }; + + Control List(ListBlock list) + { + var panel = new StackPanel { Spacing = 4 }; + var n = int.TryParse(list.OrderedStart, out var start) ? start : 1; + foreach (var item in list.OfType()) + { + var row = new Grid { ColumnDefinitions = new("Auto,*") }; + var marker = new TextBlock { Text = list.IsOrdered ? $"{n++}." : "•", Foreground = Brush("FgMuted"), Margin = new(0, 0, 8, 0), MinWidth = 18, TextAlignment = TextAlignment.Right }; + var body = RenderBlocks(item, 4); + Grid.SetColumn(body, 1); + row.Children.Add(marker); + row.Children.Add(body); + panel.Children.Add(row); + } + return panel; + } + + Control Code(string code) + { + var text = new SelectableTextBlock { Text = code, FontFamily = Mono, FontSize = 13.5, Padding = new(14, 12, 64, 12) }; + var copy = new Button { Content = "Copia", Classes = { "mini" }, HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Top, Margin = new(0, 6, 6, 0) }; + copy.Click += async (_, _) => + { + if (TopLevel.GetTopLevel(this)?.Clipboard is { } cb) await cb.SetTextAsync(code); + copy.Content = "Copiato"; + await Task.Delay(1400); + copy.Content = "Copia"; + }; + return new Border + { + Background = Brush("Muted"), BorderBrush = Brush("Border"), BorderThickness = new(1), CornerRadius = new(10), + Child = new Grid { Children = { new ScrollViewer { HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto, Content = text }, copy } }, + }; + } + + Control TableOf(Table table) + { + var grid = new Grid(); + var rows = table.OfType().ToList(); + var cols = rows.Max(r => r.Count); + for (var c = 0; c < cols; c++) grid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star)); + for (var r = 0; r < rows.Count; r++) + { + grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); + var cells = rows[r].OfType().ToList(); + for (var c = 0; c < cells.Count; c++) + { + var cell = RenderBlocks(cells[c], 4); + if (cell is StackPanel { Children: [SelectableTextBlock tb] }) // intestazione in grassetto + if (rows[r].IsHeader) tb.FontWeight = FontWeight.SemiBold; + var align = c < table.ColumnDefinitions.Count ? table.ColumnDefinitions[c].Alignment : null; + cell.HorizontalAlignment = align switch { TableColumnAlign.Center => HorizontalAlignment.Center, TableColumnAlign.Right => HorizontalAlignment.Right, _ => HorizontalAlignment.Left }; + var box = new Border + { + BorderBrush = Brush("Border"), BorderThickness = new(0.5), Padding = new(10, 6), + Background = rows[r].IsHeader ? Brush("Muted") : null, Child = cell, + }; + Grid.SetRow(box, r); Grid.SetColumn(box, c); + grid.Children.Add(box); + } + } + return new Border { BorderBrush = Brush("Border"), BorderThickness = new(0.5), CornerRadius = new(6), ClipToBounds = true, Child = grid }; + } + + // Testo con inline; i link sono Run colorati e il clic si risolve con l'hit-test sul testo. + SelectableTextBlock Text(ContainerInline? inline, bool bold = false, double size = 0) + { + var tb = new SelectableTextBlock { TextWrapping = TextWrapping.Wrap }; + if (size > 0) tb.FontSize = size; + if (bold) tb.FontWeight = FontWeight.SemiBold; + var links = new List<(int Start, int End, string Url)>(); + var pos = 0; + AddInlines(tb.Inlines!, inline, new(), links, ref pos); + if (links.Count > 0) WireLinks(tb, links); + return tb; + } + + record struct Style(bool Bold, bool Italic, bool Strike, bool Link); + + void AddInlines(InlineCollection target, ContainerInline? container, Style st, List<(int, int, string)> links, ref int pos) + { + if (container is null) return; + foreach (var node in container) + { + switch (node) + { + case LiteralInline lit: Add(target, lit.Content.ToString(), st, ref pos); break; + case LineBreakInline br: Add(target, br.IsHard ? "\n" : " ", st, ref pos); break; + case CodeInline code: + target.Add(new Run(code.Content) { FontFamily = Mono, FontSize = 13.5, Background = Brush("Muted") }); + pos += code.Content.Length; + break; + case EmphasisInline em: + var inner = em.DelimiterChar == '~' ? st with { Strike = true } + : em.DelimiterCount >= 2 ? st with { Bold = true } : st with { Italic = true }; + AddInlines(target, em, inner, links, ref pos); + break; + case LinkInline { IsImage: true } img: + Add(target, "[" + string.Concat(img.OfType().Select(l => l.Content.ToString())) + "]", st, ref pos); + break; + case LinkInline link: + var start = pos; + AddInlines(target, link, st with { Link = true }, links, ref pos); + if (link.Url is { } url) links.Add((start, pos, url)); + break; + case AutolinkInline auto: + var s = pos; + Add(target, auto.Url, st with { Link = true }, ref pos); + links.Add((s, pos, auto.Url)); + break; + case ContainerInline c: AddInlines(target, c, st, links, ref pos); break; + default: break; + } + } + } + + void Add(InlineCollection target, string text, Style st, ref int pos) + { + var run = new Run(text); + if (st.Bold) run.FontWeight = FontWeight.Bold; + if (st.Italic) run.FontStyle = FontStyle.Italic; + if (st.Strike) run.TextDecorations = TextDecorations.Strikethrough; + if (st.Link) { run.Foreground = Brush("Accent"); run.TextDecorations = TextDecorations.Underline; } + target.Add(run); + pos += text.Length; + } + + void WireLinks(SelectableTextBlock tb, List<(int Start, int End, string Url)> links) + { + string? UrlAt(Point p) + { + var i = tb.TextLayout.HitTestPoint(p).TextPosition; + foreach (var (s, e, url) in links) if (i >= s && i < e) return url; + return null; + } + tb.PointerMoved += (_, e) => tb.Cursor = UrlAt(e.GetPosition(tb)) is null ? null : new Cursor(StandardCursorType.Hand); + tb.PointerReleased += async (_, e) => + { + if (tb.SelectionStart != tb.SelectionEnd) return; // era una selezione, non un clic + if (UrlAt(e.GetPosition(tb)) is { } url && Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https") + if (TopLevel.GetTopLevel(this)?.Launcher is { } l) await l.LaunchUriAsync(uri); + }; + } +} diff --git a/Gui/ObservableObject.cs b/Gui/ObservableObject.cs new file mode 100644 index 0000000..2f45502 --- /dev/null +++ b/Gui/ObservableObject.cs @@ -0,0 +1,28 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows.Input; + +namespace AgentWikiUi.Gui; + +class ObservableObject : INotifyPropertyChanged +{ + public event PropertyChangedEventHandler? PropertyChanged; + + protected bool Set(ref T field, T value, [CallerMemberName] string? name = null) + { + if (EqualityComparer.Default.Equals(field, value)) return false; + field = value; + Raise(name); + return true; + } + + protected void Raise([CallerMemberName] string? name = null) => PropertyChanged?.Invoke(this, new(name)); +} + +class RelayCommand(Action run, Func? canRun = null) : ICommand +{ + public event EventHandler? CanExecuteChanged; + public bool CanExecute(object? parameter) => canRun?.Invoke() ?? true; + public void Execute(object? parameter) => run(parameter is T t ? t : default); + public void Refresh() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); +} diff --git a/Program.cs b/Program.cs index 44936f9..ab5c1f3 100644 --- a/Program.cs +++ b/Program.cs @@ -1,44 +1,8 @@ -using System.Diagnostics; -using System.Windows.Forms; +using Avalonia; +using AgentWikiUi.Core; +using AgentWikiUi.Gui; -if (args.Length == 0) -{ - // WebView2 richiede un thread STA, che i top-level statements non danno - var ui = new Thread(() => - { - ApplicationConfiguration.Initialize(); - Application.SetColorMode(SystemColorMode.System); - Application.Run(new Gui()); - }); - ui.SetApartmentState(ApartmentState.STA); - ui.Start(); - ui.Join(); - return 0; -} +// Con argomenti: CLI (tutti gli argomenti uniti in un prompt). Senza: GUI. +if (args.Length > 0) return Claude.RunInherited(string.Join(' ', args)); -var psi = NewClaude(string.Join(' ', args)); -try -{ - using var p = Process.Start(psi)!; // stdout/stderr ereditati: streaming in tempo reale - p.StandardInput.Close(); - p.WaitForExit(); - return p.ExitCode; -} -catch (System.ComponentModel.Win32Exception) -{ - Console.Error.WriteLine("'claude' non trovato nel PATH."); - return 127; -} - -static ProcessStartInfo NewClaude(string prompt) -{ - var psi = new ProcessStartInfo("claude") - { - WorkingDirectory = AppContext.BaseDirectory, - UseShellExecute = false, - RedirectStandardInput = true, // evita l'attesa di stdin di claude - }; - psi.ArgumentList.Add("-p"); - psi.ArgumentList.Add(prompt); - return psi; -} +return AppBuilder.Configure().UsePlatformDetect().StartWithClassicDesktopLifetime(args); diff --git a/agent-wiki-ui.csproj b/agent-wiki-ui.csproj index d301037..99361c7 100644 --- a/agent-wiki-ui.csproj +++ b/agent-wiki-ui.csproj @@ -2,20 +2,24 @@ Exe - net10.0-windows - true + net10.0 + + agent-wiki-ui + AgentWikiUi enable enable - $(NoWarn);WFO5001 + true + + true + true + none + + + - - - - -