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,128 @@
using System;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels;
using Xunit;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class UsagePillViewModelTests
{
private sealed class FakeWorker : StubWorkerClient
{
public UsageSnapshotDto? InitialSnapshot;
public override Task<UsageSnapshotDto?> GetUsageSnapshotAsync() => Task.FromResult(InitialSnapshot);
}
private static UsageSnapshotDto Snapshot(
double? fiveHourPercent = null,
DateTimeOffset? fiveHourResetsAt = null,
double? sevenDayPercent = null,
DateTimeOffset? sevenDayResetsAt = null,
int fiveHourThresholdPct = 80,
int sevenDayThresholdPct = 90,
bool isGateBlocked = false,
string? gateReason = null,
DateTime? fetchedAtUtc = null,
bool isStale = false,
string? lastError = null)
=> new(
fiveHourPercent, fiveHourResetsAt, sevenDayPercent, sevenDayResetsAt,
Array.Empty<UsageLimitDto>(), fiveHourThresholdPct, sevenDayThresholdPct,
isGateBlocked, gateReason, fetchedAtUtc ?? DateTime.UtcNow, isStale, lastError);
// ── Text formatting ─────────────────────────────────────────────────────
[Fact]
public void Text_BothBucketsKnown_ShowsBoth()
{
var worker = new FakeWorker { InitialSnapshot = Snapshot(fiveHourPercent: 30, sevenDayPercent: 57) };
var vm = new UsagePillViewModel(worker);
Assert.Contains("5h", vm.Text);
Assert.Contains("30", vm.Text);
Assert.Contains("7d", vm.Text);
Assert.Contains("57", vm.Text);
}
[Fact]
public void Text_OnlyFiveHourKnown_ShowsOnlyFiveHour()
{
var worker = new FakeWorker { InitialSnapshot = Snapshot(fiveHourPercent: 42) };
var vm = new UsagePillViewModel(worker);
Assert.Contains("5h", vm.Text);
Assert.Contains("42", vm.Text);
Assert.DoesNotContain("7d", vm.Text);
}
[Fact]
public void Text_NoSnapshot_ShowsEmptyPlaceholder()
{
var worker = new FakeWorker();
var vm = new UsagePillViewModel(worker);
Assert.Equal("Usage ", vm.Text);
}
// ── IsWarn / IsBlocked threshold logic ──────────────────────────────────
[Fact]
public void IsWarn_BelowThresholdMinusTen_IsFalse()
{
var worker = new FakeWorker { InitialSnapshot = Snapshot(fiveHourPercent: 69, fiveHourThresholdPct: 80) };
var vm = new UsagePillViewModel(worker);
Assert.False(vm.IsWarn);
Assert.False(vm.IsBlocked);
}
[Fact]
public void IsWarn_AtThresholdMinusTen_IsTrue()
{
var worker = new FakeWorker { InitialSnapshot = Snapshot(fiveHourPercent: 70, fiveHourThresholdPct: 80) };
var vm = new UsagePillViewModel(worker);
Assert.True(vm.IsWarn);
Assert.False(vm.IsBlocked);
}
[Fact]
public void IsBlocked_WhenGateReportsBlocked_IsTrue()
{
var worker = new FakeWorker
{
InitialSnapshot = Snapshot(fiveHourPercent: 80, fiveHourThresholdPct: 80, isGateBlocked: true, gateReason: "5h-Limit 80% >= 80%")
};
var vm = new UsagePillViewModel(worker);
Assert.True(vm.IsBlocked);
}
// ── Stale ────────────────────────────────────────────────────────────────
[Fact]
public void Stale_Snapshot_SetsIsStale_AndTooltipMentionsStamp()
{
var fetchedAt = new DateTime(2026, 8, 5, 9, 41, 0, DateTimeKind.Utc);
var worker = new FakeWorker { InitialSnapshot = Snapshot(isStale: true, fetchedAtUtc: fetchedAt, lastError: "timeout") };
var vm = new UsagePillViewModel(worker);
Assert.True(vm.IsStale);
Assert.Contains(fetchedAt.ToLocalTime().ToString("HH:mm"), vm.Tooltip);
Assert.Contains("timeout", vm.Tooltip);
}
// ── UsageUpdated event ───────────────────────────────────────────────────
[Fact]
public void UsageUpdated_Event_UpdatesText()
{
var worker = new FakeWorker();
var vm = new UsagePillViewModel(worker);
Assert.Equal("Usage ", vm.Text);
worker.RaiseUsageUpdated(Snapshot(fiveHourPercent: 12));
Assert.Contains("12", vm.Text);
}
}