Adds the IWorkerClient/WorkerClient usage surface (GetUsageSnapshot, GetModelUsage, GetTaskUsage, UsageUpdated event) and a shared UsagePillViewModel hosted once in IslandsShellViewModel (footer) and once in MissionControlViewModel (header), showing "5h X% · 7d Y%" with warn/blocked/stale states via existing design tokens. The OpenMonitorCommand is wired but currently a no-op, pending the usage monitor modal.
120 lines
4.6 KiB
C#
120 lines
4.6 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using ClaudeDo.Ui.Localization;
|
|
using ClaudeDo.Ui.Services;
|
|
|
|
namespace ClaudeDo.Ui.ViewModels;
|
|
|
|
/// <summary>Backs the footer/Mission-Control-header usage pill. One instance is shared by both hosts.</summary>
|
|
public sealed partial class UsagePillViewModel : ViewModelBase
|
|
{
|
|
private readonly IWorkerClient _worker;
|
|
|
|
[ObservableProperty]
|
|
private UsageSnapshotDto? _snapshot;
|
|
|
|
/// <summary>Host wires this to open the usage-monitor modal (not built yet — the command is a no-op until then).</summary>
|
|
public event Action? OpenMonitorRequested;
|
|
|
|
public UsagePillViewModel(IWorkerClient worker)
|
|
{
|
|
_worker = worker;
|
|
_worker.UsageUpdatedEvent += OnUsageUpdated;
|
|
_ = LoadAsync();
|
|
}
|
|
|
|
private async System.Threading.Tasks.Task LoadAsync()
|
|
{
|
|
var snapshot = await _worker.GetUsageSnapshotAsync();
|
|
if (snapshot is not null)
|
|
Snapshot = snapshot;
|
|
}
|
|
|
|
private void OnUsageUpdated(UsageSnapshotDto snapshot) => Snapshot = snapshot;
|
|
|
|
partial void OnSnapshotChanged(UsageSnapshotDto? value)
|
|
{
|
|
OnPropertyChanged(nameof(Text));
|
|
OnPropertyChanged(nameof(IsWarn));
|
|
OnPropertyChanged(nameof(IsBlocked));
|
|
OnPropertyChanged(nameof(IsStale));
|
|
OnPropertyChanged(nameof(Tooltip));
|
|
OnPropertyChanged(nameof(ShowNormalDot));
|
|
OnPropertyChanged(nameof(ShowWarnDot));
|
|
OnPropertyChanged(nameof(ShowStaleDot));
|
|
}
|
|
|
|
public string Text => BuildText(Snapshot);
|
|
|
|
public bool IsBlocked => Snapshot?.IsGateBlocked == true;
|
|
|
|
public bool IsStale => Snapshot?.IsStale == true;
|
|
|
|
public bool IsWarn => Snapshot is { } s &&
|
|
((s.FiveHourPercent is { } five && five >= s.FiveHourThresholdPct - 10) ||
|
|
(s.SevenDayPercent is { } seven && seven >= s.SevenDayThresholdPct - 10));
|
|
|
|
// Visual dot priority: blocked > stale > warn > normal — mirrors the connection pill's
|
|
// mutually-exclusive dot Ellipses, since Blocked/Stale/Warn aren't mutually exclusive states.
|
|
public bool ShowNormalDot => !IsBlocked && !IsStale && !IsWarn;
|
|
public bool ShowWarnDot => IsWarn && !IsBlocked && !IsStale;
|
|
public bool ShowStaleDot => IsStale && !IsBlocked;
|
|
|
|
public string Tooltip => BuildTooltip(Snapshot);
|
|
|
|
[RelayCommand]
|
|
private void OpenMonitor() => OpenMonitorRequested?.Invoke();
|
|
|
|
private static string BuildText(UsageSnapshotDto? s)
|
|
{
|
|
if (s is null) return Loc.T("usage.pill.empty");
|
|
|
|
var five = s.FiveHourPercent;
|
|
var seven = s.SevenDayPercent;
|
|
if (five is not null && seven is not null)
|
|
return Loc.T("usage.pill.bothFormat", FormatPct(five.Value), FormatPct(seven.Value));
|
|
if (five is not null)
|
|
return Loc.T("usage.pill.fiveHourFormat", FormatPct(five.Value));
|
|
if (seven is not null)
|
|
return Loc.T("usage.pill.sevenDayFormat", FormatPct(seven.Value));
|
|
return Loc.T("usage.pill.empty");
|
|
}
|
|
|
|
private static string FormatPct(double v) => Math.Round(v).ToString("0");
|
|
|
|
private static string BuildTooltip(UsageSnapshotDto? s)
|
|
{
|
|
if (s is null) return Loc.T("usage.pill.empty");
|
|
|
|
var lines = new List<string>();
|
|
if (s.FiveHourResetsAt is { } fiveReset)
|
|
lines.Add(Loc.T("usage.pill.resetIn", Loc.T("usage.pill.fiveHourLabel"), FormatRemaining(fiveReset)));
|
|
if (s.SevenDayResetsAt is { } sevenReset)
|
|
lines.Add(Loc.T("usage.pill.resetIn", Loc.T("usage.pill.sevenDayLabel"), FormatRemaining(sevenReset)));
|
|
if (s.IsGateBlocked && !string.IsNullOrEmpty(s.GateReason))
|
|
lines.Add(Loc.T("usage.pill.blockedReason", s.GateReason));
|
|
if (s.IsStale)
|
|
{
|
|
var stamp = s.FetchedAtUtc?.ToLocalTime().ToString("HH:mm") ?? "?";
|
|
lines.Add(Loc.T("usage.pill.stale", stamp));
|
|
if (!string.IsNullOrEmpty(s.LastError))
|
|
lines.Add(Loc.T("usage.pill.lastError", s.LastError));
|
|
}
|
|
|
|
return lines.Count > 0 ? string.Join(Environment.NewLine, lines) : Loc.T("usage.pill.empty");
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|