Motori multipli: Codex e interruttore CLI nelle Impostazioni

Introduce l'astrazione Engine (Claude Code, Codex) con argomenti e parser
propri e EngineRunner come unico punto di avvio, al posto di Claude.cs. Il
JSON delle chat salva motore e id di sessione (i file vecchi restano chat di
Claude Code). Cambiare CLI apre una nuova chat; modelli ed effort seguono il
motore; un motore non installato non è selezionabile. La CLI accetta
--engine <id>. Codex gira in sola lettura e la sessione si legge da
thread.started.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-15 11:40:47 +02:00
1 parent c50bd63723
commit e60d64ed58
6 files changed
+349 -34

No files matched your search

+92 -25
View File
@@ -4,32 +4,41 @@ using AgentWikiUi.Core;
namespace AgentWikiUi.Gui;
class MessageVm(string role, string text)
class MessageVm(string role, string text, string author)
{
public string Role { get; } = role;
public string Text { get; } = text;
public string Author { get; } = author;
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)
class ChatItem(string id, string title, DateTime updated, bool active, string engineName)
{
public string Id { get; } = id;
public string Title { get; } = title;
public string When { get; } = updated.ToString("d MMM HH:mm");
public string When { get; } = $"{engineName} · {updated:d MMM HH:mm}";
public bool IsActive { get; } = active;
}
/// <summary>Voce dell'interruttore CLI: un motore non installato è mostrato ma non selezionabile.</summary>
class EngineChoice(Engine engine)
{
public Engine Engine { get; } = engine;
public bool Installed { get; } = engine.IsInstalled();
public string Label => Installed ? Engine.Name : $"{Engine.Name} (non installato)";
}
class MainViewModel : ObservableObject
{
static readonly ModelOption Default = new("Predefinito", "");
static readonly ModelOption Custom = new("Altro ID…", "__custom");
const string DefaultEffort = "Predefinito";
public ObservableCollection<MessageVm> Messages { get; } = [];
public ObservableCollection<ChatItem> Chats { get; } = [];
public ModelOption[] ModelChoices { get; } = [Default, .. Catalog.Models, Custom];
public string[] EffortChoices { get; } = ["Predefinito", .. Catalog.Efforts];
public EngineChoice[] EngineChoices { get; } = Engines.All.Select(e => new EngineChoice(e)).ToArray();
public ICommand NewChatCommand => newChat;
public ICommand CopyCommand { get; }
@@ -39,25 +48,33 @@ class MainViewModel : ObservableObject
public ICommand ToggleSidebarCommand { get; }
/// <summary>Conferma di eliminazione, fornita dalla finestra.</summary>
public Func<Task<bool>> ConfirmDelete { get; set; } = () => Task.FromResult(true);
/// <summary>Copia negli appunti, fornita dalla finestra.</summary>
public Func<string, Task> CopyText { get; set; } = _ => Task.CompletedTask;
public Func<Task<bool>> ConfirmDelete { get; set; } = () => Task.FromResult(true);
/// <summary>Chiesto alla finestra di rimettere il fuoco sul campo messaggio.</summary>
public event Action? FocusInput;
string sessionId = Guid.NewGuid().ToString();
bool started; // true dopo il primo messaggio riuscito: da lì in poi --resume
// Una chat = un motore. chatId è il nome del file; sessionId è l'id del motore:
// per Claude Code lo sceglie l'app (= chatId), per Codex arriva dall'output dopo il primo messaggio.
string chatId = Guid.NewGuid().ToString();
string? sessionId;
bool started; // true dopo il primo messaggio riuscito: da lì in poi si riprende la sessione
List<Msg> history = [];
EngineChoice selectedEngine;
ModelOption[] modelChoices = [];
string[] effortChoices = [];
string input = "";
bool busy, sidebarOpen = true;
ModelOption selectedModel = Default;
string customModel = "";
string selectedEffort = "Predefinito";
string selectedEffort = DefaultEffort;
readonly RelayCommand<object> send, newChat;
public MainViewModel()
{
selectedEngine = EngineChoices.FirstOrDefault(c => c.Installed) ?? EngineChoices[0];
ApplyEngine(selectedEngine);
send = new(_ => _ = SendAsync(), () => CanSend);
SendCommand = send;
newChat = new(_ => NewChat(), () => !busy);
@@ -66,14 +83,20 @@ class MainViewModel : ObservableObject
DeleteCommand = new RelayCommand<ChatItem>(c => { if (c is not null) _ = DeleteAsync(c); });
ToggleSidebarCommand = new RelayCommand<object>(_ => SidebarOpen = !SidebarOpen);
Messages.CollectionChanged += (_, _) => { Raise(nameof(HasMessages)); Raise(nameof(ShowEmpty)); };
ResetSession();
// all'avvio riapre la chat più recente
if (ChatStore.List().FirstOrDefault() is { } last) OpenChat(last.Id); else RefreshChats();
}
Engine Engine => selectedEngine.Engine;
public bool HasMessages => Messages.Count > 0;
public bool ShowEmpty => Messages.Count == 0;
public bool CanSend => !busy && !string.IsNullOrWhiteSpace(input);
public string Author => Engine.Author;
public string InputPlaceholder => $"Scrivi un messaggio a {Engine.Author}…";
public string BusyText => $"{Engine.Author} sta scrivendo…";
public string Input
{
@@ -84,10 +107,28 @@ class MainViewModel : ObservableObject
public bool IsBusy
{
get => busy;
private set { if (Set(ref busy, value)) { Raise(nameof(CanSend)); send.Refresh(); newChat.Refresh(); } }
private set { if (Set(ref busy, value)) { Raise(nameof(CanSend)); Raise(nameof(CanSwitchEngine)); send.Refresh(); newChat.Refresh(); } }
}
public bool SidebarOpen { get => sidebarOpen; set => Set(ref sidebarOpen, value); }
public bool CanSwitchEngine => !busy;
/// <summary>Interruttore CLI: cambiare motore apre una nuova chat (il contesto non passa da un motore all'altro).</summary>
public EngineChoice SelectedEngine
{
get => selectedEngine;
set
{
if (value is null || value == selectedEngine || busy || !value.Installed) return;
ApplyEngine(value);
NewChat();
}
}
public ModelOption[] ModelChoices { get => modelChoices; private set => Set(ref modelChoices, value); }
public string[] EffortChoices { get => effortChoices; private set => Set(ref effortChoices, value); }
public ModelOption SelectedModel
{
get => selectedModel;
@@ -97,37 +138,58 @@ class MainViewModel : ObservableObject
public string CustomModel { get => customModel; set => Set(ref customModel, value); }
public string SelectedEffort { get => selectedEffort; set => Set(ref selectedEffort, value); }
// Adotta un motore: liste di modelli ed effort sue, scelte riportate a "Predefinito".
void ApplyEngine(EngineChoice choice)
{
selectedEngine = choice;
ModelChoices = [Default, .. choice.Engine.Models, Custom];
EffortChoices = [DefaultEffort, .. choice.Engine.Efforts];
SelectedModel = Default; CustomModel = ""; SelectedEffort = DefaultEffort;
Raise(nameof(SelectedEngine)); Raise(nameof(Author)); Raise(nameof(InputPlaceholder)); Raise(nameof(BusyText));
}
void ResetSession()
{
chatId = Guid.NewGuid().ToString();
sessionId = Engine.OwnsSessionId ? null : chatId;
started = false;
history = [];
}
void RefreshChats()
{
Chats.Clear();
foreach (var c in ChatStore.List()) Chats.Add(new(c.Id, c.Title, c.Updated, c.Id == sessionId));
foreach (var c in ChatStore.List())
Chats.Add(new(c.Id, c.Title, c.Updated, c.Id == chatId, Engines.Find(c.EngineId)?.Name ?? c.EngineId));
}
void Show(List<Msg> msgs)
void Show(List<Msg> msgs, string author)
{
Messages.Clear();
foreach (var m in msgs) Messages.Add(new(m.Role, m.Text));
foreach (var m in msgs) Messages.Add(new(m.Role, m.Text, author));
}
void NewChat()
{
sessionId = Guid.NewGuid().ToString(); started = false; history = [];
Show(history); RefreshChats(); FocusInput?.Invoke();
ResetSession();
Show(history, Author); 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();
var choice = EngineChoices.FirstOrDefault(c => c.Engine.Id == chat.EngineId);
if (choice is not null && choice != selectedEngine) ApplyEngine(choice);
chatId = chat.Id; sessionId = chat.EffectiveSessionId; started = chat.Started; history = chat.Messages;
Show(history, Author); RefreshChats();
}
async Task DeleteAsync(ChatItem c)
{
if (busy || !await ConfirmDelete()) return;
ChatStore.Delete(c.Id);
if (c.Id == sessionId) NewChat(); else RefreshChats();
if (c.Id == chatId) NewChat(); else RefreshChats();
}
async Task SendAsync()
@@ -135,7 +197,7 @@ class MainViewModel : ObservableObject
if (!CanSend) return;
var text = input.Trim();
var model = IsCustomModel ? customModel.Trim() : selectedModel.Id;
var effort = selectedEffort == "Predefinito" ? "" : selectedEffort;
var effort = selectedEffort == DefaultEffort ? "" : selectedEffort;
Input = ""; IsBusy = true;
Add("user", text);
@@ -143,11 +205,16 @@ class MainViewModel : ObservableObject
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());
var r = await EngineRunner.RunCapturedAsync(Engine, text, sessionId, started, model, effort);
if (r.Error is null)
{
started = true;
if (r.SessionId is not null) sessionId = r.SessionId;
Add("ai", r.Text);
}
else Add("err", r.Error);
}
catch (System.ComponentModel.Win32Exception) { Add("err", "'claude' non trovato nel PATH."); }
catch (System.ComponentModel.Win32Exception) { Add("err", $"'{Engine.Executable}' non trovato nel PATH."); }
catch (Exception ex) { Add("err", ex.Message); }
finally
{
@@ -158,11 +225,11 @@ class MainViewModel : ObservableObject
}
}
void Add(string role, string text) { history.Add(new(role, text)); Messages.Add(new(role, text)); }
void Add(string role, string text) { history.Add(new(role, text)); Messages.Add(new(role, text, Author)); }
void Persist()
{
try { ChatStore.Save(sessionId, started, history); }
try { ChatStore.Save(chatId, Engine.Id, sessionId, started, history); }
catch (IOException) { } // la chat resta a schermo anche se il salvataggio fallisce
}
}
+16 -4
View File
@@ -93,7 +93,7 @@
<SelectableTextBlock Text="{Binding Text}" TextWrapping="Wrap" />
</Border>
<StackPanel IsVisible="{Binding IsAi}" Spacing="6">
<TextBlock Text="Claude" FontWeight="SemiBold" FontSize="13.5" Foreground="{DynamicResource Accent}" />
<TextBlock Text="{Binding Author}" FontWeight="SemiBold" FontSize="13.5" Foreground="{DynamicResource Accent}" />
<gui:MarkdownView Source="{Binding Text}" />
<Button Classes="ghost" HorizontalAlignment="Left" MinHeight="30" Padding="10,0" FontSize="13" Content="Copia"
Command="{Binding $parent[ItemsControl].((gui:MainViewModel)DataContext).CopyCommand}" CommandParameter="{Binding Text}"
@@ -108,7 +108,7 @@
<!-- Attesa -->
<StackPanel IsVisible="{Binding IsBusy}" Orientation="Horizontal" Spacing="12">
<ProgressBar IsIndeterminate="True" Width="80" Height="4" Foreground="{DynamicResource Accent}" VerticalAlignment="Center" />
<TextBlock Text="Claude sta scrivendo…" Foreground="{DynamicResource FgMuted}" />
<TextBlock Text="{Binding BusyText}" Foreground="{DynamicResource FgMuted}" />
</StackPanel>
</StackPanel>
</ScrollViewer>
@@ -125,7 +125,19 @@
<Flyout Placement="BottomEdgeAlignedRight">
<StackPanel Width="300" Spacing="6" Margin="4">
<TextBlock Text="Impostazioni" FontWeight="SemiBold" Margin="0,0,0,6" />
<TextBlock Text="Modello" FontSize="13" Foreground="{DynamicResource FgMuted}" />
<TextBlock Text="CLI" FontSize="13" Foreground="{DynamicResource FgMuted}" />
<ComboBox ItemsSource="{Binding EngineChoices}" SelectedItem="{Binding SelectedEngine}" IsEnabled="{Binding CanSwitchEngine}"
HorizontalAlignment="Stretch" AutomationProperties.Name="CLI (motore)">
<ComboBox.Styles>
<Style Selector="ComboBoxItem" x:DataType="gui:EngineChoice">
<Setter Property="IsEnabled" Value="{Binding Installed}" />
</Style>
</ComboBox.Styles>
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="gui:EngineChoice"><TextBlock Text="{Binding Label}" /></DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="Modello" FontSize="13" Foreground="{DynamicResource FgMuted}" Margin="0,8,0,0" />
<ComboBox ItemsSource="{Binding ModelChoices}" SelectedItem="{Binding SelectedModel}" HorizontalAlignment="Stretch"
AutomationProperties.Name="Modello">
<ComboBox.ItemTemplate>
@@ -151,7 +163,7 @@
<Border Classes="composer" Background="{DynamicResource Surface}" BorderBrush="{DynamicResource Border}" BorderThickness="1" CornerRadius="20" Padding="14,10">
<Grid ColumnDefinitions="*,Auto">
<TextBox x:Name="InputBox" Classes="bare" Text="{Binding Input, Mode=TwoWay}" AcceptsReturn="True" TextWrapping="Wrap" MaxHeight="200"
PlaceholderText="Scrivi un messaggio a Claude…" BorderThickness="0" Background="Transparent"
PlaceholderText="{Binding InputPlaceholder}" BorderThickness="0" Background="Transparent"
VerticalContentAlignment="Center" AutomationProperties.Name="Messaggio" />
<Button Grid.Column="1" Classes="send" VerticalAlignment="Bottom" Margin="8,0,0,0" Command="{Binding SendCommand}" AutomationProperties.Name="Invia messaggio">
<Viewbox Width="18" Height="18"><Path Data="M12 19V5M5 12L12 5L19 12" Stroke="{Binding $parent[Button].Foreground}" StrokeThickness="2.2" StrokeLineCap="Round" StrokeJoin="Round" /></Viewbox>