diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs index 4f3a8414..0a9268fc 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs @@ -44,15 +44,57 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase [ObservableProperty] [NotifyPropertyChangedFor(nameof(ModelsEmpty))] + [NotifyPropertyChangedFor(nameof(TotalsText))] private IReadOnlyList _modelRows = Array.Empty(); [ObservableProperty] [NotifyPropertyChangedFor(nameof(TasksEmpty))] private IReadOnlyList _taskRows = Array.Empty(); + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(TokenTrackerMissing))] + [NotifyPropertyChangedFor(nameof(ShowInstallButton))] + [NotifyPropertyChangedFor(nameof(NodeHintText))] + [NotifyPropertyChangedFor(nameof(TokenTrackerErrorText))] + [NotifyPropertyChangedFor(nameof(HasTokenTrackerError))] + [NotifyPropertyChangedFor(nameof(AnalyticsStampText))] + private TokenTrackerStatusDto? _tokenTracker; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(TotalsText))] + private double _totalCostUsd; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowInstallButton))] + private bool _isInstalling; + public bool ModelsEmpty => !IsBusy && ModelRows.Count == 0; public bool TasksEmpty => !IsBusy && TaskRows.Count == 0; + public bool TokenTrackerMissing => TokenTracker is { Installed: false }; + public bool ShowInstallButton => TokenTracker is { Installed: false, NodeOk: true } && !IsInstalling; + + public string NodeHintText => TokenTracker switch + { + { NodeOk: true } => "", + { NodeVersion: { Length: > 0 } v } => Loc.T("modals.usageMonitor.ttNodeHintFormat", v), + _ => Loc.T("modals.usageMonitor.ttNodeMissing"), + }; + + public bool HasTokenTrackerError => TokenTracker?.LastError is { Length: > 0 }; + public string TokenTrackerErrorText => TokenTracker?.LastError is { Length: > 0 } e + ? Loc.T("modals.usageMonitor.ttErrorFormat", e) + : ""; + + public string AnalyticsStampText => TokenTracker?.LastFetchedUtc is { } t + ? Loc.T("modals.usageMonitor.analyticsStampFormat", t.ToLocalTime().ToString("HH:mm")) + : Loc.T("modals.usageMonitor.analyticsNever"); + + public string TotalsText => Loc.T( + "modals.usageMonitor.analyticsTotalsFormat", + ModelRows.Sum(r => r.GrandTotal).ToString("N0"), + TotalCostUsd.ToString("0.00")); + [ObservableProperty] private IReadOnlyList _gaugeRows = Array.Empty(); @@ -154,6 +196,39 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase [RelayCommand] private Task SetPreset30Days() => SetPreset(30); + [RelayCommand] + private async Task RefreshAnalytics() + { + try + { + TokenTracker = await _worker.RefreshTokenTrackerAsync(); + await LoadUsageDataAsync(); + } + catch (Exception ex) + { + ErrorReported?.Invoke(Loc.T("vm.usageMonitor.analyticsRefreshFailed", ex.Message)); + } + } + + // ErrorReported is the channel the shell hangs FlashFooterError on, so even the "install + // running" notice goes through it rather than getting its own banner. + [RelayCommand] + private async Task InstallTokenTracker() + { + IsInstalling = true; + ErrorReported?.Invoke(Loc.T("vm.usageMonitor.installStarted")); + try + { + TokenTracker = await _worker.InstallTokenTrackerAsync(); + if (TokenTracker is { Installed: true }) await RefreshAnalytics(); + } + catch (Exception ex) + { + ErrorReported?.Invoke(Loc.T("vm.usageMonitor.installFailed", ex.Message)); + } + finally { IsInstalling = false; } + } + private Task SetPreset(int days) { ApplyPresetRange(days); @@ -183,10 +258,14 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase var modelRows = await _worker.GetModelUsageAsync(from, to); var taskRows = await _worker.GetTaskUsageAsync(from, to); ModelRows = BuildModelDisplayRows(modelRows); + TotalCostUsd = modelRows.Sum(r => r.CostUsd ?? 0); TaskRows = taskRows - .Select(r => new TaskUsageDisplayRow(r.TaskId, r.TaskTitle, r.ListName, r.Model, r.Runs, r.TokensIn, r.TokensOut)) + .Select(r => new TaskUsageDisplayRow( + r.TaskId, r.TaskTitle, r.ListName, r.Model, r.Runs, r.TokensIn, r.TokensOut, + r.CostUsd, r.Retries)) .OrderByDescending(r => r.TotalTokens) .ToList(); + TokenTracker = await _worker.GetTokenTrackerStatusAsync(); } catch (Exception ex) { @@ -303,9 +382,11 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase foreach (var group in rows.GroupBy(r => r.Model)) { long cdIn = 0, cdOut = 0, cdCache = 0, otIn = 0, otOut = 0, otCache = 0; + double cost = 0; foreach (var row in group) { var cache = row.CacheReadTokens + row.CacheCreationTokens; + cost += row.CostUsd ?? 0; if (string.Equals(row.Scope, "ClaudeDo", StringComparison.OrdinalIgnoreCase)) { cdIn += row.InputTokens; cdOut += row.OutputTokens; cdCache += cache; @@ -315,7 +396,7 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase otIn += row.InputTokens; otOut += row.OutputTokens; otCache += cache; } } - built.Add(new ModelUsageDisplayRow(group.Key, cdIn, cdOut, cdCache, otIn, otOut, otCache)); + built.Add(new ModelUsageDisplayRow(group.Key, cdIn, cdOut, cdCache, otIn, otOut, otCache, cost)); } var grandTotal = built.Sum(r => r.GrandTotal); @@ -496,12 +577,14 @@ public sealed record ModelUsageDisplayRow( long ClaudeDoCacheTokens, long OtherInputTokens, long OtherOutputTokens, - long OtherCacheTokens) + long OtherCacheTokens, + double CostUsd) { public double SharePercent { get; init; } public long ClaudeDoTotal => ClaudeDoInputTokens + ClaudeDoOutputTokens + ClaudeDoCacheTokens; public long OtherTotal => OtherInputTokens + OtherOutputTokens + OtherCacheTokens; public long GrandTotal => ClaudeDoTotal + OtherTotal; + public string CostText => CostUsd > 0 ? CostUsd.ToString("0.00") : "—"; } public sealed record TaskUsageDisplayRow( @@ -511,7 +594,11 @@ public sealed record TaskUsageDisplayRow( string? Model, int Runs, long TokensIn, - long TokensOut) + long TokensOut, + double? CostUsd, + int? Retries) { public long TotalTokens => TokensIn + TokensOut; + public string CostText => CostUsd is > 0 ? CostUsd.Value.ToString("0.00") : "—"; + public string RetriesText => Retries?.ToString() ?? "—"; }