feat(ui): PowerUse-Toggle im Usage-Monitor, Opt-in pro Liste

Der Toggle sitzt unter den Gauges im Usage-Monitor, weil "das restliche Fenster
ausnutzen" eine Entscheidung ist, die man beim Blick auf das restliche Fenster
trifft. Er speichert sofort per Read-modify-write, gleiche Disziplin wie
SaveStagesAsync, damit er keine Felder ueberschreibt, die er nicht besitzt.

Zwei Stellen, die das Opt-in still ausgeschaltet haetten:
- WorkerHub.UpdateListConfig loescht die Config-Zeile, wenn alle Felder leer
  sind. PowerUseAllowed muss in diese Leer-Pruefung, sonst verschwindet das
  Opt-in sofort wieder, wenn es das einzige gesetzte Feld ist.
- SettingsModalViewModel baut das AppSettingsDto positional neu auf. Ohne
  Round-trip haette jedes Speichern der Settings PowerUseEnabled auf den
  Record-Default false zurueckgesetzt. Gleiches Muster wie _throttleStages.

Das Handoff-Budget ist als Spinner in Settings -> General editierbar, weil es
auch fuer normale (interaktive) Handler-Runs gilt und nicht an PowerUse haengt.
Das Addendum selbst ist ueber Settings -> Files anpassbar.
This commit is contained in:
Mika Kuns
2026-09-02 15:44:13 +02:00
parent 453d61ca58
commit 193752540b
11 changed files with 132 additions and 8 deletions
@@ -41,6 +41,8 @@
"usageGateSevenDayPct": "7-Tage-Fenster (%)", "usageGateSevenDayPct": "7-Tage-Fenster (%)",
"autoContinueOnUsageLimit": "Nach Limit-Reset fortsetzen", "autoContinueOnUsageLimit": "Nach Limit-Reset fortsetzen",
"autoContinueOnUsageLimitHint": "Ist ein Run am Nutzungslimit abgebrochen, wird er nach Ablauf des 5-Stunden-Fensters automatisch fortgesetzt. Weckt außerdem die pausierte Queue genau zum Reset-Zeitpunkt statt erst beim nächsten Backstop-Check.", "autoContinueOnUsageLimitHint": "Ist ein Run am Nutzungslimit abgebrochen, wird er nach Ablauf des 5-Stunden-Fensters automatisch fortgesetzt. Weckt außerdem die pausierte Queue genau zum Reset-Zeitpunkt statt erst beim nächsten Backstop-Check.",
"listHandlerMaxHandoffs": "Handoff-Budget des List-Handlers",
"listHandlerMaxHandoffsHint": "Wie viele Wait/Merge-Handoffs ein „Let Claude handle it\"-Run verketten darf. 4 entspricht zwei Merge-Runden. Für Läufe, die stundenlang durchlaufen sollen, höher setzen; der Wert begrenzt eine entlaufene Kette und darf daher nicht 0 sein.",
"throttleStagesHeading": "Throttle-Stufen", "throttleStagesHeading": "Throttle-Stufen",
"throttleStagesHint": "Die Parallelität wird vor dem harten Gate begrenzt: soft auf 2 Slots, hard auf 1. Eingestellt wird das durch Ziehen der Marker an den Gauges im Usage-Monitor. Der Monitor schließt diese Einstellungen — vorher speichern, wenn du etwas geändert hast.", "throttleStagesHint": "Die Parallelität wird vor dem harten Gate begrenzt: soft auf 2 Slots, hard auf 1. Eingestellt wird das durch Ziehen der Marker an den Gauges im Usage-Monitor. Der Monitor schließt diese Einstellungen — vorher speichern, wenn du etwas geändert hast.",
"throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%", "throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%",
@@ -85,6 +87,7 @@
"retryPrompt": "Wiederholung", "retryPrompt": "Wiederholung",
"dailyPrepPrompt": "Tagesplanung", "dailyPrepPrompt": "Tagesplanung",
"weeklyReportPrompt": "Wochenbericht", "weeklyReportPrompt": "Wochenbericht",
"powerUsePrompt": "PowerUse-Addendum",
"viewPrompt": "Ansehen", "viewPrompt": "Ansehen",
"customizedSection": "ANGEPASST", "customizedSection": "ANGEPASST",
"customizedHint": "Diese Prompts weichen vom mitgelieferten Standard ab und werden unverändert beibehalten. Zurücksetzen, um künftige Standard-Verbesserungen zu erhalten.", "customizedHint": "Diese Prompts weichen vom mitgelieferten Standard ab und werden unverändert beibehalten. Zurücksetzen, um künftige Standard-Verbesserungen zu erhalten.",
@@ -398,6 +401,8 @@
"manualListHint": "Neue Aufgaben in dieser Liste sind zunächst manuell: kein Einreihen, Ausführen oder Verfeinern, und die Automatik überspringt sie. Eine handgesteuerte Sitzung kannst du weiterhin öffnen.", "manualListHint": "Neue Aufgaben in dieser Liste sind zunächst manuell: kein Einreihen, Ausführen oder Verfeinern, und die Automatik überspringt sie. Eine handgesteuerte Sitzung kannst du weiterhin öffnen.",
"findingsTracked": "Findings-Ordner .claudedo einchecken", "findingsTracked": "Findings-Ordner .claudedo einchecken",
"findingsTrackedHint": "Aus: der Ordner bleibt über .git/info/exclude aus git heraus. An: Findings reisen mit dem Repo.", "findingsTrackedHint": "Aus: der Ordner bleibt über .git/info/exclude aus git heraus. An: Findings reisen mit dem Repo.",
"powerUseAllowed": "PowerUse für diese Liste erlauben",
"powerUseAllowedHint": "Erlaubt einem List-Handler-Run auf dieser Liste, unbeaufsichtigt zu laufen, sobald PowerUse im Usage-Monitor eingeschaltet ist: er fragt nichts, löst Konflikte selbst und sammelt neue Arbeit. Beide Schalter sind nötig — ein global aktives PowerUse ändert an einer Liste ohne Opt-in also nichts.",
"sectionIcon": "ICON", "sectionIcon": "ICON",
"icon": "Listen-Icon", "icon": "Listen-Icon",
"iconHint": "Wird in der Seitenleiste neben dem Listennamen angezeigt.", "iconHint": "Wird in der Seitenleiste neben dem Listennamen angezeigt.",
@@ -504,6 +509,8 @@
"title": "USAGE MONITOR", "title": "USAGE MONITOR",
"windowTitle": "Usage Monitor", "windowTitle": "Usage Monitor",
"noGauges": "Noch keine Nutzungslimits gemeldet.", "noGauges": "Noch keine Nutzungslimits gemeldet.",
"powerUse": "PowerUse",
"powerUseHint": "Das restliche Fenster ausnutzen: ein List-Handler-Run fragt nichts, löst Konflikte selbst, sammelt neue Arbeit und handoffet weiter, solange sein Budget reicht. Wirkt nur auf Listen, die PowerUse in ihren Listen-Einstellungen erlaubt haben.",
"refresh": "Jetzt aktualisieren", "refresh": "Jetzt aktualisieren",
"refreshHint": "Abruf alle 5 Min., während ein Task läuft, sonst alle 15 Min.", "refreshHint": "Abruf alle 5 Min., während ein Task läuft, sonst alle 15 Min.",
"staleFormat": "Werte veraltet (Stand {0})", "staleFormat": "Werte veraltet (Stand {0})",
@@ -749,6 +756,7 @@
"loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}", "loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}",
"refreshFailed": "Nutzung konnte nicht aktualisiert werden: {0}", "refreshFailed": "Nutzung konnte nicht aktualisiert werden: {0}",
"thresholdSaveFailed": "Grenze konnte nicht gespeichert werden: {0}", "thresholdSaveFailed": "Grenze konnte nicht gespeichert werden: {0}",
"powerUseSaveFailed": "PowerUse-Schalter konnte nicht gespeichert werden: {0}",
"installStarted": "TokenTracker wird installiert — der Fortschritt läuft in der Log-Leiste.", "installStarted": "TokenTracker wird installiert — der Fortschritt läuft in der Log-Leiste.",
"installFailed": "TokenTracker konnte nicht installiert werden: {0}", "installFailed": "TokenTracker konnte nicht installiert werden: {0}",
"analyticsRefreshFailed": "Die Auswertung konnte nicht aktualisiert werden: {0}", "analyticsRefreshFailed": "Die Auswertung konnte nicht aktualisiert werden: {0}",
@@ -41,6 +41,8 @@
"usageGateSevenDayPct": "7-day window (%)", "usageGateSevenDayPct": "7-day window (%)",
"autoContinueOnUsageLimit": "Continue on session limit reset", "autoContinueOnUsageLimit": "Continue on session limit reset",
"autoContinueOnUsageLimitHint": "When a run stops because it hit the usage limit, automatically continue it once the 5-hour window resets. Also wakes the paused queue exactly at reset time instead of waiting for the next backstop check.", "autoContinueOnUsageLimitHint": "When a run stops because it hit the usage limit, automatically continue it once the 5-hour window resets. Also wakes the paused queue exactly at reset time instead of waiting for the next backstop check.",
"listHandlerMaxHandoffs": "List-handler handoff budget",
"listHandlerMaxHandoffsHint": "How many wait/merge handoffs one \"Let Claude handle it\" run may chain. 4 means two merge rounds. Raise it for runs meant to keep going for hours; it bounds a runaway chain, so it cannot be 0.",
"throttleStagesHeading": "Throttle stages", "throttleStagesHeading": "Throttle stages",
"throttleStagesHint": "Parallelism is capped before the hard gate: soft caps at 2 slots, hard at 1. Edited by dragging the markers on the usage-monitor gauges. Opening the monitor closes these settings — save first if you changed anything.", "throttleStagesHint": "Parallelism is capped before the hard gate: soft caps at 2 slots, hard at 1. Edited by dragging the markers on the usage-monitor gauges. Opening the monitor closes these settings — save first if you changed anything.",
"throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%", "throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%",
@@ -85,6 +87,7 @@
"retryPrompt": "Retry", "retryPrompt": "Retry",
"dailyPrepPrompt": "Daily prep", "dailyPrepPrompt": "Daily prep",
"weeklyReportPrompt": "Weekly report", "weeklyReportPrompt": "Weekly report",
"powerUsePrompt": "PowerUse addendum",
"viewPrompt": "View", "viewPrompt": "View",
"customizedSection": "CUSTOMIZED", "customizedSection": "CUSTOMIZED",
"customizedHint": "These prompts differ from the bundled default and are kept as-is. Reset to pick up future default improvements.", "customizedHint": "These prompts differ from the bundled default and are kept as-is. Reset to pick up future default improvements.",
@@ -398,6 +401,8 @@
"manualListHint": "New tasks in this list start out manual: no queueing, running or refining, and automation skips them. You can still open a hand-driven session.", "manualListHint": "New tasks in this list start out manual: no queueing, running or refining, and automation skips them. You can still open a hand-driven session.",
"findingsTracked": "Commit the .claudedo findings folder", "findingsTracked": "Commit the .claudedo findings folder",
"findingsTrackedHint": "Off: the folder stays out of git via .git/info/exclude. On: findings travel with the repo.", "findingsTrackedHint": "Off: the folder stays out of git via .git/info/exclude. On: findings travel with the repo.",
"powerUseAllowed": "Allow PowerUse for this list",
"powerUseAllowedHint": "Lets a list-handler run on this list go unattended when PowerUse is switched on in the usage monitor: it asks nothing, resolves conflicts itself, and harvests new work. Both switches are needed, so leaving PowerUse on globally never changes a list that hasn't opted in.",
"sectionIcon": "ICON", "sectionIcon": "ICON",
"icon": "List icon", "icon": "List icon",
"iconHint": "Shown next to the list name in the sidebar.", "iconHint": "Shown next to the list name in the sidebar.",
@@ -504,6 +509,8 @@
"title": "USAGE MONITOR", "title": "USAGE MONITOR",
"windowTitle": "Usage Monitor", "windowTitle": "Usage Monitor",
"noGauges": "No usage limits reported yet.", "noGauges": "No usage limits reported yet.",
"powerUse": "PowerUse",
"powerUseHint": "Spend the rest of the window: a list-handler run asks nothing, resolves conflicts itself, harvests new work, and keeps handing off for as long as its budget allows. Only affects lists that allowed PowerUse in their list settings.",
"refresh": "Refresh now", "refresh": "Refresh now",
"refreshHint": "Polled every 5 min while a task runs, otherwise every 15 min.", "refreshHint": "Polled every 5 min while a task runs, otherwise every 15 min.",
"staleFormat": "Values stale (as of {0})", "staleFormat": "Values stale (as of {0})",
@@ -749,6 +756,7 @@
"loadFailed": "Couldn't load usage data: {0}", "loadFailed": "Couldn't load usage data: {0}",
"refreshFailed": "Couldn't refresh usage: {0}", "refreshFailed": "Couldn't refresh usage: {0}",
"thresholdSaveFailed": "Couldn't save the threshold: {0}", "thresholdSaveFailed": "Couldn't save the threshold: {0}",
"powerUseSaveFailed": "Couldn't save the PowerUse switch: {0}",
"installStarted": "Installing TokenTracker — watch the log strip for progress.", "installStarted": "Installing TokenTracker — watch the log strip for progress.",
"installFailed": "Couldn't install TokenTracker: {0}", "installFailed": "Couldn't install TokenTracker: {0}",
"analyticsRefreshFailed": "Couldn't refresh the analytics: {0}", "analyticsRefreshFailed": "Couldn't refresh the analytics: {0}",
@@ -208,11 +208,13 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
catch { } catch { }
} }
// verifyCommand and ticketProjectId are List-only fields owned by ListSettingsModalViewModel // verifyCommand, ticketProjectId and powerUseAllowed are List-only fields owned by
// (not this editor, which is also reused for Task scope); the caller passes them through so the // ListSettingsModalViewModel (not this editor, which is also reused for Task scope); the caller
// single UpdateListConfig call carries the full desired row instead of clobbering it. // passes them through so the single UpdateListConfig call carries the full desired row instead
// of clobbering it. powerUseAllowed stays null-by-default for the same reason ticketProjectId
// does: a caller that doesn't own it must not revoke the list's opt-in by omission.
public async System.Threading.Tasks.Task SaveAsync( public async System.Threading.Tasks.Task SaveAsync(
string? verifyCommand = null, int? ticketProjectId = null) string? verifyCommand = null, int? ticketProjectId = null, bool? powerUseAllowed = null)
{ {
if (TargetId is null) return; if (TargetId is null) return;
var model = string.IsNullOrWhiteSpace(Model) ? null : Model; var model = string.IsNullOrWhiteSpace(Model) ? null : Model;
@@ -225,7 +227,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
if (_scope == AgentConfigScope.Task) if (_scope == AgentConfigScope.Task)
await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills, permission)); await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills, permission));
else else
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand, permission, ticketProjectId)); await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand, permission, ticketProjectId, powerUseAllowed));
} }
private List<string>? SelectedSessionSkillNames() private List<string>? SelectedSessionSkillNames()
@@ -45,6 +45,10 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
// Optional post-merge verification command (build/test), run in WorkingDir after a merge // Optional post-merge verification command (build/test), run in WorkingDir after a merge
// lands; a non-zero exit keeps the task out of Done instead of silently reporting merged. // lands; a non-zero exit keeps the task out of Done instead of silently reporting merged.
[ObservableProperty] private string _verifyCommand = ""; [ObservableProperty] private string _verifyCommand = "";
// Per-list opt-in for PowerUse. A handler run on this list only goes unattended (no questions,
// harvests new work, longer handoff chain) when this AND the global switch in the usage monitor
// are both on. Off by default — the global switch alone must never change a list's behaviour.
[ObservableProperty] private bool _powerUseAllowed;
// Dropdown hidden entirely when no ticket-system base URL is configured (Settings → Files tab). // Dropdown hidden entirely when no ticket-system base URL is configured (Settings → Files tab).
// Icon key into the UI icon library (IconKeyConverter -> "Icon.<key>" StreamGeometry). // Icon key into the UI icon library (IconKeyConverter -> "Icon.<key>" StreamGeometry).
[ObservableProperty] private string _icon = DefaultIcon; [ObservableProperty] private string _icon = DefaultIcon;
@@ -110,6 +114,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
await Agent.LoadForListAsync(listId, ct); await Agent.LoadForListAsync(listId, ct);
var cfg = await _worker.GetListConfigAsync(listId); var cfg = await _worker.GetListConfigAsync(listId);
VerifyCommand = cfg?.VerifyCommand ?? ""; VerifyCommand = cfg?.VerifyCommand ?? "";
PowerUseAllowed = cfg?.PowerUseAllowed ?? false;
var ticketSettings = TicketSettingsTabViewModel.FeatureEnabled var ticketSettings = TicketSettingsTabViewModel.FeatureEnabled
? await _worker.GetTicketSettingsAsync() ? await _worker.GetTicketSettingsAsync()
@@ -149,9 +154,12 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
// Never null here, or an explicit unlink would silently fail to reach the worker. // Never null here, or an explicit unlink would silently fail to reach the worker.
var ticketProjectId = TicketsAvailable ? (SelectedTicketProject?.Id ?? 0) : (int?)null; var ticketProjectId = TicketsAvailable ? (SelectedTicketProject?.Id ?? 0) : (int?)null;
// Always explicit (never null) — this modal owns the checkbox, so unchecking it has to reach
// the worker as a real false rather than a "leave it alone".
await Agent.SaveAsync( await Agent.SaveAsync(
string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand, string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand,
ticketProjectId); ticketProjectId,
PowerUseAllowed);
Saved = true; Saved = true;
CloseAction?.Invoke(); CloseAction?.Invoke();
@@ -30,6 +30,7 @@ public sealed partial class FilesSettingsTabViewModel : ViewModelBase
public string RetryPromptPath { get; } = PromptFiles.PathFor(PromptKind.Retry); public string RetryPromptPath { get; } = PromptFiles.PathFor(PromptKind.Retry);
public string DailyPrepPromptPath { get; } = PromptFiles.PathFor(PromptKind.DailyPrep); public string DailyPrepPromptPath { get; } = PromptFiles.PathFor(PromptKind.DailyPrep);
public string WeeklyReportPromptPath { get; } = PromptFiles.PathFor(PromptKind.WeeklyReport); public string WeeklyReportPromptPath { get; } = PromptFiles.PathFor(PromptKind.WeeklyReport);
public string PowerUsePromptPath { get; } = PromptFiles.PathFor(PromptKind.PowerUseAddendum);
public ObservableCollection<CustomizedPromptRowViewModel> CustomizedPrompts { get; } = new(); public ObservableCollection<CustomizedPromptRowViewModel> CustomizedPrompts { get; } = new();
@@ -27,6 +27,9 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
// Off by default: auto-continues a usage-limit-failed task once the 5h window resets, and // 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. // wakes a gate-blocked queue exactly at that reset time instead of only the 30s backstop.
[ObservableProperty] private bool _autoContinueOnUsageLimit; [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;
// Newline-separated path prefixes excluded from the weekly report. // Newline-separated path prefixes excluded from the weekly report.
[ObservableProperty] private string _reportExcludedPaths = @"C:\Private"; [ObservableProperty] private string _reportExcludedPaths = @"C:\Private";
// 0=Sunday..6=Saturday (System.DayOfWeek); default Wednesday. // 0=Sunday..6=Saturday (System.DayOfWeek); default Wednesday.
@@ -130,6 +130,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
// carried through load→save verbatim so saving Settings can never reset a dragged value. // carried through load→save verbatim so saving Settings can never reset a dragged value.
private (int FiveSoft, int FiveHard, int SevenSoft, int SevenHard) _throttleStages = (50, 65, 50, 65); private (int FiveSoft, int FiveHard, int SevenSoft, int SevenHard) _throttleStages = (50, 65, 50, 65);
// Same deal: the PowerUse master switch is toggled on the usage monitor. Carried load→save so
// opening and saving Settings can't switch it off behind the user's back.
private bool _powerUseEnabled;
public async Task LoadAsync() public async Task LoadAsync()
{ {
IsBusy = true; IsBusy = true;
@@ -147,6 +151,11 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
General.UsageGateFiveHourPct = dto.UsageGateFiveHourPct; General.UsageGateFiveHourPct = dto.UsageGateFiveHourPct;
General.UsageGateSevenDayPct = dto.UsageGateSevenDayPct; General.UsageGateSevenDayPct = dto.UsageGateSevenDayPct;
General.AutoContinueOnUsageLimit = dto.AutoContinueOnUsageLimit; General.AutoContinueOnUsageLimit = dto.AutoContinueOnUsageLimit;
General.ListHandlerMaxHandoffs = dto.ListHandlerMaxHandoffs;
// Owned by the usage monitor, not editable here — but it MUST be round-tripped,
// because SaveAsync rebuilds the whole DTO positionally and would otherwise write
// the record default (false) back and silently switch PowerUse off.
_powerUseEnabled = dto.PowerUseEnabled;
_throttleStages = ( _throttleStages = (
dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct, dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct,
dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct); dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct);
@@ -213,7 +222,9 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
_throttleStages.FiveHard, _throttleStages.FiveHard,
_throttleStages.SevenSoft, _throttleStages.SevenSoft,
_throttleStages.SevenHard, _throttleStages.SevenHard,
General.AutoContinueOnUsageLimit); General.AutoContinueOnUsageLimit,
_powerUseEnabled,
General.ListHandlerMaxHandoffs);
await _worker.UpdateAppSettingsAsync(dto); await _worker.UpdateAppSettingsAsync(dto);
await Prime.SaveAsync(); await Prime.SaveAsync();
await OnlineInbox.SaveAsync(); await OnlineInbox.SaveAsync();
@@ -100,6 +100,45 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
private IReadOnlyList<UsageGaugeRowViewModel> _gaugeRows = Array.Empty<UsageGaugeRowViewModel>(); private IReadOnlyList<UsageGaugeRowViewModel> _gaugeRows = Array.Empty<UsageGaugeRowViewModel>();
/// <summary>
/// PowerUse master switch. It lives here rather than in Settings because turning it on is a
/// "spend the rest of the window" decision, made while looking at how much window is left.
/// On its own it changes nothing — a list-handler run only goes unattended when its LIST also
/// opted in (list settings → PowerUse), so this can stay on without affecting other lists.
/// </summary>
[ObservableProperty] private bool _powerUseEnabled;
// Set while LoadAsync writes the stored value in, so seeding the property doesn't immediately
// write it straight back to the worker.
private bool _loadingPowerUse;
partial void OnPowerUseEnabledChanged(bool value)
{
if (_loadingPowerUse) return;
_ = SavePowerUseAsync(value);
}
/// Read-modify-write, same discipline as SaveStagesAsync: never clobber a field this modal
/// doesn't own.
private async Task SavePowerUseAsync(bool value)
{
try
{
var settings = await _worker.GetAppSettingsAsync();
if (settings is null)
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.powerUseSaveFailed", Loc.T("vm.settingsModal.workerOffline")));
return;
}
await _worker.UpdateAppSettingsAsync(settings with { PowerUseEnabled = value });
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.powerUseSaveFailed", ex.Message));
}
}
public bool IsStale => Snapshot?.IsStale == true; public bool IsStale => Snapshot?.IsStale == true;
public string? LastError => Snapshot?.LastError; public string? LastError => Snapshot?.LastError;
public bool IsGateBlocked => Snapshot?.IsGateBlocked == true; public bool IsGateBlocked => Snapshot?.IsGateBlocked == true;
@@ -148,6 +187,15 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
Snapshot = snapshot; Snapshot = snapshot;
_worker.UsageUpdatedEvent -= OnUsageUpdated; _worker.UsageUpdatedEvent -= OnUsageUpdated;
_worker.UsageUpdatedEvent += OnUsageUpdated; _worker.UsageUpdatedEvent += OnUsageUpdated;
var settings = await _worker.GetAppSettingsAsync();
if (settings is not null)
{
_loadingPowerUse = true;
try { PowerUseEnabled = settings.PowerUseEnabled; }
finally { _loadingPowerUse = false; }
}
ApplyPresetRange(SelectedPresetDays); ApplyPresetRange(SelectedPresetDays);
await LoadUsageDataAsync(); await LoadUsageDataAsync();
} }
@@ -103,6 +103,13 @@
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/> Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="4">
<CheckBox IsChecked="{Binding PowerUseAllowed, Mode=TwoWay}"
Content="{loc:Tr modals.listSettings.powerUseAllowed}"/>
<TextBlock Text="{loc:Tr modals.listSettings.powerUseAllowedHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding TicketsAvailable}"> <StackPanel Spacing="4" IsVisible="{Binding TicketsAvailable}">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.ticketProject}"/> <TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.ticketProject}"/>
<ComboBox ItemsSource="{Binding TicketProjects}" <ComboBox ItemsSource="{Binding TicketProjects}"
@@ -251,6 +251,17 @@
Content="{loc:Tr settings.general.autoContinueOnUsageLimit}"/> Content="{loc:Tr settings.general.autoContinueOnUsageLimit}"/>
<TextBlock Text="{loc:Tr settings.general.autoContinueOnUsageLimitHint}" <TextBlock Text="{loc:Tr settings.general.autoContinueOnUsageLimitHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/> Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
<!-- PowerUse itself is toggled on the usage monitor; only its handoff budget is
typed here, because it also applies to a normal (interactive) handler run. -->
<StackPanel Spacing="4">
<TextBlock Classes="eyebrow" Text="{loc:Tr settings.general.listHandlerMaxHandoffs}"/>
<NumericUpDown Value="{Binding General.ListHandlerMaxHandoffs, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
Minimum="1" Maximum="200" Increment="2" FormatString="0"
HorizontalAlignment="Left" Width="140"/>
<TextBlock Text="{loc:Tr settings.general.listHandlerMaxHandoffsHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
</StackPanel> </StackPanel>
<!-- Throttle stages live on the usage-monitor gauges (drag + spinner per stage); <!-- Throttle stages live on the usage-monitor gauges (drag + spinner per stage);
@@ -433,7 +444,7 @@
</StackPanel> </StackPanel>
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Classes="section-label" Text="{loc:Tr settings.files.promptsSection}"/> <TextBlock Classes="section-label" Text="{loc:Tr settings.files.promptsSection}"/>
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="120,*,Auto" RowSpacing="8"> <Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="120,*,Auto" RowSpacing="8">
<TextBlock Grid.Row="0" Grid.Column="0" Classes="field-label" Text="{loc:Tr settings.files.systemPrompt}" VerticalAlignment="Center"/> <TextBlock Grid.Row="0" Grid.Column="0" Classes="field-label" Text="{loc:Tr settings.files.systemPrompt}" VerticalAlignment="Center"/>
<TextBlock Grid.Row="0" Grid.Column="1" Classes="path-mono" Text="{Binding Files.SystemPromptPath}" VerticalAlignment="Center"/> <TextBlock Grid.Row="0" Grid.Column="1" Classes="path-mono" Text="{Binding Files.SystemPromptPath}" VerticalAlignment="Center"/>
<Button Classes="btn" Grid.Row="0" Grid.Column="2" Content="{loc:Tr settings.files.viewPrompt}" <Button Classes="btn" Grid.Row="0" Grid.Column="2" Content="{loc:Tr settings.files.viewPrompt}"
@@ -458,6 +469,10 @@
<TextBlock Grid.Row="5" Grid.Column="1" Classes="path-mono" Text="{Binding Files.WeeklyReportPromptPath}" VerticalAlignment="Center"/> <TextBlock Grid.Row="5" Grid.Column="1" Classes="path-mono" Text="{Binding Files.WeeklyReportPromptPath}" VerticalAlignment="Center"/>
<Button Classes="btn" Grid.Row="5" Grid.Column="2" Content="{loc:Tr settings.files.viewPrompt}" <Button Classes="btn" Grid.Row="5" Grid.Column="2" Content="{loc:Tr settings.files.viewPrompt}"
Command="{Binding Files.ViewPromptCommand}" CommandParameter="WeeklyReport"/> Command="{Binding Files.ViewPromptCommand}" CommandParameter="WeeklyReport"/>
<TextBlock Grid.Row="6" Grid.Column="0" Classes="field-label" Text="{loc:Tr settings.files.powerUsePrompt}" VerticalAlignment="Center"/>
<TextBlock Grid.Row="6" Grid.Column="1" Classes="path-mono" Text="{Binding Files.PowerUsePromptPath}" VerticalAlignment="Center"/>
<Button Classes="btn" Grid.Row="6" Grid.Column="2" Content="{loc:Tr settings.files.viewPrompt}"
Command="{Binding Files.ViewPromptCommand}" CommandParameter="PowerUseAddendum"/>
</Grid> </Grid>
</StackPanel> </StackPanel>
<StackPanel Spacing="6" IsVisible="{Binding Files.ViewedKindName, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"> <StackPanel Spacing="6" IsVisible="{Binding Files.ViewedKindName, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
@@ -134,6 +134,19 @@
Text="{loc:Tr modals.usageMonitor.noGauges}" Text="{loc:Tr modals.usageMonitor.noGauges}"
IsVisible="{Binding !GaugeRows.Count}"/> IsVisible="{Binding !GaugeRows.Count}"/>
<!-- PowerUse: sits under the gauges on purpose — "spend the rest of the window" is a
decision made while looking at how much window is left. Saves immediately on toggle. -->
<Border DockPanel.Dock="Top" Margin="20,12,20,0" Padding="12,10"
Background="{DynamicResource DeepBrush}" BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1" CornerRadius="6">
<StackPanel Spacing="4">
<CheckBox IsChecked="{Binding PowerUseEnabled, Mode=TwoWay}"
Content="{loc:Tr modals.usageMonitor.powerUse}"/>
<TextBlock Classes="meta" TextWrapping="Wrap"
Text="{loc:Tr modals.usageMonitor.powerUseHint}"/>
</StackPanel>
</Border>
<!-- Period picker --> <!-- Period picker -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="20,12,20,0"> <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="20,12,20,0">
<TextBlock Classes="meta" Text="{loc:Tr modals.usageMonitor.period}" VerticalAlignment="Center"/> <TextBlock Classes="meta" Text="{loc:Tr modals.usageMonitor.period}" VerticalAlignment="Center"/>