feat(usage): split throttle thresholds per bucket, add draggable gauge markers
This commit is contained in:
@@ -32,7 +32,7 @@ ViewModels/
|
||||
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
|
||||
DescriptionStepsCard, WorkConsole; plus SessionTerminalView
|
||||
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffLinesView, InheritedBadge,
|
||||
AgentConfigEditor
|
||||
AgentConfigEditor, UsagePill, UsageGaugeBar
|
||||
Design/ — Tokens.axaml (design tokens; merged before styles)
|
||||
+ IslandStyles.axaml (component styles + the filled icon geometry library)
|
||||
```
|
||||
@@ -58,7 +58,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles)
|
||||
| `ListSettingsModalViewModel` | Name, working dir, commit type, "manual list" flag, `VerifyCommand`, delete. Hosts the shared `AgentConfigEditorViewModel` as `Agent` (scope=List) — ⚠️ save delegates to `Agent.SaveAsync(verifyCommand)` because both land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other. |
|
||||
| `WeeklyReportModalViewModel` | Range pickers default "since last standup weekday → today", cached per range. |
|
||||
| `MergeHelperSelectionModalViewModel` | "Let Claude handle it" picker → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). |
|
||||
| `UsageMonitorModalViewModel` | Opened from the usage pill; gauges are **dynamic** per `UsageSnapshotDto.Limits` row. |
|
||||
| `UsageMonitorModalViewModel` | Opened from the usage pill (shown **before** the data loads via `BeginLoad`); gauges are **dynamic** per `UsageSnapshotDto.Limits` row, and the 5h/7d ones carry three draggable stage markers (soft/hard/gate) via `UsageGaugeBar` + the pure `UsageThresholdDrag`, plus a colour-matched legend with a `NumericUpDown` per stage → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). |
|
||||
|
||||
Self-explanatory: `RepoImportModalViewModel` (bulk-create lists from git repos; already-wired
|
||||
repos disabled), `MergeModalViewModel`, `WorktreesOverviewModalViewModel`,
|
||||
|
||||
@@ -670,7 +670,12 @@ public sealed record AppSettingsDto(
|
||||
List<ModelPresetDto>? ModelPresets = null,
|
||||
int UsageGateFiveHourPct = 80,
|
||||
int UsageGateSevenDayPct = 90,
|
||||
int MaxTurnsCeiling = 80);
|
||||
int MaxTurnsCeiling = 80,
|
||||
// Throttle stages per bucket — dragged on the usage-monitor gauges, not typed in Settings.
|
||||
int UsageThrottleFiveHourSoftPct = 50,
|
||||
int UsageThrottleFiveHourHardPct = 65,
|
||||
int UsageThrottleSevenDaySoftPct = 50,
|
||||
int UsageThrottleSevenDayHardPct = 65);
|
||||
|
||||
// Per-model run defaults (effort + turn budget) edited in Settings → General.
|
||||
public sealed record ModelPresetDto(string Model, string Effort, int MaxTurns);
|
||||
@@ -764,7 +769,13 @@ public sealed record UsageSnapshotDto(
|
||||
string? LastError,
|
||||
int ConfiguredSlots,
|
||||
int EffectiveSlots,
|
||||
string? ThrottleBucket);
|
||||
string? ThrottleBucket,
|
||||
// Throttle stages per bucket, drawn (and dragged) on the usage-monitor gauges. Defaults match
|
||||
// the DB defaults so an older worker that doesn't send them yet still yields sane markers.
|
||||
int ThrottleFiveHourSoftPct = 50,
|
||||
int ThrottleFiveHourHardPct = 65,
|
||||
int ThrottleSevenDaySoftPct = 50,
|
||||
int ThrottleSevenDayHardPct = 65);
|
||||
|
||||
public sealed record ModelUsageRowDto(
|
||||
DateOnly Date,
|
||||
|
||||
@@ -570,7 +570,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
var vm = _usageMonitorVmFactory();
|
||||
vm.ErrorReported += FlashFooterError;
|
||||
await vm.LoadAsync();
|
||||
// Show first, load after: the initial transcript scan takes seconds, and awaiting it
|
||||
// here left the pill looking unresponsive until the window finally appeared.
|
||||
vm.BeginLoad();
|
||||
await Dialogs.ShowUsageMonitorAsync(vm);
|
||||
}
|
||||
finally { _usageMonitorOpen = false; }
|
||||
|
||||
@@ -19,6 +19,9 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
public PrimeClaudeTabViewModel Prime { get; }
|
||||
public OnlineInboxSettingsViewModel OnlineInbox { get; }
|
||||
public SessionSkillsSettingsTabViewModel SessionSkills { get; }
|
||||
|
||||
// Online Inbox ist unfertig und vor Usern verborgen — hier auf true für Reaktivierung.
|
||||
public bool ShowOnlineInbox => false;
|
||||
|
||||
[ObservableProperty] private string _validationError = "";
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
@@ -48,6 +51,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
SessionSkills = new SessionSkillsSettingsTabViewModel(worker);
|
||||
}
|
||||
|
||||
// Throttle stages are edited by dragging the usage-monitor gauges, not on any Settings tab —
|
||||
// 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);
|
||||
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
@@ -64,6 +71,9 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
General.MaxParallelExecutions = dto.MaxParallelExecutions;
|
||||
General.UsageGateFiveHourPct = dto.UsageGateFiveHourPct;
|
||||
General.UsageGateSevenDayPct = dto.UsageGateSevenDayPct;
|
||||
_throttleStages = (
|
||||
dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct,
|
||||
dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct);
|
||||
Worktrees.WorktreeStrategy = dto.WorktreeStrategy ?? "sibling";
|
||||
Worktrees.CentralWorktreeRoot = dto.CentralWorktreeRoot;
|
||||
Worktrees.WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled;
|
||||
@@ -115,7 +125,11 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
General.ModelPresetDtos(),
|
||||
General.UsageGateFiveHourPct,
|
||||
General.UsageGateSevenDayPct,
|
||||
General.MaxTurnsCeiling);
|
||||
General.MaxTurnsCeiling,
|
||||
_throttleStages.FiveSoft,
|
||||
_throttleStages.FiveHard,
|
||||
_throttleStages.SevenSoft,
|
||||
_throttleStages.SevenHard);
|
||||
await _worker.UpdateAppSettingsAsync(dto);
|
||||
await Prime.SaveAsync();
|
||||
await OnlineInbox.SaveAsync();
|
||||
|
||||
@@ -20,7 +20,6 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
||||
public event Action<string>? ErrorReported;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(GaugeRows))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStale))]
|
||||
[NotifyPropertyChangedFor(nameof(LastError))]
|
||||
[NotifyPropertyChangedFor(nameof(IsGateBlocked))]
|
||||
@@ -53,8 +52,8 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
||||
public bool ModelsEmpty => !IsBusy && ModelRows.Count == 0;
|
||||
public bool TasksEmpty => !IsBusy && TaskRows.Count == 0;
|
||||
|
||||
public IReadOnlyList<UsageGaugeRowViewModel> GaugeRows =>
|
||||
Snapshot is null ? Array.Empty<UsageGaugeRowViewModel>() : Snapshot.Limits.Select(BuildGaugeRow).ToList();
|
||||
[ObservableProperty]
|
||||
private IReadOnlyList<UsageGaugeRowViewModel> _gaugeRows = Array.Empty<UsageGaugeRowViewModel>();
|
||||
|
||||
public bool IsStale => Snapshot?.IsStale == true;
|
||||
public string? LastError => Snapshot?.LastError;
|
||||
@@ -83,13 +82,30 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
||||
CloseAction?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the load without blocking the caller, so the host can show the window right away and
|
||||
/// let it fill in behind the busy spinner. The first load per worker process pays a full scan of
|
||||
/// <c>~/.claude/projects</c> (hundreds of MB of transcripts) — awaiting it before showing the
|
||||
/// window made the usage pill look like it swallowed the click.
|
||||
/// </summary>
|
||||
public void BeginLoad() => _ = LoadAsync();
|
||||
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
Snapshot = await _worker.GetUsageSnapshotAsync();
|
||||
_worker.UsageUpdatedEvent -= OnUsageUpdated;
|
||||
_worker.UsageUpdatedEvent += OnUsageUpdated;
|
||||
ApplyPresetRange(SelectedPresetDays);
|
||||
await LoadUsageDataAsync();
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
Snapshot = await _worker.GetUsageSnapshotAsync();
|
||||
_worker.UsageUpdatedEvent -= OnUsageUpdated;
|
||||
_worker.UsageUpdatedEvent += OnUsageUpdated;
|
||||
ApplyPresetRange(SelectedPresetDays);
|
||||
await LoadUsageDataAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.loadFailed", ex.Message));
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
private void OnUsageUpdated(UsageSnapshotDto snapshot) => Snapshot = snapshot;
|
||||
@@ -173,6 +189,99 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
partial void OnSnapshotChanged(UsageSnapshotDto? value) => SyncGaugeRows();
|
||||
|
||||
/// <summary>
|
||||
/// Folds a fresh snapshot into the existing rows instead of rebuilding them, so a poll landing
|
||||
/// while the user works the markers doesn't swap the instances out from under the drag.
|
||||
/// </summary>
|
||||
private void SyncGaugeRows()
|
||||
{
|
||||
var limits = Snapshot?.Limits ?? (IReadOnlyList<UsageLimitDto>)Array.Empty<UsageLimitDto>();
|
||||
var existing = GaugeRows.ToDictionary(r => r.Key);
|
||||
var rows = new List<UsageGaugeRowViewModel>(limits.Count);
|
||||
|
||||
foreach (var limit in limits)
|
||||
{
|
||||
var key = GaugeKey(limit);
|
||||
var bucket = GaugeBucket(limit);
|
||||
var (soft, hard, gate) = StagesFor(bucket);
|
||||
var label = BuildGaugeLabel(limit);
|
||||
|
||||
if (existing.TryGetValue(key, out var row))
|
||||
{
|
||||
row.Update(label, limit.Percent, limit.Severity, limit.ResetsAt, soft, hard, gate);
|
||||
rows.Add(row);
|
||||
}
|
||||
else
|
||||
{
|
||||
rows.Add(new UsageGaugeRowViewModel(
|
||||
key, bucket, label, limit.Percent, limit.Severity, limit.ResetsAt,
|
||||
soft, hard, gate, SaveStagesAsync));
|
||||
}
|
||||
}
|
||||
|
||||
if (!rows.SequenceEqual(GaugeRows)) GaugeRows = rows;
|
||||
}
|
||||
|
||||
// Scoped weekly buckets are plan-dependent and share no settings row, so they stay read-only.
|
||||
private static string? GaugeBucket(UsageLimitDto limit) => limit.Kind switch
|
||||
{
|
||||
"session" => "five_hour",
|
||||
"weekly_all" => "seven_day",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string GaugeKey(UsageLimitDto limit) =>
|
||||
limit.Kind == "weekly_scoped" ? $"weekly_scoped:{limit.ScopeModelDisplayName}" : limit.Kind;
|
||||
|
||||
private (int? Soft, int? Hard, int? Gate) StagesFor(string? bucket) => (bucket, Snapshot) switch
|
||||
{
|
||||
("five_hour", { } s) => (s.ThrottleFiveHourSoftPct, s.ThrottleFiveHourHardPct, s.FiveHourThresholdPct),
|
||||
("seven_day", { } s) => (s.ThrottleSevenDaySoftPct, s.ThrottleSevenDayHardPct, s.SevenDayThresholdPct),
|
||||
_ => (null, null, null),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Persists one gauge's stages after a drag. Read-modify-write against the current settings, so
|
||||
/// this never clobbers a field the usage monitor doesn't own.
|
||||
/// </summary>
|
||||
private async Task SaveStagesAsync(UsageGaugeRowViewModel row)
|
||||
{
|
||||
if (row.Bucket is null || row.SoftPct is not { } soft || row.HardPct is not { } hard || row.GatePct is not { } gate)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var settings = await _worker.GetAppSettingsAsync();
|
||||
if (settings is null)
|
||||
{
|
||||
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.thresholdSaveFailed", Loc.T("vm.settingsModal.workerOffline")));
|
||||
return;
|
||||
}
|
||||
|
||||
var updated = row.Bucket == "five_hour"
|
||||
? settings with
|
||||
{
|
||||
UsageThrottleFiveHourSoftPct = soft,
|
||||
UsageThrottleFiveHourHardPct = hard,
|
||||
UsageGateFiveHourPct = gate,
|
||||
}
|
||||
: settings with
|
||||
{
|
||||
UsageThrottleSevenDaySoftPct = soft,
|
||||
UsageThrottleSevenDayHardPct = hard,
|
||||
UsageGateSevenDayPct = gate,
|
||||
};
|
||||
|
||||
await _worker.UpdateAppSettingsAsync(updated);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.thresholdSaveFailed", ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildGaugeLabel(UsageLimitDto limit) => limit.Kind switch
|
||||
{
|
||||
"session" => Loc.T("modals.usageMonitor.gaugeSession"),
|
||||
@@ -182,17 +291,6 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
||||
_ => limit.Kind,
|
||||
};
|
||||
|
||||
private UsageGaugeRowViewModel BuildGaugeRow(UsageLimitDto limit)
|
||||
{
|
||||
int? threshold = limit.Kind switch
|
||||
{
|
||||
"session" => Snapshot?.FiveHourThresholdPct,
|
||||
"weekly_all" => Snapshot?.SevenDayThresholdPct,
|
||||
_ => null,
|
||||
};
|
||||
return new UsageGaugeRowViewModel(BuildGaugeLabel(limit), limit.Percent, limit.Severity, limit.ResetsAt, threshold);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ModelUsageDisplayRow> BuildModelDisplayRows(IReadOnlyList<ModelUsageRowDto> rows)
|
||||
{
|
||||
var built = new List<ModelUsageDisplayRow>();
|
||||
@@ -222,22 +320,115 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UsageGaugeRowViewModel(
|
||||
string Label,
|
||||
double Percent,
|
||||
string Severity,
|
||||
DateTimeOffset? ResetsAt,
|
||||
int? ThresholdPercent)
|
||||
/// <summary>
|
||||
/// One usage gauge. The two real buckets (5h session, 7d week) carry their three stage thresholds
|
||||
/// and are adjustable by dragging; plan-dependent scoped buckets render as a plain bar.
|
||||
/// </summary>
|
||||
public sealed partial class UsageGaugeRowViewModel : ObservableObject
|
||||
{
|
||||
private readonly Func<UsageGaugeRowViewModel, Task>? _commit;
|
||||
|
||||
public UsageGaugeRowViewModel(
|
||||
string key,
|
||||
string? bucket,
|
||||
string label,
|
||||
double percent,
|
||||
string severity,
|
||||
DateTimeOffset? resetsAt,
|
||||
int? softPct,
|
||||
int? hardPct,
|
||||
int? gatePct,
|
||||
Func<UsageGaugeRowViewModel, Task>? commit = null)
|
||||
{
|
||||
Key = key;
|
||||
Bucket = bucket;
|
||||
_label = label;
|
||||
_percent = percent;
|
||||
_severity = severity;
|
||||
_resetsAt = resetsAt;
|
||||
_softPct = softPct;
|
||||
_hardPct = hardPct;
|
||||
_gatePct = gatePct;
|
||||
_commit = commit;
|
||||
}
|
||||
|
||||
/// <summary>Identity across snapshot updates, so a live poll updates rows instead of replacing them.</summary>
|
||||
public string Key { get; }
|
||||
|
||||
/// <summary>Which settings bucket a drag writes to: <c>five_hour</c>, <c>seven_day</c>, or null.</summary>
|
||||
public string? Bucket { get; }
|
||||
|
||||
[ObservableProperty] private string _label;
|
||||
[ObservableProperty] private double _percent;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsWarnSeverity))]
|
||||
private string _severity;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ResetText))]
|
||||
private DateTimeOffset? _resetsAt;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsAdjustable))]
|
||||
private int? _softPct;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsAdjustable))]
|
||||
private int? _hardPct;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsAdjustable))]
|
||||
private int? _gatePct;
|
||||
|
||||
public bool IsAdjustable => Bucket is not null && SoftPct is not null && HardPct is not null && GatePct is not null;
|
||||
|
||||
public bool IsWarnSeverity => !string.Equals(Severity, "normal", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public string ResetText => ResetsAt is { } r ? Loc.T("modals.usageMonitor.resetIn", FormatRemaining(r)) : "";
|
||||
|
||||
// Matches the gauge card's inner track width in the view (240 card width - 12*2 padding).
|
||||
private const double GaugeTrackWidthPx = 216;
|
||||
/// <summary>Live values from a fresh snapshot, without replacing the row instance mid-view.</summary>
|
||||
public void Update(string label, double percent, string severity, DateTimeOffset? resetsAt,
|
||||
int? softPct, int? hardPct, int? gatePct)
|
||||
{
|
||||
Label = label;
|
||||
Percent = percent;
|
||||
Severity = severity;
|
||||
ResetsAt = resetsAt;
|
||||
SoftPct = softPct;
|
||||
HardPct = hardPct;
|
||||
GatePct = gatePct;
|
||||
}
|
||||
|
||||
public double ThresholdMarkerLeftPx =>
|
||||
ThresholdPercent is { } t ? GaugeTrackWidthPx * Math.Clamp(t, 0, 100) / 100.0 : 0;
|
||||
/// <summary>Raised by the gauge control when a drag ends — that is when the value is persisted.</summary>
|
||||
[RelayCommand]
|
||||
private Task Commit() => _commit?.Invoke(this) ?? Task.CompletedTask;
|
||||
|
||||
// One per legend input box. A typed value goes through the same clamp as a dragged one, so a box
|
||||
// can't invert the order — and only the edited stage moves, never its neighbours.
|
||||
[RelayCommand] private Task CommitSoft() => CommitStage(UsageThresholdDrag.Stage.Soft);
|
||||
[RelayCommand] private Task CommitHard() => CommitStage(UsageThresholdDrag.Stage.Hard);
|
||||
[RelayCommand] private Task CommitGate() => CommitStage(UsageThresholdDrag.Stage.Gate);
|
||||
|
||||
private Task CommitStage(UsageThresholdDrag.Stage stage)
|
||||
{
|
||||
if (!IsAdjustable) return Task.CompletedTask;
|
||||
|
||||
var edited = stage switch
|
||||
{
|
||||
UsageThresholdDrag.Stage.Soft => SoftPct!.Value,
|
||||
UsageThresholdDrag.Stage.Hard => HardPct!.Value,
|
||||
_ => GatePct!.Value,
|
||||
};
|
||||
|
||||
var (soft, hard, gate) = UsageThresholdDrag.Apply(
|
||||
SoftPct!.Value, HardPct!.Value, GatePct!.Value, stage, edited);
|
||||
SoftPct = soft;
|
||||
HardPct = hard;
|
||||
GatePct = gate;
|
||||
|
||||
return Commit();
|
||||
}
|
||||
|
||||
private static string FormatRemaining(DateTimeOffset resetsAt)
|
||||
{
|
||||
@@ -251,6 +442,54 @@ public sealed record UsageGaugeRowViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drag math for the gauge stage markers, kept out of the control so it can be tested directly:
|
||||
/// every stage stays inside 0..100 and never crosses a neighbour (soft ≤ hard ≤ gate). A neighbour
|
||||
/// at 0 means "that stage is off" and therefore does not constrain anything.
|
||||
/// </summary>
|
||||
public static class UsageThresholdDrag
|
||||
{
|
||||
public enum Stage { Soft, Hard, Gate }
|
||||
|
||||
/// <summary>Pointer reach for grabbing a marker, as a share of the bar width.</summary>
|
||||
public static Stage? Nearest(int soft, int hard, int gate, double percent, double tolerancePercent)
|
||||
{
|
||||
Stage? best = null;
|
||||
var bestDistance = double.MaxValue;
|
||||
|
||||
foreach (var (stage, value) in new[] { (Stage.Soft, soft), (Stage.Hard, hard), (Stage.Gate, gate) })
|
||||
{
|
||||
var distance = Math.Abs(percent - value);
|
||||
if (distance > tolerancePercent || distance >= bestDistance) continue;
|
||||
best = stage;
|
||||
bestDistance = distance;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
public static (int Soft, int Hard, int Gate) Apply(int soft, int hard, int gate, Stage stage, double rawPercent)
|
||||
{
|
||||
var value = (int)Math.Round(Math.Clamp(rawPercent, 0, 100));
|
||||
|
||||
return stage switch
|
||||
{
|
||||
Stage.Soft => (ClampRange(value, 0, UpperBound(hard, gate)), hard, gate),
|
||||
Stage.Hard => (soft, ClampRange(value, soft, UpperBound(gate, 100)), gate),
|
||||
Stage.Gate => (soft, hard, ClampRange(value, Math.Max(soft, hard), 100)),
|
||||
_ => (soft, hard, gate),
|
||||
};
|
||||
}
|
||||
|
||||
// A neighbour of 0 is switched off and must not pin the dragged marker to 0.
|
||||
private static int UpperBound(int nearest, int fallback) =>
|
||||
nearest > 0 ? nearest : (fallback > 0 ? fallback : 100);
|
||||
|
||||
// An already-inconsistent stored config (min above max) must not throw mid-drag.
|
||||
private static int ClampRange(int value, int min, int max) =>
|
||||
max < min ? max : Math.Clamp(value, min, max);
|
||||
}
|
||||
|
||||
public sealed record ModelUsageDisplayRow(
|
||||
string Model,
|
||||
long ClaudeDoInputTokens,
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
using System;
|
||||
using System.Windows.Input;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Data;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Rendering;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Usage bar with three draggable stage markers: soft (throttle to 2 slots), hard (1 slot) and gate
|
||||
/// (queue paused). Positions are computed against the control's real width — no hardcoded track
|
||||
/// size — and the drag math lives in <see cref="UsageThresholdDrag"/> so it stays testable.
|
||||
/// Values are written back through TwoWay bindings while dragging; <see cref="CommitCommand"/>
|
||||
/// fires once on release, which is when the host persists them.
|
||||
/// A row without thresholds (plan-dependent scoped buckets) renders as a plain read-only bar.
|
||||
/// </summary>
|
||||
public sealed class UsageGaugeBar : Control, ICustomHitTest
|
||||
{
|
||||
/// <summary>How close the pointer has to be to grab a marker.</summary>
|
||||
private const double GrabRadiusPx = 12;
|
||||
|
||||
private const double TrackHeightPx = 10;
|
||||
private const double MarkerWidthPx = 2;
|
||||
|
||||
public static readonly StyledProperty<double> PercentProperty =
|
||||
AvaloniaProperty.Register<UsageGaugeBar, double>(nameof(Percent));
|
||||
|
||||
public static readonly StyledProperty<bool> IsWarnProperty =
|
||||
AvaloniaProperty.Register<UsageGaugeBar, bool>(nameof(IsWarn));
|
||||
|
||||
public static readonly StyledProperty<int?> SoftPctProperty =
|
||||
AvaloniaProperty.Register<UsageGaugeBar, int?>(
|
||||
nameof(SoftPct), defaultBindingMode: BindingMode.TwoWay);
|
||||
|
||||
public static readonly StyledProperty<int?> HardPctProperty =
|
||||
AvaloniaProperty.Register<UsageGaugeBar, int?>(
|
||||
nameof(HardPct), defaultBindingMode: BindingMode.TwoWay);
|
||||
|
||||
public static readonly StyledProperty<int?> GatePctProperty =
|
||||
AvaloniaProperty.Register<UsageGaugeBar, int?>(
|
||||
nameof(GatePct), defaultBindingMode: BindingMode.TwoWay);
|
||||
|
||||
public static readonly StyledProperty<IBrush?> TrackBrushProperty =
|
||||
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(TrackBrush));
|
||||
|
||||
public static readonly StyledProperty<IBrush?> FillBrushProperty =
|
||||
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(FillBrush));
|
||||
|
||||
public static readonly StyledProperty<IBrush?> WarnFillBrushProperty =
|
||||
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(WarnFillBrush));
|
||||
|
||||
public static readonly StyledProperty<IBrush?> SoftMarkerBrushProperty =
|
||||
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(SoftMarkerBrush));
|
||||
|
||||
public static readonly StyledProperty<IBrush?> HardMarkerBrushProperty =
|
||||
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(HardMarkerBrush));
|
||||
|
||||
public static readonly StyledProperty<IBrush?> GateMarkerBrushProperty =
|
||||
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(GateMarkerBrush));
|
||||
|
||||
public static readonly StyledProperty<ICommand?> CommitCommandProperty =
|
||||
AvaloniaProperty.Register<UsageGaugeBar, ICommand?>(nameof(CommitCommand));
|
||||
|
||||
static UsageGaugeBar()
|
||||
{
|
||||
AffectsRender<UsageGaugeBar>(
|
||||
PercentProperty, IsWarnProperty, SoftPctProperty, HardPctProperty, GatePctProperty,
|
||||
TrackBrushProperty, FillBrushProperty, WarnFillBrushProperty,
|
||||
SoftMarkerBrushProperty, HardMarkerBrushProperty, GateMarkerBrushProperty);
|
||||
}
|
||||
|
||||
private UsageThresholdDrag.Stage? _dragging;
|
||||
|
||||
public double Percent
|
||||
{
|
||||
get => GetValue(PercentProperty);
|
||||
set => SetValue(PercentProperty, value);
|
||||
}
|
||||
|
||||
public bool IsWarn
|
||||
{
|
||||
get => GetValue(IsWarnProperty);
|
||||
set => SetValue(IsWarnProperty, value);
|
||||
}
|
||||
|
||||
public int? SoftPct
|
||||
{
|
||||
get => GetValue(SoftPctProperty);
|
||||
set => SetValue(SoftPctProperty, value);
|
||||
}
|
||||
|
||||
public int? HardPct
|
||||
{
|
||||
get => GetValue(HardPctProperty);
|
||||
set => SetValue(HardPctProperty, value);
|
||||
}
|
||||
|
||||
public int? GatePct
|
||||
{
|
||||
get => GetValue(GatePctProperty);
|
||||
set => SetValue(GatePctProperty, value);
|
||||
}
|
||||
|
||||
public IBrush? TrackBrush
|
||||
{
|
||||
get => GetValue(TrackBrushProperty);
|
||||
set => SetValue(TrackBrushProperty, value);
|
||||
}
|
||||
|
||||
public IBrush? FillBrush
|
||||
{
|
||||
get => GetValue(FillBrushProperty);
|
||||
set => SetValue(FillBrushProperty, value);
|
||||
}
|
||||
|
||||
public IBrush? WarnFillBrush
|
||||
{
|
||||
get => GetValue(WarnFillBrushProperty);
|
||||
set => SetValue(WarnFillBrushProperty, value);
|
||||
}
|
||||
|
||||
public IBrush? SoftMarkerBrush
|
||||
{
|
||||
get => GetValue(SoftMarkerBrushProperty);
|
||||
set => SetValue(SoftMarkerBrushProperty, value);
|
||||
}
|
||||
|
||||
public IBrush? HardMarkerBrush
|
||||
{
|
||||
get => GetValue(HardMarkerBrushProperty);
|
||||
set => SetValue(HardMarkerBrushProperty, value);
|
||||
}
|
||||
|
||||
public IBrush? GateMarkerBrush
|
||||
{
|
||||
get => GetValue(GateMarkerBrushProperty);
|
||||
set => SetValue(GateMarkerBrushProperty, value);
|
||||
}
|
||||
|
||||
public ICommand? CommitCommand
|
||||
{
|
||||
get => GetValue(CommitCommandProperty);
|
||||
set => SetValue(CommitCommandProperty, value);
|
||||
}
|
||||
|
||||
private bool IsAdjustable => SoftPct is not null && HardPct is not null && GatePct is not null;
|
||||
|
||||
// Custom hit test (point is in local coordinates): the control draws itself, so the whole
|
||||
// rectangle takes the pointer — not just the pixels the track happens to cover.
|
||||
public bool HitTest(Point point) => new Rect(Bounds.Size).Contains(point);
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
var width = Bounds.Width;
|
||||
var height = Bounds.Height;
|
||||
if (width <= 0 || height <= 0) return;
|
||||
|
||||
var top = Math.Max(0, (height - TrackHeightPx) / 2);
|
||||
var trackHeight = Math.Min(TrackHeightPx, height);
|
||||
var radius = trackHeight / 2;
|
||||
|
||||
// Transparent full-bounds fill keeps the grab area the whole control, not just the track.
|
||||
context.FillRectangle(Brushes.Transparent, new Rect(0, 0, width, height));
|
||||
|
||||
if (TrackBrush is { } track)
|
||||
context.DrawRectangle(track, null, new RoundedRect(new Rect(0, top, width, trackHeight), radius));
|
||||
|
||||
var fillWidth = width * Math.Clamp(Percent, 0, 100) / 100.0;
|
||||
var fill = IsWarn ? WarnFillBrush ?? FillBrush : FillBrush;
|
||||
if (fillWidth > 0 && fill is not null)
|
||||
context.DrawRectangle(fill, null, new RoundedRect(new Rect(0, top, fillWidth, trackHeight), radius));
|
||||
|
||||
DrawMarker(context, SoftPct, SoftMarkerBrush, width, height);
|
||||
DrawMarker(context, HardPct, HardMarkerBrush, width, height);
|
||||
DrawMarker(context, GatePct, GateMarkerBrush, width, height);
|
||||
}
|
||||
|
||||
private static void DrawMarker(DrawingContext context, int? percent, IBrush? brush, double width, double height)
|
||||
{
|
||||
if (percent is not { } value || brush is null) return;
|
||||
|
||||
var x = Math.Clamp(width * Math.Clamp(value, 0, 100) / 100.0 - MarkerWidthPx / 2, 0, Math.Max(0, width - MarkerWidthPx));
|
||||
context.FillRectangle(brush, new Rect(x, 0, MarkerWidthPx, height));
|
||||
}
|
||||
|
||||
protected override void OnPointerPressed(PointerPressedEventArgs e)
|
||||
{
|
||||
base.OnPointerPressed(e);
|
||||
if (!IsAdjustable) return;
|
||||
|
||||
var percent = PercentAt(e.GetPosition(this).X);
|
||||
_dragging = UsageThresholdDrag.Nearest(
|
||||
SoftPct!.Value, HardPct!.Value, GatePct!.Value, percent, GrabTolerancePercent());
|
||||
if (_dragging is null) return;
|
||||
|
||||
e.Pointer.Capture(this);
|
||||
ApplyDrag(_dragging.Value, percent);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
protected override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
base.OnPointerMoved(e);
|
||||
if (!IsAdjustable) return;
|
||||
|
||||
var percent = PercentAt(e.GetPosition(this).X);
|
||||
|
||||
if (_dragging is { } stage)
|
||||
{
|
||||
ApplyDrag(stage, percent);
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var hover = UsageThresholdDrag.Nearest(
|
||||
SoftPct!.Value, HardPct!.Value, GatePct!.Value, percent, GrabTolerancePercent());
|
||||
Cursor = new Cursor(hover is null ? StandardCursorType.Arrow : StandardCursorType.SizeWestEast);
|
||||
}
|
||||
|
||||
protected override void OnPointerReleased(PointerReleasedEventArgs e)
|
||||
{
|
||||
base.OnPointerReleased(e);
|
||||
if (_dragging is null) return;
|
||||
|
||||
_dragging = null;
|
||||
e.Pointer.Capture(null);
|
||||
e.Handled = true;
|
||||
|
||||
if (CommitCommand is { } command && command.CanExecute(null))
|
||||
command.Execute(null);
|
||||
}
|
||||
|
||||
private void ApplyDrag(UsageThresholdDrag.Stage stage, double percent)
|
||||
{
|
||||
var (soft, hard, gate) = UsageThresholdDrag.Apply(
|
||||
SoftPct!.Value, HardPct!.Value, GatePct!.Value, stage, percent);
|
||||
|
||||
SoftPct = soft;
|
||||
HardPct = hard;
|
||||
GatePct = gate;
|
||||
}
|
||||
|
||||
private double PercentAt(double x) => Bounds.Width <= 0 ? 0 : Math.Clamp(x / Bounds.Width * 100.0, 0, 100);
|
||||
|
||||
private double GrabTolerancePercent() => Bounds.Width <= 0 ? 0 : GrabRadiusPx / Bounds.Width * 100.0;
|
||||
}
|
||||
@@ -390,7 +390,8 @@
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="{loc:Tr settings.onlineInbox.tabHeader}">
|
||||
<TabItem Header="{loc:Tr settings.onlineInbox.tabHeader}"
|
||||
IsVisible="{Binding ShowOnlineInbox}">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="14" Margin="0,8,0,0">
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ public partial class SettingsModalView : Window
|
||||
public SettingsModalView()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
}
|
||||
|
||||
protected override void OnDataContextChanged(EventArgs e)
|
||||
|
||||
@@ -18,19 +18,6 @@
|
||||
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
|
||||
</Window.KeyBindings>
|
||||
|
||||
<Window.Styles>
|
||||
<Style Selector="ProgressBar.usage-gauge">
|
||||
<Setter Property="Height" Value="10"/>
|
||||
<Setter Property="CornerRadius" Value="4"/>
|
||||
<Setter Property="Minimum" Value="0"/>
|
||||
<Setter Property="Maximum" Value="100"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource AccentBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="ProgressBar.usage-gauge.warn">
|
||||
<Setter Property="Foreground" Value="{DynamicResource StatusReviewBrush}"/>
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<ctl:ModalShell Title="{loc:Tr modals.usageMonitor.title}" CloseCommand="{Binding CloseCommand}">
|
||||
<DockPanel>
|
||||
|
||||
@@ -82,19 +69,60 @@
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:UsageGaugeRowViewModel">
|
||||
<Border Background="{DynamicResource DeepBrush}" BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="1" CornerRadius="6" Padding="12,10" Margin="0,0,10,10" Width="240">
|
||||
BorderThickness="1" CornerRadius="6" Padding="12,10" Margin="0,0,10,10" Width="270">
|
||||
<StackPanel Spacing="6">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Classes="eyebrow" Text="{Binding Label}"/>
|
||||
<TextBlock Classes="meta" Text="{Binding Percent, StringFormat={}{0:0}%}" HorizontalAlignment="Right"/>
|
||||
</StackPanel>
|
||||
<Grid Height="10">
|
||||
<ProgressBar Classes="usage-gauge" Classes.warn="{Binding IsWarnSeverity}" Value="{Binding Percent}"/>
|
||||
<Canvas IsHitTestVisible="False">
|
||||
<Rectangle Canvas.Left="{Binding ThresholdMarkerLeftPx}" Width="2" Height="10"
|
||||
Fill="{DynamicResource TextDimBrush}"
|
||||
IsVisible="{Binding ThresholdPercent, Converter={x:Static conv:ObjectConverters.IsNotNull}}"/>
|
||||
</Canvas>
|
||||
<ctl:UsageGaugeBar Height="16"
|
||||
Percent="{Binding Percent}"
|
||||
IsWarn="{Binding IsWarnSeverity}"
|
||||
SoftPct="{Binding SoftPct, Mode=TwoWay}"
|
||||
HardPct="{Binding HardPct, Mode=TwoWay}"
|
||||
GatePct="{Binding GatePct, Mode=TwoWay}"
|
||||
CommitCommand="{Binding CommitCommand}"
|
||||
TrackBrush="{DynamicResource LineBrush}"
|
||||
FillBrush="{DynamicResource AccentBrush}"
|
||||
WarnFillBrush="{DynamicResource StatusReviewBrush}"
|
||||
SoftMarkerBrush="{DynamicResource TextDimBrush}"
|
||||
HardMarkerBrush="{DynamicResource StatusReviewBrush}"
|
||||
GateMarkerBrush="{DynamicResource StatusErrorBrush}"
|
||||
ToolTip.Tip="{loc:Tr modals.usageMonitor.dragHint}"/>
|
||||
|
||||
<!-- Legend doubles as the numeric editor: swatch colours match the bar's markers,
|
||||
and each box commits on Enter / focus loss (handlers in the code-behind). -->
|
||||
<Grid ColumnDefinitions="10,*,62" RowDefinitions="Auto,Auto,Auto"
|
||||
IsVisible="{Binding IsAdjustable}" Margin="0,2,0,0">
|
||||
<Rectangle Grid.Row="0" Grid.Column="0" Width="10" Height="3" RadiusX="1.5" RadiusY="1.5"
|
||||
VerticalAlignment="Center" Fill="{DynamicResource TextDimBrush}"/>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" Classes="meta" Margin="6,0,4,0"
|
||||
VerticalAlignment="Center" Text="{loc:Tr modals.usageMonitor.legendSoft}"/>
|
||||
<NumericUpDown Grid.Row="0" Grid.Column="2" Tag="soft"
|
||||
Value="{Binding SoftPct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
|
||||
Minimum="0" Maximum="100" Increment="5" FormatString="0"
|
||||
ShowButtonSpinner="False" FontSize="12" Padding="6,2"
|
||||
LostFocus="OnStageBoxCommit" KeyDown="OnStageBoxKeyDown"/>
|
||||
|
||||
<Rectangle Grid.Row="1" Grid.Column="0" Width="10" Height="3" RadiusX="1.5" RadiusY="1.5"
|
||||
VerticalAlignment="Center" Fill="{DynamicResource StatusReviewBrush}"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="1" Classes="meta" Margin="6,0,4,0"
|
||||
VerticalAlignment="Center" Text="{loc:Tr modals.usageMonitor.legendHard}"/>
|
||||
<NumericUpDown Grid.Row="1" Grid.Column="2" Tag="hard"
|
||||
Value="{Binding HardPct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
|
||||
Minimum="0" Maximum="100" Increment="5" FormatString="0"
|
||||
ShowButtonSpinner="False" FontSize="12" Padding="6,2"
|
||||
LostFocus="OnStageBoxCommit" KeyDown="OnStageBoxKeyDown"/>
|
||||
|
||||
<Rectangle Grid.Row="2" Grid.Column="0" Width="10" Height="3" RadiusX="1.5" RadiusY="1.5"
|
||||
VerticalAlignment="Center" Fill="{DynamicResource StatusErrorBrush}"/>
|
||||
<TextBlock Grid.Row="2" Grid.Column="1" Classes="meta" Margin="6,0,4,0"
|
||||
VerticalAlignment="Center" Text="{loc:Tr modals.usageMonitor.legendGate}"/>
|
||||
<NumericUpDown Grid.Row="2" Grid.Column="2" Tag="gate"
|
||||
Value="{Binding GatePct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
|
||||
Minimum="0" Maximum="100" Increment="5" FormatString="0"
|
||||
ShowButtonSpinner="False" FontSize="12" Padding="6,2"
|
||||
LostFocus="OnStageBoxCommit" KeyDown="OnStageBoxKeyDown"/>
|
||||
</Grid>
|
||||
<TextBlock Classes="meta" Text="{Binding ResetText}" IsVisible="{Binding ResetText, Converter={x:Static conv:StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
|
||||
@@ -1,8 +1,36 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Modals;
|
||||
|
||||
public partial class UsageMonitorModalView : Window
|
||||
{
|
||||
public UsageMonitorModalView() => InitializeComponent();
|
||||
|
||||
/// <summary>
|
||||
/// Persists a stage typed into a gauge's legend box. `NumericUpDown` has no commit command, so
|
||||
/// the box's <c>Tag</c> names the stage and the row's matching command does the clamp + save.
|
||||
/// </summary>
|
||||
private void OnStageBoxCommit(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not Control { Tag: string stage, DataContext: UsageGaugeRowViewModel row }) return;
|
||||
|
||||
var command = stage switch
|
||||
{
|
||||
"soft" => row.CommitSoftCommand,
|
||||
"hard" => row.CommitHardCommand,
|
||||
_ => row.CommitGateCommand,
|
||||
};
|
||||
|
||||
if (command.CanExecute(null)) command.Execute(null);
|
||||
}
|
||||
|
||||
private void OnStageBoxKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key != Key.Enter) return;
|
||||
OnStageBoxCommit(sender, e);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,9 @@ public sealed class WindowDialogService : IDialogService
|
||||
{
|
||||
var dlg = new UsageMonitorModalView { DataContext = vm };
|
||||
vm.CloseAction = () => dlg.Close();
|
||||
await dlg.ShowDialog(_owner);
|
||||
// The pill sits in both the footer and the Mission Control header, so own the dialog to
|
||||
// whichever window is active — otherwise it opens behind Mission Control.
|
||||
await dlg.ShowDialog(ActiveOwner());
|
||||
}
|
||||
|
||||
public async Task ShowSettingsAsync(SettingsModalViewModel vm)
|
||||
|
||||
Reference in New Issue
Block a user