Three usability-audit findings in Settings: - An unparseable PowerUse window (e.g. "9:0") used to save silently as null (= round the clock), the opposite of what the typo implied. Validate() now rejects an unparseable start/end unless blank; the worker-side read-time tolerance is unchanged. - "Effort" column header gets a tooltip explaining it's the model's reasoning effort (cost/depth tradeoff). - The "Prime Claude" tab gets a tooltip explaining scheduled Claude runs (Ping / Fill My Day / custom prompt). New locale keys added to en+de. Updated the PowerUse round-trip test that asserted the old silent-normalize behavior.
157 lines
7.4 KiB
C#
157 lines
7.4 KiB
C#
using System.Collections.ObjectModel;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Localization;
|
|
using ClaudeDo.Ui.Localization;
|
|
using ClaudeDo.Ui.Services;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
|
|
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
|
|
|
public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
|
|
{
|
|
private readonly ILocalizer? _localizer;
|
|
private readonly Action<string>? _persist;
|
|
|
|
[ObservableProperty] private string _defaultClaudeInstructions = "";
|
|
[ObservableProperty] private string _defaultModel = ModelRegistry.DefaultAlias;
|
|
[ObservableProperty] private int _defaultMaxTurns = 40;
|
|
// Hard ceiling every resolved max-turns value is clamped to before a run starts. Drives the
|
|
// clamp hints on the preset rows below and on AgentConfigEditorViewModel.
|
|
[ObservableProperty] private int _maxTurnsCeiling = 80;
|
|
[ObservableProperty] private string _defaultPermissionMode = PermissionModeRegistry.DefaultMode;
|
|
[ObservableProperty] private int _maxParallelExecutions = 1;
|
|
// Percentage of the 5h/7d Claude usage window at which the autonomous queue pauses. 0 = gate off.
|
|
[ObservableProperty] private int _usageGateFiveHourPct = 80;
|
|
[ObservableProperty] private int _usageGateSevenDayPct = 90;
|
|
// Off by default: auto-continues a usage-limit-failed task once the 5h window resets, and
|
|
// wakes a gate-blocked queue exactly at that reset time instead of only the 30s backstop.
|
|
[ObservableProperty] private bool _autoContinueOnUsageLimit;
|
|
// Cap on a list-handler run's wait<->merge handoff chain. 4 = two merge rounds (the long-standing
|
|
// default); raise it for runs meant to keep going for hours. Bounds a runaway chain, so never 0.
|
|
[ObservableProperty] private int _listHandlerMaxHandoffs = 4;
|
|
// PowerUse master switch: a handler run spends the rest of the window unattended. On its own it
|
|
// changes nothing — the run's LIST must also opt in (list settings → PowerUse).
|
|
[ObservableProperty] private bool _powerUseEnabled;
|
|
// Optional local-time window (HH:mm) narrowing when PowerUse applies; both blank = round the
|
|
// clock. Unparseable input is normalized to null on save, same as the worker does.
|
|
[ObservableProperty] private string? _powerUseWindowStart;
|
|
[ObservableProperty] private string? _powerUseWindowEnd;
|
|
// 5h-usage percentage at which a PowerUse handler session hands off. Clamped 50..99.
|
|
[ObservableProperty] private int _powerUseHandoffPct = 95;
|
|
// Newline-separated path prefixes excluded from the weekly report.
|
|
[ObservableProperty] private string _reportExcludedPaths = @"C:\Private";
|
|
// 0=Sunday..6=Saturday (System.DayOfWeek); default Wednesday.
|
|
[ObservableProperty] private int _standupWeekday = (int)DayOfWeek.Wednesday;
|
|
|
|
public IReadOnlyList<string> Models { get; } = ModelRegistry.Aliases;
|
|
public IReadOnlyList<string> PermissionModes { get; } = PermissionModeRegistry.Modes;
|
|
|
|
public ObservableCollection<AccentPresetSwatchViewModel> AccentPresetSwatches { get; } = new();
|
|
private Action<string>? _persistAccent;
|
|
|
|
public void InitAccentPresets(string saved, Action<string> persist)
|
|
{
|
|
_persistAccent = persist;
|
|
var current = AccentPresets.Find(saved);
|
|
AccentPresetSwatches.Clear();
|
|
foreach (var p in AccentPresets.All)
|
|
AccentPresetSwatches.Add(new AccentPresetSwatchViewModel(p, p.Name == current.Name));
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void SelectAccentPreset(AccentPreset preset)
|
|
{
|
|
foreach (var s in AccentPresetSwatches)
|
|
s.IsSelected = s.Preset.Name == preset.Name;
|
|
AccentPresetService.Apply(preset);
|
|
_persistAccent?.Invoke(preset.Name);
|
|
}
|
|
|
|
/// <summary>One editable row per model alias: the effort and turn budget a run gets under that
|
|
/// model. Supplies the global defaults; list- and task-level max-turns overrides still win.</summary>
|
|
public ObservableCollection<ModelPresetRowViewModel> ModelPresets { get; } = new();
|
|
|
|
public void LoadModelPresets(IReadOnlyCollection<ModelPresetDto>? presets, int ceiling)
|
|
{
|
|
ModelPresets.Clear();
|
|
var source = presets is { Count: > 0 }
|
|
? presets.Select(p => new ModelPreset(p.Model, p.Effort, p.MaxTurns)).ToList()
|
|
: Data.Models.ModelPresets.Defaults.ToList();
|
|
foreach (var p in Data.Models.ModelPresets.Parse(Data.Models.ModelPresets.Serialize(source)))
|
|
ModelPresets.Add(new ModelPresetRowViewModel(p, ceiling));
|
|
}
|
|
|
|
public List<ModelPresetDto> ModelPresetDtos()
|
|
=> ModelPresets.Select(r => new ModelPresetDto(r.Model, r.Effort, (int)r.MaxTurns)).ToList();
|
|
|
|
public GeneralSettingsTabViewModel() { }
|
|
|
|
public GeneralSettingsTabViewModel(ILocalizer localizer, Action<string> persist)
|
|
{
|
|
_localizer = localizer;
|
|
_persist = persist;
|
|
Languages = localizer.AvailableLanguages;
|
|
_selectedLanguage = Languages.FirstOrDefault(l => l.Code == localizer.CurrentCode);
|
|
}
|
|
|
|
public IReadOnlyList<LanguageOption> Languages { get; } = Array.Empty<LanguageOption>();
|
|
|
|
[ObservableProperty] private LanguageOption? _selectedLanguage;
|
|
|
|
partial void OnSelectedLanguageChanged(LanguageOption? value)
|
|
{
|
|
if (value is null || _localizer is null) return;
|
|
_localizer.SetLanguage(value.Value.Code);
|
|
_persist?.Invoke(value.Value.Code);
|
|
}
|
|
|
|
public string? Validate()
|
|
{
|
|
if (MaxTurnsCeiling < 1)
|
|
return Loc.T("settings.general.maxTurnsCeilingInvalid");
|
|
if (MaxParallelExecutions < 1 || MaxParallelExecutions > 20)
|
|
return "Max parallel executions must be between 1 and 20.";
|
|
if (UsageGateFiveHourPct < 0 || UsageGateFiveHourPct > 100)
|
|
return "Usage gate (5h) must be between 0 and 100.";
|
|
if (UsageGateSevenDayPct < 0 || UsageGateSevenDayPct > 100)
|
|
return "Usage gate (7d) must be between 0 and 100.";
|
|
foreach (var row in ModelPresets)
|
|
if (row.MaxTurns is < 1 or > 200)
|
|
return $"Max turns for {row.Model} must be between 1 and 200.";
|
|
if (!IsValidWindowBound(PowerUseWindowStart) || !IsValidWindowBound(PowerUseWindowEnd))
|
|
return Loc.T("settings.general.powerUseWindowInvalid");
|
|
return null;
|
|
}
|
|
|
|
private static bool IsValidWindowBound(string? value)
|
|
=> string.IsNullOrWhiteSpace(value) || PowerUseWindow.Parse(value) is not null;
|
|
}
|
|
|
|
/// <summary>One row of the per-model preset table. Single consumer, so it lives here.</summary>
|
|
public sealed partial class ModelPresetRowViewModel : ViewModelBase
|
|
{
|
|
public string Model { get; }
|
|
public IReadOnlyList<string> EffortLevels { get; } = EffortRegistry.Levels;
|
|
|
|
[ObservableProperty] private string _effort;
|
|
// decimal so it binds straight to a NumericUpDown, like the other numeric settings.
|
|
[ObservableProperty] private decimal _maxTurns;
|
|
[ObservableProperty] private int _ceiling;
|
|
|
|
public ModelPresetRowViewModel(ModelPreset preset, int ceiling)
|
|
{
|
|
Model = preset.Model;
|
|
_effort = preset.Effort;
|
|
_maxTurns = preset.MaxTurns;
|
|
_ceiling = ceiling;
|
|
}
|
|
|
|
public string ClampHint => MaxTurns > Ceiling
|
|
? Loc.T("settings.turnsCeilingHint", Ceiling)
|
|
: "";
|
|
|
|
partial void OnMaxTurnsChanged(decimal value) => OnPropertyChanged(nameof(ClampHint));
|
|
partial void OnCeilingChanged(int value) => OnPropertyChanged(nameof(ClampHint));
|
|
}
|