ClaudeDo never passed --effort, so every session inherited whatever effortLevel the user's Claude Code config happened to carry. Settings -> General now holds one row per model alias (haiku medium/20, sonnet high/30, opus high/40, fable high/25) supplying the global effort and turn defaults; list- and task-level max-turns overrides still win, and the agent editor's inherited badge follows the model. --effort is applied to autonomous runs and to every ConPTY spec (task session, planning start/resume, ad-hoc, list handler). The model itself is deliberately not forced on interactive sessions. The single global 'Max turns' field is replaced by the table, and 'fable' joins ModelRegistry.Aliases. The migration also adds the is_manual columns used by the next commit.
327 lines
13 KiB
C#
327 lines
13 KiB
C#
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 }
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="SaveAsync"/>; Task: debounced auto-save).
|
|
/// </summary>
|
|
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<ModelPreset> _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<string> ModelOptions { get; } = new(ModelRegistry.Aliases);
|
|
public ObservableCollection<AgentInfo> Agents { get; } = new();
|
|
public ObservableCollection<SelectableSkillViewModel> 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<string>? 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<string>? ParseSessionSkills(string? json)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json)) return null;
|
|
try { return JsonSerializer.Deserialize<List<string>>(json); }
|
|
catch (JsonException) { return null; }
|
|
}
|
|
|
|
private async System.Threading.Tasks.Task ReloadSessionSkillsAsync(IReadOnlyCollection<string>? selected)
|
|
{
|
|
var installed = await _worker.GetSessionSkillsAsync();
|
|
var selectedSet = selected is null ? new HashSet<string>() : new HashSet<string>(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;
|
|
}
|
|
}
|