feat(ui): add usage pill to footer and mission control header
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.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user