From 7cbd4e66aeea01725ba6291f816133112ccf62f2 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 5 Aug 2026 13:16:29 +0200 Subject: [PATCH] feat(ui): add usage pill to footer and mission control header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/ClaudeDo.App/Program.cs | 5 +- src/ClaudeDo.Localization/locales/de.json | 16 +++ src/ClaudeDo.Localization/locales/en.json | 16 +++ .../Services/Interfaces/IWorkerClient.cs | 6 + src/ClaudeDo.Ui/Services/WorkerClient.cs | 57 ++++++++ .../ViewModels/IslandsShellViewModel.cs | 5 +- .../ViewModels/MissionControlViewModel.cs | 5 +- .../ViewModels/UsagePillViewModel.cs | 119 ++++++++++++++++ .../Views/Controls/UsagePill.axaml | 25 ++++ .../Views/Controls/UsagePill.axaml.cs | 8 ++ src/ClaudeDo.Ui/Views/MainWindow.axaml | 7 + .../MissionControl/MissionControlView.axaml | 4 + tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs | 8 ++ .../MissionControlViewModelTests.cs | 2 +- .../ViewModels/UsagePillViewModelTests.cs | 128 ++++++++++++++++++ .../UiVm/TasksIslandViewModelPlanningTests.cs | 7 + 16 files changed, 414 insertions(+), 4 deletions(-) create mode 100644 src/ClaudeDo.Ui/ViewModels/UsagePillViewModel.cs create mode 100644 src/ClaudeDo.Ui/Views/Controls/UsagePill.axaml create mode 100644 src/ClaudeDo.Ui/Views/Controls/UsagePill.axaml.cs create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/UsagePillViewModelTests.cs diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index 2fe3d046..7037ff49 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -159,10 +159,13 @@ sealed class Program sp, sp.GetRequiredService(), sp.GetRequiredService())); + sc.AddSingleton(sp => + new UsagePillViewModel(sp.GetRequiredService())); sc.AddSingleton(sp => new MissionControlViewModel( sp.GetRequiredService>(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService())); sc.AddSingleton(sp => { var shell = ActivatorUtilities.CreateInstance(sp); diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 89587489..e066f820 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -562,5 +562,21 @@ "listSettings": { "untitled": "Unbenannt" }, "detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt." }, "lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" } + }, + "usage": { + "pill": { + "empty": "Nutzung –", + "bothFormat": "5h {0}% · 7d {1}%", + "fiveHourFormat": "5h {0}%", + "sevenDayFormat": "7d {0}%", + "fiveHourLabel": "5h", + "sevenDayLabel": "7d", + "resetIn": "{0}: Reset in {1}", + "durationHoursMinutes": "{0} h {1} m", + "durationMinutes": "{0} m", + "blockedReason": "Blockiert: {0}", + "stale": "veraltet (Stand {0})", + "lastError": "Letzter Fehler: {0}" + } } } diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index c9a572ae..f0efd074 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -562,5 +562,21 @@ "listSettings": { "untitled": "Untitled" }, "detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done." }, "lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" } + }, + "usage": { + "pill": { + "empty": "Usage –", + "bothFormat": "5h {0}% · 7d {1}%", + "fiveHourFormat": "5h {0}%", + "sevenDayFormat": "7d {0}%", + "fiveHourLabel": "5h", + "sevenDayLabel": "7d", + "resetIn": "{0}: Reset in {1}", + "durationHoursMinutes": "{0} h {1} m", + "durationMinutes": "{0} m", + "blockedReason": "Blocked: {0}", + "stale": "stale (as of {0})", + "lastError": "Last error: {0}" + } } } diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index 3b1e4f7f..cd9995a7 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -139,4 +139,10 @@ public interface IWorkerClient : INotifyPropertyChanged Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input); Task SetOnlineInboxAuthAsync(string refreshToken); Task ClearOnlineInboxAuthAsync(); + + /// Raised whenever the worker's usage poller ticks (success or failure). + event Action? UsageUpdatedEvent; + Task GetUsageSnapshotAsync(); + Task> GetModelUsageAsync(DateOnly from, DateOnly to); + Task> GetTaskUsageAsync(DateOnly from, DateOnly to); } diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 375c612b..2e5e8095 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -61,6 +61,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public event Action? RefineStartedEvent; public event Action? RefineFinishedEvent; + public event Action? UsageUpdatedEvent; + public event Action? PlanningMergeStartedEvent; public event Action? PlanningSubtaskMergedEvent; public event Action>? PlanningMergeConflictEvent; @@ -202,6 +204,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC Dispatcher.UIThread.Post(() => RefineStartedEvent?.Invoke(id))); _hub.On("RefineFinished", (id, ok, err) => Dispatcher.UIThread.Post(() => RefineFinishedEvent?.Invoke(id, ok, err))); + + _hub.On("UsageUpdated", snapshot => + Dispatcher.UIThread.Post(() => UsageUpdatedEvent?.Invoke(snapshot))); } public Task StartAsync() @@ -574,6 +579,15 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public async Task ClearOnlineInboxAuthAsync() => await _hub.InvokeAsync("ClearOnlineInboxAuth"); + public Task GetUsageSnapshotAsync() + => TryInvokeAsync("GetUsageSnapshot"); + + public async Task> GetModelUsageAsync(DateOnly from, DateOnly to) + => await TryInvokeAsync>("GetModelUsage", from, to) ?? []; + + public async Task> GetTaskUsageAsync(DateOnly from, DateOnly to) + => await TryInvokeAsync>("GetTaskUsage", from, to) ?? []; + // IWorkerClient explicit implementations (drop typed return values) async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct) => await StartPlanningSessionAsync(taskId, ct); @@ -676,3 +690,46 @@ public sealed record OnlineInboxConfigInputDto( string ClientId, string Scopes, string RedirectUri); + +public sealed record UsageLimitDto( + string Kind, + string Group, + double Percent, + string Severity, + DateTimeOffset? ResetsAt, + string? ScopeModelDisplayName, + bool IsActive); + +public sealed record UsageSnapshotDto( + double? FiveHourPercent, + DateTimeOffset? FiveHourResetsAt, + double? SevenDayPercent, + DateTimeOffset? SevenDayResetsAt, + IReadOnlyList Limits, + int FiveHourThresholdPct, + int SevenDayThresholdPct, + bool IsGateBlocked, + string? GateReason, + DateTime? FetchedAtUtc, + bool IsStale, + string? LastError); + +public sealed record ModelUsageRowDto( + DateOnly Date, + string Model, + string Scope, + long InputTokens, + long OutputTokens, + long CacheReadTokens, + long CacheCreationTokens, + int Messages); + +public sealed record TaskUsageRowDto( + string TaskId, + string TaskTitle, + string ListId, + string ListName, + string? Model, + int Runs, + long TokensIn, + long TokensOut); diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index 7f689ffa..070c7d50 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -21,6 +21,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable public DetailsIslandViewModel? Details { get; } public IWorkerClient? Worker { get; } public MissionControlViewModel? MissionControl { get; } + public UsagePillViewModel? UsagePill { get; } public UpdateCheckService UpdateCheck => _updateCheck; public string ConnectionText => @@ -208,10 +209,12 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable Func weeklyReportVmFactory, Func mergeVmFactory, Func repoImportVmFactory, - MissionControlViewModel missionControl) + MissionControlViewModel missionControl, + UsagePillViewModel usagePill) { Lists = lists; Tasks = tasks; Details = details; Worker = worker; MissionControl = missionControl; + UsagePill = usagePill; MissionControl.OpenInApp = id => _ = RevealTaskAsync(id); MissionControl.OpenSettingsRequested = () => Lists.OpenSettingsCommand.Execute(null); MissionControl.ErrorReported += FlashFooterError; diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index 1a8bc9c4..79d72ddb 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -54,10 +54,13 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable public ObservableCollection Queued { get; } = new(); public bool HasQueued => Queued.Count > 0; - public MissionControlViewModel(IDbContextFactory dbFactory, IWorkerClient worker) + public UsagePillViewModel UsagePill { get; } + + public MissionControlViewModel(IDbContextFactory dbFactory, IWorkerClient worker, UsagePillViewModel usagePill) { _dbFactory = dbFactory; _worker = worker; + UsagePill = usagePill; ConPtySessions.CollectionChanged += OnConPtySessionsChanged; Panes.CollectionChanged += OnPanesChanged; diff --git a/src/ClaudeDo.Ui/ViewModels/UsagePillViewModel.cs b/src/ClaudeDo.Ui/ViewModels/UsagePillViewModel.cs new file mode 100644 index 00000000..6f963636 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/UsagePillViewModel.cs @@ -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; + +/// Backs the footer/Mission-Control-header usage pill. One instance is shared by both hosts. +public sealed partial class UsagePillViewModel : ViewModelBase +{ + private readonly IWorkerClient _worker; + + [ObservableProperty] + private UsageSnapshotDto? _snapshot; + + /// Host wires this to open the usage-monitor modal (not built yet — the command is a no-op until then). + 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(); + 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); + } +} diff --git a/src/ClaudeDo.Ui/Views/Controls/UsagePill.axaml b/src/ClaudeDo.Ui/Views/Controls/UsagePill.axaml new file mode 100644 index 00000000..31b11c8d --- /dev/null +++ b/src/ClaudeDo.Ui/Views/Controls/UsagePill.axaml @@ -0,0 +1,25 @@ + + + diff --git a/src/ClaudeDo.Ui/Views/Controls/UsagePill.axaml.cs b/src/ClaudeDo.Ui/Views/Controls/UsagePill.axaml.cs new file mode 100644 index 00000000..9a392fd0 --- /dev/null +++ b/src/ClaudeDo.Ui/Views/Controls/UsagePill.axaml.cs @@ -0,0 +1,8 @@ +using Avalonia.Controls; + +namespace ClaudeDo.Ui.Views.Controls; + +public partial class UsagePill : UserControl +{ + public UsagePill() => InitializeComponent(); +} diff --git a/src/ClaudeDo.Ui/Views/MainWindow.axaml b/src/ClaudeDo.Ui/Views/MainWindow.axaml index 7c2f0653..b59d01fd 100644 --- a/src/ClaudeDo.Ui/Views/MainWindow.axaml +++ b/src/ClaudeDo.Ui/Views/MainWindow.axaml @@ -2,6 +2,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ClaudeDo.Ui.ViewModels" xmlns:islands="using:ClaudeDo.Ui.Views.Islands" + xmlns:controls="using:ClaudeDo.Ui.Views.Controls" xmlns:converters="using:ClaudeDo.Ui.Converters" xmlns:loc="using:ClaudeDo.Ui.Localization" x:Class="ClaudeDo.Ui.Views.MainWindow" @@ -184,6 +185,12 @@ + + +