using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace ClaudeDo.Ui.ViewModels.Modals; public sealed partial class UsageMonitorModalViewModel : ViewModelBase { private readonly IWorkerClient _worker; private bool _applyingRange; private bool _isClosed; public UsageMonitorModalViewModel(IWorkerClient worker) => _worker = worker; public Action? CloseAction { get; set; } public event Action? ErrorReported; [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsStale))] [NotifyPropertyChangedFor(nameof(LastError))] [NotifyPropertyChangedFor(nameof(IsGateBlocked))] [NotifyPropertyChangedFor(nameof(GateReason))] [NotifyPropertyChangedFor(nameof(StaleStampText))] [NotifyPropertyChangedFor(nameof(StaleBandText))] [NotifyPropertyChangedFor(nameof(GateBandText))] [NotifyPropertyChangedFor(nameof(IsThrottled))] [NotifyPropertyChangedFor(nameof(ThrottleBandText))] private UsageSnapshotDto? _snapshot; [ObservableProperty] private DateTime? _startDate; [ObservableProperty] private DateTime? _endDate; [ObservableProperty] private int _selectedPresetDays = 7; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ModelsEmpty))] private bool _isBusy; [ObservableProperty] private bool _isRefreshing; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ModelsEmpty))] private IReadOnlyList _modelRows = Array.Empty(); [ObservableProperty] [NotifyPropertyChangedFor(nameof(TasksEmpty))] private IReadOnlyList _taskRows = Array.Empty(); public bool ModelsEmpty => !IsBusy && ModelRows.Count == 0; public bool TasksEmpty => !IsBusy && TaskRows.Count == 0; [ObservableProperty] private IReadOnlyList _gaugeRows = Array.Empty(); public bool IsStale => Snapshot?.IsStale == true; public string? LastError => Snapshot?.LastError; public bool IsGateBlocked => Snapshot?.IsGateBlocked == true; public string? GateReason => Snapshot?.GateReason; public string StaleStampText => Snapshot?.FetchedAtUtc is { } t ? t.ToLocalTime().ToString("HH:mm") : "?"; public string StaleBandText => Loc.T("modals.usageMonitor.staleFormat", StaleStampText); public string GateBandText => GateReason is null ? "" : Loc.T("modals.usageMonitor.gateBlockedFormat", GateReason); public bool IsThrottled => Snapshot is { } s && !IsGateBlocked && s.EffectiveSlots < s.ConfiguredSlots; public string ThrottleBandText => Snapshot is not { } s || !IsThrottled ? "" : Loc.T("modals.usageMonitor.throttleFormat", s.EffectiveSlots, s.ConfiguredSlots, BucketLabel(s.ThrottleBucket)); private static string BucketLabel(string? bucket) => bucket switch { "five_hour" => Loc.T("usage.pill.fiveHourLabel"), "seven_day" => Loc.T("usage.pill.sevenDayLabel"), _ => "", }; [RelayCommand] private void Close() { _isClosed = true; _worker.UsageUpdatedEvent -= OnUsageUpdated; CloseAction?.Invoke(); } /// /// 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 /// ~/.claude/projects (hundreds of MB of transcripts) — awaiting it before showing the /// window made the usage pill look like it swallowed the click. /// public void BeginLoad() => _ = LoadAsync(); public async Task LoadAsync() { IsBusy = true; try { var snapshot = await _worker.GetUsageSnapshotAsync(); // The modal can be closed while the first-load scan is still running (see remarks // above) — subscribing after that would leak this VM onto the long-lived WorkerClient. if (_isClosed) return; Snapshot = snapshot; _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; /// /// Manual refresh: the worker polls usage on a slow cadence (15 min idle / 5 min while a /// task runs) to stay clear of the endpoint's 429s, so this is the way to get a number now. /// [RelayCommand] private async Task RefreshAsync() { if (IsRefreshing) return; IsRefreshing = true; try { var snapshot = await _worker.RefreshUsageAsync(); if (snapshot is not null) Snapshot = snapshot; await LoadUsageDataAsync(); } catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.usageMonitor.refreshFailed", ex.Message)); } finally { IsRefreshing = false; } } private void ApplyPresetRange(int days) { var today = DateOnly.FromDateTime(DateTime.Today); _applyingRange = true; SelectedPresetDays = days; StartDate = today.AddDays(-(days - 1)).ToDateTime(TimeOnly.MinValue); EndDate = today.ToDateTime(TimeOnly.MinValue); _applyingRange = false; } [RelayCommand] private Task SetPreset7Days() => SetPreset(7); [RelayCommand] private Task SetPreset30Days() => SetPreset(30); private Task SetPreset(int days) { ApplyPresetRange(days); return LoadUsageDataAsync(); } partial void OnStartDateChanged(DateTime? value) { if (!_applyingRange) _ = LoadUsageDataAsync(); } partial void OnEndDateChanged(DateTime? value) { if (!_applyingRange) _ = LoadUsageDataAsync(); } private bool RangeValid => StartDate is not null && EndDate is not null && StartDate <= EndDate; private async Task LoadUsageDataAsync() { if (!RangeValid) return; IsBusy = true; try { var from = DateOnly.FromDateTime(StartDate!.Value); var to = DateOnly.FromDateTime(EndDate!.Value); var modelRows = await _worker.GetModelUsageAsync(from, to); var taskRows = await _worker.GetTaskUsageAsync(from, to); ModelRows = BuildModelDisplayRows(modelRows); TaskRows = taskRows .Select(r => new TaskUsageDisplayRow(r.TaskId, r.TaskTitle, r.ListName, r.Model, r.Runs, r.TokensIn, r.TokensOut)) .OrderByDescending(r => r.TotalTokens) .ToList(); } catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.usageMonitor.loadFailed", ex.Message)); } finally { IsBusy = false; } } partial void OnSnapshotChanged(UsageSnapshotDto? value) => SyncGaugeRows(); /// /// 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. /// private void SyncGaugeRows() { var limits = Snapshot?.Limits ?? (IReadOnlyList)Array.Empty(); var existing = GaugeRows.ToDictionary(r => r.Key); var rows = new List(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), }; /// /// 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. /// 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"), "weekly_all" => Loc.T("modals.usageMonitor.gaugeWeeklyAll"), "weekly_scoped" when !string.IsNullOrWhiteSpace(limit.ScopeModelDisplayName) => Loc.T("modals.usageMonitor.gaugeWeeklyScopedFormat", limit.ScopeModelDisplayName!), _ => limit.Kind, }; private static IReadOnlyList BuildModelDisplayRows(IReadOnlyList rows) { var built = new List(); foreach (var group in rows.GroupBy(r => r.Model)) { long cdIn = 0, cdOut = 0, cdCache = 0, otIn = 0, otOut = 0, otCache = 0; foreach (var row in group) { var cache = row.CacheReadTokens + row.CacheCreationTokens; if (string.Equals(row.Scope, "ClaudeDo", StringComparison.OrdinalIgnoreCase)) { cdIn += row.InputTokens; cdOut += row.OutputTokens; cdCache += cache; } else { otIn += row.InputTokens; otOut += row.OutputTokens; otCache += cache; } } built.Add(new ModelUsageDisplayRow(group.Key, cdIn, cdOut, cdCache, otIn, otOut, otCache)); } var grandTotal = built.Sum(r => r.GrandTotal); return built .Select(r => r with { SharePercent = grandTotal > 0 ? r.GrandTotal * 100.0 / grandTotal : 0 }) .OrderByDescending(r => r.GrandTotal) .ToList(); } } /// /// 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. /// public sealed partial class UsageGaugeRowViewModel : ObservableObject { private readonly Func? _commit; public UsageGaugeRowViewModel( string key, string? bucket, string label, double percent, string severity, DateTimeOffset? resetsAt, int? softPct, int? hardPct, int? gatePct, Func? commit = null) { Key = key; Bucket = bucket; _label = label; _percent = percent; _severity = severity; _resetsAt = resetsAt; _softPct = softPct; _hardPct = hardPct; _gatePct = gatePct; _commit = commit; } /// Identity across snapshot updates, so a live poll updates rows instead of replacing them. public string Key { get; } /// Which settings bucket a drag writes to: five_hour, seven_day, or null. 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), r.ToLocalTime().ToString("HH:mm")) : ""; /// Live values from a fresh snapshot, without replacing the row instance mid-view. 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; } /// Raised by the gauge control when a drag ends — that is when the value is persisted. [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) { var remaining = resetsAt - DateTimeOffset.UtcNow; if (remaining < TimeSpan.Zero) remaining = TimeSpan.Zero; var hours = (int)remaining.TotalHours; var minutes = remaining.Minutes; return hours > 0 ? Loc.T("usage.pill.durationHoursMinutes", hours, minutes) : Loc.T("usage.pill.durationMinutes", minutes); } } /// /// 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. /// public static class UsageThresholdDrag { public enum Stage { Soft, Hard, Gate } /// Pointer reach for grabbing a marker, as a share of the bar width. 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, long ClaudeDoOutputTokens, long ClaudeDoCacheTokens, long OtherInputTokens, long OtherOutputTokens, long OtherCacheTokens) { public double SharePercent { get; init; } public long ClaudeDoTotal => ClaudeDoInputTokens + ClaudeDoOutputTokens + ClaudeDoCacheTokens; public long OtherTotal => OtherInputTokens + OtherOutputTokens + OtherCacheTokens; public long GrandTotal => ClaudeDoTotal + OtherTotal; } public sealed record TaskUsageDisplayRow( string TaskId, string TaskTitle, string ListName, string? Model, int Runs, long TokensIn, long TokensOut) { public long TotalTokens => TokensIn + TokensOut; }