using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; using System.Text.Json; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ClaudeDo.Data.Models; using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; namespace ClaudeDo.Ui.ViewModels.Agent; public enum AgentConfigScope { List, Task } /// /// One agent-config editor (Model / MaxTurns / SystemPrompt / AgentFile with inherited /// badges + reset) shared by the List Settings modal and the per-task gear flyout. /// Scope picks the inheritance depth (List: list→global; Task: task→list→global) and the /// persistence (List: explicit ; Task: debounced auto-save). /// public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposable { private readonly IWorkerClient _worker; private readonly AgentConfigScope _scope; private readonly EventHandler _langChangedHandler; /// scope==List ⇒ the list id; scope==Task ⇒ the task id. Null ⇒ no save target. internal string? TargetId { get; set; } [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsEnabled))] private bool _isRunning; // Task scope gates the editor while the run is live; List scope is always enabled. public bool IsEnabled => !IsRunning; [ObservableProperty] private string? _model; [ObservableProperty] private decimal? _maxTurns; [ObservableProperty] private string _systemPrompt = ""; [ObservableProperty] private AgentInfo? _selectedAgent; [ObservableProperty] private string _modelBadge = ""; [ObservableProperty] private string _modelInheritedHint = ""; [ObservableProperty] private string _turnsBadge = ""; [ObservableProperty] private string _turnsInheritedHint = ""; [ObservableProperty] private string _agentBadge = ""; [ObservableProperty] private string _effectiveSystemPromptHint = ""; private string _globalModel = ModelRegistry.DefaultAlias; // The global max-turns default is per-model (Settings -> General), so it moves with whichever // model actually ends up in effect here. private IReadOnlyList _presets = ModelPresets.Defaults; private string EffectiveModel => Model ?? _listModel ?? _globalModel; private int GlobalMaxTurns => ModelPresets.For(_presets, EffectiveModel).MaxTurns; private string? _listModel; // Task scope only private int? _listMaxTurns; // Task scope only private string? _listAgentName; // Task scope only private bool _suppressSave; private CancellationTokenSource? _saveCts; public int EffectiveMaxTurns => MaxTurns is decimal t ? (int)t : (_listMaxTurns ?? GlobalMaxTurns); public ObservableCollection ModelOptions { get; } = new(ModelRegistry.Aliases); public ObservableCollection Agents { get; } = new(); public ObservableCollection SessionSkills { get; } = new(); public AgentConfigEditorViewModel(IWorkerClient worker, AgentConfigScope scope) { _worker = worker; _scope = scope; _langChangedHandler = (_, _) => RecomputeBadges(); // Only the long-lived Task editor needs live re-badging; the List editor is a // short-lived modal recreated with the current language on each open. if (scope == AgentConfigScope.Task) Loc.LanguageChanged += _langChangedHandler; SessionSkills.CollectionChanged += OnSessionSkillsCollectionChanged; } private void OnSessionSkillsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { if (e.NewItems is not null) foreach (SelectableSkillViewModel item in e.NewItems) item.PropertyChanged += OnSkillItemPropertyChanged; if (e.OldItems is not null) foreach (SelectableSkillViewModel item in e.OldItems) item.PropertyChanged -= OnSkillItemPropertyChanged; } private void OnSkillItemPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(SelectableSkillViewModel.IsSelected)) QueueSave(); } public void Dispose() { Loc.LanguageChanged -= _langChangedHandler; SessionSkills.CollectionChanged -= OnSessionSkillsCollectionChanged; foreach (var s in SessionSkills) s.PropertyChanged -= OnSkillItemPropertyChanged; } partial void OnModelChanged(string? value) { RecomputeModelBadge(); // A different model means a different global turn default. RecomputeTurnsBadge(); OnPropertyChanged(nameof(EffectiveMaxTurns)); QueueSave(); } partial void OnMaxTurnsChanged(decimal? value) { RecomputeTurnsBadge(); OnPropertyChanged(nameof(EffectiveMaxTurns)); QueueSave(); } partial void OnSystemPromptChanged(string value) => QueueSave(); partial void OnSelectedAgentChanged(AgentInfo? value) { RecomputeAgentBadge(); QueueSave(); } private void RecomputeBadges() { RecomputeModelBadge(); RecomputeTurnsBadge(); RecomputeAgentBadge(); } private void RecomputeModelBadge() { var own = string.IsNullOrWhiteSpace(Model) ? null : Model; var (value, source) = _scope == AgentConfigScope.Task ? InheritanceResolver.Resolve(own, _listModel, _globalModel) : InheritanceResolver.ResolveList(own, _globalModel); ModelInheritedHint = value; ModelBadge = BadgeFor(source, own is not null); } private void RecomputeTurnsBadge() { var own = MaxTurns?.ToString(); var (value, source) = _scope == AgentConfigScope.Task ? InheritanceResolver.Resolve(own, _listMaxTurns?.ToString(), GlobalMaxTurns.ToString()) : InheritanceResolver.ResolveList(own, GlobalMaxTurns.ToString()); TurnsInheritedHint = value; TurnsBadge = BadgeFor(source, MaxTurns is not null); } private void RecomputeAgentBadge() { var agentSet = SelectedAgent is not null && !string.IsNullOrWhiteSpace(SelectedAgent.Path); var own = agentSet ? SelectedAgent!.Path : null; var (_, source) = _scope == AgentConfigScope.Task ? InheritanceResolver.Resolve(own, _listAgentName, null) : InheritanceResolver.ResolveList(own, null); AgentBadge = BadgeFor(source, agentSet); } private static string BadgeFor(InheritSource source, bool isSet) => isSet ? Loc.T("settings.inherit.overrideBadge") : source == InheritSource.List ? Loc.T("settings.inherit.inheritedFromList") : Loc.T("settings.inherit.inheritedFromGlobal"); private void QueueSave() { // List scope persists on the modal Save button; only Task auto-saves. if (_suppressSave || _scope != AgentConfigScope.Task || TargetId is null) return; _saveCts?.Cancel(); _saveCts = new CancellationTokenSource(); _ = DebouncedSaveAsync(_saveCts.Token); } private async System.Threading.Tasks.Task DebouncedSaveAsync(CancellationToken ct) { try { await System.Threading.Tasks.Task.Delay(300, ct); if (TargetId is null) return; await SaveAsync(); } catch (OperationCanceledException) { } catch { } } public async System.Threading.Tasks.Task SaveAsync() { if (TargetId is null) return; var model = string.IsNullOrWhiteSpace(Model) ? null : Model; var sp = string.IsNullOrWhiteSpace(SystemPrompt) ? null : SystemPrompt; var ap = SelectedAgent is null || string.IsNullOrWhiteSpace(SelectedAgent.Path) ? null : SelectedAgent.Path; var turns = MaxTurns is decimal d ? (int?)d : null; var skills = SelectedSessionSkillNames(); if (_scope == AgentConfigScope.Task) await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills)); else await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills)); } private List? SelectedSessionSkillNames() { var names = SessionSkills.Where(s => s.IsSelected).Select(s => s.Name).ToList(); return names.Count == 0 ? null : names; } public async System.Threading.Tasks.Task LoadForListAsync(string listId, CancellationToken ct = default) { _suppressSave = true; try { TargetId = listId; await ReloadAgentsAsync("(none)"); await LoadGlobalDefaultsAsync(); var cfg = await _worker.GetListConfigAsync(listId); ApplyConfig(cfg?.Model, cfg?.MaxTurns, cfg?.SystemPrompt, cfg?.AgentPath); await ReloadSessionSkillsAsync(cfg?.SessionSkills); _listModel = null; _listMaxTurns = null; _listAgentName = null; EffectiveSystemPromptHint = ""; RecomputeBadges(); OnPropertyChanged(nameof(EffectiveMaxTurns)); } finally { _suppressSave = false; } } public async System.Threading.Tasks.Task LoadForTaskAsync(TaskEntity entity, CancellationToken ct = default) { _suppressSave = true; try { TargetId = entity.Id; await ReloadAgentsAsync("(inherited)"); ApplyConfig(entity.Model, entity.MaxTurns, entity.SystemPrompt, entity.AgentPath); await ReloadSessionSkillsAsync(ParseSessionSkills(entity.SessionSkills)); var listCfg = await _worker.GetListConfigAsync(entity.ListId); await LoadGlobalDefaultsAsync(); _listModel = listCfg?.Model; _listMaxTurns = listCfg?.MaxTurns; _listAgentName = string.IsNullOrWhiteSpace(listCfg?.AgentPath) ? null : System.IO.Path.GetFileName(listCfg!.AgentPath!); EffectiveSystemPromptHint = string.IsNullOrWhiteSpace(listCfg?.SystemPrompt) ? "" : listCfg!.SystemPrompt!; RecomputeBadges(); OnPropertyChanged(nameof(EffectiveMaxTurns)); } finally { _suppressSave = false; } } public void Clear() { _suppressSave = true; try { Model = null; MaxTurns = null; SystemPrompt = ""; SelectedAgent = null; foreach (var s in SessionSkills) s.IsSelected = false; } finally { _suppressSave = false; } EffectiveSystemPromptHint = ""; TargetId = null; } private static List? ParseSessionSkills(string? json) { if (string.IsNullOrWhiteSpace(json)) return null; try { return JsonSerializer.Deserialize>(json); } catch (JsonException) { return null; } } private async System.Threading.Tasks.Task ReloadSessionSkillsAsync(IReadOnlyCollection? selected) { var installed = await _worker.GetSessionSkillsAsync(); var selectedSet = selected is null ? new HashSet() : new HashSet(selected); foreach (var s in SessionSkills) s.PropertyChanged -= OnSkillItemPropertyChanged; SessionSkills.Clear(); foreach (var s in installed) SessionSkills.Add(new SelectableSkillViewModel(s.Name, s.Description, selectedSet.Contains(s.Name))); } private async System.Threading.Tasks.Task ReloadAgentsAsync(string placeholderName) { Agents.Clear(); Agents.Add(new AgentInfo(placeholderName, "", "")); foreach (var a in await _worker.GetAgentsAsync()) Agents.Add(a); } private async System.Threading.Tasks.Task LoadGlobalDefaultsAsync() { var app = await _worker.GetAppSettingsAsync(); _globalModel = app?.DefaultModel ?? ModelRegistry.DefaultAlias; _presets = app?.ModelPresets is { Count: > 0 } rows ? rows.Select(r => new ModelPreset(r.Model, r.Effort, r.MaxTurns)).ToList() : ModelPresets.Defaults; } private void ApplyConfig(string? model, int? maxTurns, string? systemPrompt, string? agentPath) { Model = string.IsNullOrWhiteSpace(model) ? null : model!; MaxTurns = maxTurns is int mt ? mt : (decimal?)null; SystemPrompt = systemPrompt ?? ""; SelectedAgent = string.IsNullOrWhiteSpace(agentPath) ? Agents[0] : (Agents.FirstOrDefault(a => a.Path == agentPath) ?? Agents[0]); } [RelayCommand] private void ResetModel() => Model = null; [RelayCommand] private void ResetTurns() => MaxTurns = null; [RelayCommand] private void ResetAgent() => SelectedAgent = Agents.Count > 0 ? Agents[0] : null; [RelayCommand] private void ResetAll() { Model = null; MaxTurns = null; SystemPrompt = ""; SelectedAgent = Agents.Count > 0 ? Agents[0] : null; foreach (var s in SessionSkills) s.IsSelected = false; } }