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 { }
}
// verifyCommand is a List-only field owned by ListSettingsModalViewModel (not this editor,
// which is also reused for Task scope); the caller passes it through so the single
// UpdateListConfig call carries the full desired row instead of clobbering it.
public async System.Threading.Tasks.Task SaveAsync(string? verifyCommand = null)
{
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, verifyCommand));
}
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;
}
}