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:
mika kuns
2026-08-05 13:16:29 +02:00
parent 6e2158d157
commit 7cbd4e66ae
16 changed files with 414 additions and 4 deletions
@@ -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);
}
}