diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index e066f820..eed661a8 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -21,6 +21,10 @@ "permission": "Berechtigung", "maxParallelExecutions": "Max. parallele Ausführungen", "maxParallelExecutionsHint": "Wie viele Aufgaben aus der Warteschlange der Worker gleichzeitig ausführt.", + "usageGateHeading": "Usage-Limit-Stopp", + "usageGateHint": "Ab dieser Auslastung holt die Queue keine neuen Tasks mehr; laufende Tasks laufen zu Ende. 0 schaltet den Stopp ab.", + "usageGateFiveHourPct": "5-Stunden-Fenster (%)", + "usageGateSevenDayPct": "7-Tage-Fenster (%)", "reportExcludedPaths": "Bericht: ausgeschlossene Pfade (einer pro Zeile)", "standupWeekday": "Standup-Wochentag", "weekdaySunday": "Sonntag", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index f0efd074..b1feeccf 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -21,6 +21,10 @@ "permission": "Permission", "maxParallelExecutions": "Max parallel executions", "maxParallelExecutionsHint": "How many queued tasks the worker runs at once.", + "usageGateHeading": "Usage limit stop", + "usageGateHint": "Once usage reaches this level, the queue stops picking up new tasks; running tasks finish normally. 0 turns the stop off.", + "usageGateFiveHourPct": "5-hour window (%)", + "usageGateSevenDayPct": "7-day window (%)", "reportExcludedPaths": "Report: excluded paths (one per line)", "standupWeekday": "Standup weekday", "weekdaySunday": "Sunday", diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 2e5e8095..9258cb6f 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -623,7 +623,9 @@ public sealed record AppSettingsDto( int StandupWeekday, int DailyPrepMaxTasks, List? SessionSkills = null, - List? ModelPresets = null); + List? ModelPresets = null, + int UsageGateFiveHourPct = 80, + int UsageGateSevenDayPct = 90); // Per-model run defaults (effort + turn budget) edited in Settings → General. public sealed record ModelPresetDto(string Model, string Effort, int MaxTurns); diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs index a95c7b76..4f3ab8fa 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs @@ -18,6 +18,9 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase [ObservableProperty] private int _defaultMaxTurns = 100; [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; // Newline-separated path prefixes excluded from the weekly report. [ObservableProperty] private string _reportExcludedPaths = @"C:\Private"; // 0=Sunday..6=Saturday (System.DayOfWeek); default Wednesday. @@ -91,6 +94,10 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase { 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."; diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs index d6fc071c..f6340bc1 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs @@ -61,6 +61,8 @@ public sealed partial class SettingsModalViewModel : ViewModelBase General.DefaultMaxTurns = dto.DefaultMaxTurns; General.DefaultPermissionMode = dto.DefaultPermissionMode ?? "auto"; General.MaxParallelExecutions = dto.MaxParallelExecutions; + General.UsageGateFiveHourPct = dto.UsageGateFiveHourPct; + General.UsageGateSevenDayPct = dto.UsageGateSevenDayPct; Worktrees.WorktreeStrategy = dto.WorktreeStrategy ?? "sibling"; Worktrees.CentralWorktreeRoot = dto.CentralWorktreeRoot; Worktrees.WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled; @@ -109,7 +111,9 @@ public sealed partial class SettingsModalViewModel : ViewModelBase General.StandupWeekday, Prime.DailyPrepMaxTasks, General.SelectedSessionSkillNames(), - General.ModelPresetDtos()); + General.ModelPresetDtos(), + General.UsageGateFiveHourPct, + General.UsageGateSevenDayPct); await _worker.UpdateAppSettingsAsync(dto); await Prime.SaveAsync(); await OnlineInbox.SaveAsync(); diff --git a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml index 2c2e3c3d..08a2e9fe 100644 --- a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml @@ -149,6 +149,25 @@ + + + + + + + + + + + + + + diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 9c6bf219..59ede361 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -43,7 +43,9 @@ public record AppSettingsDto( int StandupWeekday, int DailyPrepMaxTasks, List? SessionSkills = null, - List? ModelPresets = null); + List? ModelPresets = null, + int UsageGateFiveHourPct = 80, + int UsageGateSevenDayPct = 90); // Per-model run defaults (effort + turn budget) edited in Settings -> General. public record ModelPresetDto(string Model, string Effort, int MaxTurns); @@ -378,7 +380,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub row.DailyPrepMaxTasks, SkillsFromJson(row.SessionSkills), Data.Models.ModelPresets.Parse(row.ModelPresets) - .Select(p => new ModelPresetDto(p.Model, p.Effort, p.MaxTurns)).ToList()); + .Select(p => new ModelPresetDto(p.Model, p.Effort, p.MaxTurns)).ToList(), + row.UsageGateFiveHourPct, + row.UsageGateSevenDayPct); } public async Task UpdateAppSettings(AppSettingsDto dto) @@ -406,6 +410,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub ? Data.Models.ModelPresets.Serialize( dto.ModelPresets.Select(p => new ModelPreset(p.Model, p.Effort, p.MaxTurns))) : Data.Models.ModelPresets.SerializeDefaults(), + UsageGateFiveHourPct = dto.UsageGateFiveHourPct, + UsageGateSevenDayPct = dto.UsageGateSevenDayPct, }); } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/GeneralSettingsTabViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/GeneralSettingsTabViewModelTests.cs index e897a0c6..e3cd9708 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/GeneralSettingsTabViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/GeneralSettingsTabViewModelTests.cs @@ -45,4 +45,30 @@ public class GeneralSettingsTabViewModelTests Assert.Equal(new List { "a" }, vm.SelectedSessionSkillNames()); } + + [Theory] + [InlineData(-1, false)] + [InlineData(0, true)] + [InlineData(80, true)] + [InlineData(100, true)] + [InlineData(101, false)] + public void Validate_UsageGateFiveHourPct_bounds(int value, bool expectedValid) + { + var vm = new GeneralSettingsTabViewModel { UsageGateFiveHourPct = value }; + + Assert.Equal(expectedValid, vm.Validate() is null); + } + + [Theory] + [InlineData(-1, false)] + [InlineData(0, true)] + [InlineData(80, true)] + [InlineData(100, true)] + [InlineData(101, false)] + public void Validate_UsageGateSevenDayPct_bounds(int value, bool expectedValid) + { + var vm = new GeneralSettingsTabViewModel { UsageGateSevenDayPct = value }; + + Assert.Equal(expectedValid, vm.Validate() is null); + } } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/SettingsModalViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/SettingsModalViewModelTests.cs new file mode 100644 index 00000000..de8ba656 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/SettingsModalViewModelTests.cs @@ -0,0 +1,76 @@ +using ClaudeDo.Localization; +using ClaudeDo.Ui; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.Modals; +using ClaudeDo.Ui.ViewModels.Modals.Settings; +using Xunit; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +public class SettingsModalViewModelTests +{ + private sealed class FakeWorker : StubWorkerClient + { + public AppSettingsDto? AppToReturn; + public AppSettingsDto? Saved; + public override Task GetAppSettingsAsync() => Task.FromResult(AppToReturn); + public override Task UpdateAppSettingsAsync(AppSettingsDto dto) { Saved = dto; return Task.CompletedTask; } + } + + private sealed class FakePrimeApi : IPrimeScheduleApi + { + public Task> ListAsync() => Task.FromResult(new List()); + public Task UpsertAsync(PrimeScheduleDto dto) => Task.FromResult(dto); + public Task DeleteAsync(Guid id) => Task.CompletedTask; + } + + private static Localizer MakeLocalizer() + { + var dir = Path.Combine(Path.GetTempPath(), "loc_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "en.json"), + """{ "metadata": { "code": "en", "name": "English" }, "settings": { "save": "Save" } }"""); + File.WriteAllText(Path.Combine(dir, "de.json"), + """{ "metadata": { "code": "de", "name": "Deutsch" }, "settings": { "save": "Speichern" } }"""); + return new Localizer(LocaleStore.Load(dir), "en"); + } + + private static AppSettingsDto DtoWith(int fiveHourPct, int sevenDayPct) => + new(DefaultClaudeInstructions: "", DefaultModel: "sonnet", DefaultMaxTurns: 30, + DefaultPermissionMode: "auto", MaxParallelExecutions: 1, WorktreeStrategy: "sibling", + CentralWorktreeRoot: null, WorktreeAutoCleanupEnabled: false, WorktreeAutoCleanupDays: 7, + ReportExcludedPaths: null, StandupWeekday: 3, DailyPrepMaxTasks: 5, + SessionSkills: null, ModelPresets: null, + UsageGateFiveHourPct: fiveHourPct, UsageGateSevenDayPct: sevenDayPct); + + private static SettingsModalViewModel MakeVm(FakeWorker worker) => + new(worker, new PrimeClaudeTabViewModel(new FakePrimeApi()), new OnlineLoginService(), + MakeLocalizer(), new AppSettings()); + + [Fact] + public async Task LoadAsync_fills_usage_gate_fields_from_dto() + { + var worker = new FakeWorker { AppToReturn = DtoWith(65, 95) }; + var vm = MakeVm(worker); + + await vm.LoadAsync(); + + Assert.Equal(65, vm.General.UsageGateFiveHourPct); + Assert.Equal(95, vm.General.UsageGateSevenDayPct); + } + + [Fact] + public async Task Save_sends_usage_gate_fields_in_dto() + { + var worker = new FakeWorker(); + var vm = MakeVm(worker); + vm.General.UsageGateFiveHourPct = 42; + vm.General.UsageGateSevenDayPct = 77; + + await vm.SaveCommand.ExecuteAsync(null); + + Assert.NotNull(worker.Saved); + Assert.Equal(42, worker.Saved!.UsageGateFiveHourPct); + Assert.Equal(77, worker.Saved.UsageGateSevenDayPct); + } +}