feat(ui): add usage monitor modal with gauges and model/task usage analysis

Adds a Usage Monitor modal (Worker menu + wired to the footer/Mission-Control
usage pill's Open command): dynamic gauges built from UsageSnapshotDto.Limits
with gate-threshold marks, a stale/blocked-gate band, and Models/Tasks tabs
backed by GetModelUsageAsync/GetTaskUsageAsync over a 7d/30d/custom range.
This commit is contained in:
mika kuns
2026-08-05 13:57:21 +02:00
parent 5115cfc288
commit 8103006e26
12 changed files with 800 additions and 0 deletions
@@ -16,6 +16,7 @@ public interface IDialogService
{
Task ShowAboutAsync(AboutModalViewModel vm);
Task ShowWeeklyReportAsync(WeeklyReportModalViewModel vm);
Task ShowUsageMonitorAsync(UsageMonitorModalViewModel vm);
Task ShowSettingsAsync(SettingsModalViewModel vm);
Task ShowListSettingsAsync(ListSettingsModalViewModel vm);
Task ShowRepoImportAsync(RepoImportModalViewModel vm);
@@ -37,6 +37,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!;
private readonly Func<WeeklyReportModalViewModel> _weeklyReportVmFactory = () => null!;
private readonly Func<UsageMonitorModalViewModel> _usageMonitorVmFactory = () => null!;
private readonly Func<MergeModalViewModel> _mergeVmFactory = () => null!;
private readonly Func<RepoImportModalViewModel>? _repoImportVmFactory;
@@ -207,6 +208,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
IDbContextFactory<ClaudeDoDbContext> dbFactory,
Func<WorktreesOverviewModalViewModel> worktreesOverviewVmFactory,
Func<WeeklyReportModalViewModel> weeklyReportVmFactory,
Func<UsageMonitorModalViewModel> usageMonitorVmFactory,
Func<MergeModalViewModel> mergeVmFactory,
Func<RepoImportModalViewModel> repoImportVmFactory,
MissionControlViewModel missionControl,
@@ -215,6 +217,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
MissionControl = missionControl;
UsagePill = usagePill;
UsagePill.OpenMonitorRequested += () => _ = OpenUsageMonitor();
MissionControl.OpenInApp = id => _ = RevealTaskAsync(id);
MissionControl.OpenSettingsRequested = () => Lists.OpenSettingsCommand.Execute(null);
MissionControl.ErrorReported += FlashFooterError;
@@ -226,6 +229,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
_dbFactory = dbFactory;
_worktreesOverviewVmFactory = worktreesOverviewVmFactory;
_weeklyReportVmFactory = weeklyReportVmFactory;
_usageMonitorVmFactory = usageMonitorVmFactory;
_mergeVmFactory = mergeVmFactory;
_repoImportVmFactory = repoImportVmFactory;
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
@@ -429,6 +433,23 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
finally { _weeklyReportOpen = false; }
}
private bool _usageMonitorOpen;
[RelayCommand]
private async Task OpenUsageMonitor()
{
if (Dialogs is null || _usageMonitorOpen) return;
_usageMonitorOpen = true;
try
{
var vm = _usageMonitorVmFactory();
vm.ErrorReported += FlashFooterError;
await vm.LoadAsync();
await Dialogs.ShowUsageMonitorAsync(vm);
}
finally { _usageMonitorOpen = false; }
}
[RelayCommand]
private async Task CheckForUpdatesAsync()
{
@@ -0,0 +1,236 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class UsageMonitorModalViewModel : ViewModelBase
{
private readonly IWorkerClient _worker;
private bool _applyingRange;
public UsageMonitorModalViewModel(IWorkerClient worker) => _worker = worker;
public Action? CloseAction { get; set; }
public event Action<string>? ErrorReported;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(GaugeRows))]
[NotifyPropertyChangedFor(nameof(IsStale))]
[NotifyPropertyChangedFor(nameof(LastError))]
[NotifyPropertyChangedFor(nameof(IsGateBlocked))]
[NotifyPropertyChangedFor(nameof(GateReason))]
[NotifyPropertyChangedFor(nameof(StaleStampText))]
[NotifyPropertyChangedFor(nameof(StaleBandText))]
[NotifyPropertyChangedFor(nameof(GateBandText))]
private UsageSnapshotDto? _snapshot;
[ObservableProperty] private DateTime? _startDate;
[ObservableProperty] private DateTime? _endDate;
[ObservableProperty] private int _selectedPresetDays = 7;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ModelsEmpty))]
private bool _isBusy;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ModelsEmpty))]
private IReadOnlyList<ModelUsageDisplayRow> _modelRows = Array.Empty<ModelUsageDisplayRow>();
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(TasksEmpty))]
private IReadOnlyList<TaskUsageDisplayRow> _taskRows = Array.Empty<TaskUsageDisplayRow>();
public bool ModelsEmpty => !IsBusy && ModelRows.Count == 0;
public bool TasksEmpty => !IsBusy && TaskRows.Count == 0;
public IReadOnlyList<UsageGaugeRowViewModel> GaugeRows =>
Snapshot is null ? Array.Empty<UsageGaugeRowViewModel>() : Snapshot.Limits.Select(BuildGaugeRow).ToList();
public bool IsStale => Snapshot?.IsStale == true;
public string? LastError => Snapshot?.LastError;
public bool IsGateBlocked => Snapshot?.IsGateBlocked == true;
public string? GateReason => Snapshot?.GateReason;
public string StaleStampText => Snapshot?.FetchedAtUtc is { } t ? t.ToLocalTime().ToString("HH:mm") : "?";
public string StaleBandText => Loc.T("modals.usageMonitor.staleFormat", StaleStampText);
public string GateBandText => GateReason is null ? "" : Loc.T("modals.usageMonitor.gateBlockedFormat", GateReason);
[RelayCommand]
private void Close()
{
_worker.UsageUpdatedEvent -= OnUsageUpdated;
CloseAction?.Invoke();
}
public async Task LoadAsync()
{
Snapshot = await _worker.GetUsageSnapshotAsync();
_worker.UsageUpdatedEvent -= OnUsageUpdated;
_worker.UsageUpdatedEvent += OnUsageUpdated;
ApplyPresetRange(SelectedPresetDays);
await LoadUsageDataAsync();
}
private void OnUsageUpdated(UsageSnapshotDto snapshot) => Snapshot = snapshot;
private void ApplyPresetRange(int days)
{
var today = DateOnly.FromDateTime(DateTime.Today);
_applyingRange = true;
SelectedPresetDays = days;
StartDate = today.AddDays(-(days - 1)).ToDateTime(TimeOnly.MinValue);
EndDate = today.ToDateTime(TimeOnly.MinValue);
_applyingRange = false;
}
[RelayCommand]
private Task SetPreset(int days)
{
ApplyPresetRange(days);
return LoadUsageDataAsync();
}
partial void OnStartDateChanged(DateTime? value)
{
if (!_applyingRange) _ = LoadUsageDataAsync();
}
partial void OnEndDateChanged(DateTime? value)
{
if (!_applyingRange) _ = LoadUsageDataAsync();
}
private bool RangeValid => StartDate is not null && EndDate is not null && StartDate <= EndDate;
private async Task LoadUsageDataAsync()
{
if (!RangeValid) return;
IsBusy = true;
try
{
var from = DateOnly.FromDateTime(StartDate!.Value);
var to = DateOnly.FromDateTime(EndDate!.Value);
var modelRows = await _worker.GetModelUsageAsync(from, to);
var taskRows = await _worker.GetTaskUsageAsync(from, to);
ModelRows = BuildModelDisplayRows(modelRows);
TaskRows = taskRows
.Select(r => new TaskUsageDisplayRow(r.TaskId, r.TaskTitle, r.ListName, r.Model, r.Runs, r.TokensIn, r.TokensOut))
.OrderByDescending(r => r.TotalTokens)
.ToList();
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.loadFailed", ex.Message));
}
finally { IsBusy = false; }
}
private static string BuildGaugeLabel(UsageLimitDto limit) => limit.Kind switch
{
"session" => Loc.T("modals.usageMonitor.gaugeSession"),
"weekly_all" => Loc.T("modals.usageMonitor.gaugeWeeklyAll"),
"weekly_scoped" when !string.IsNullOrWhiteSpace(limit.ScopeModelDisplayName)
=> Loc.T("modals.usageMonitor.gaugeWeeklyScopedFormat", limit.ScopeModelDisplayName!),
_ => limit.Kind,
};
private UsageGaugeRowViewModel BuildGaugeRow(UsageLimitDto limit)
{
int? threshold = limit.Kind switch
{
"session" => Snapshot?.FiveHourThresholdPct,
"weekly_all" => Snapshot?.SevenDayThresholdPct,
_ => null,
};
return new UsageGaugeRowViewModel(BuildGaugeLabel(limit), limit.Percent, limit.Severity, limit.ResetsAt, threshold);
}
private static IReadOnlyList<ModelUsageDisplayRow> BuildModelDisplayRows(IReadOnlyList<ModelUsageRowDto> rows)
{
var built = new List<ModelUsageDisplayRow>();
foreach (var group in rows.GroupBy(r => r.Model))
{
long cdIn = 0, cdOut = 0, cdCache = 0, otIn = 0, otOut = 0, otCache = 0;
foreach (var row in group)
{
var cache = row.CacheReadTokens + row.CacheCreationTokens;
if (string.Equals(row.Scope, "ClaudeDo", StringComparison.OrdinalIgnoreCase))
{
cdIn += row.InputTokens; cdOut += row.OutputTokens; cdCache += cache;
}
else
{
otIn += row.InputTokens; otOut += row.OutputTokens; otCache += cache;
}
}
built.Add(new ModelUsageDisplayRow(group.Key, cdIn, cdOut, cdCache, otIn, otOut, otCache));
}
var grandTotal = built.Sum(r => r.GrandTotal);
return built
.Select(r => r with { SharePercent = grandTotal > 0 ? r.GrandTotal * 100.0 / grandTotal : 0 })
.OrderByDescending(r => r.GrandTotal)
.ToList();
}
}
public sealed record UsageGaugeRowViewModel(
string Label,
double Percent,
string Severity,
DateTimeOffset? ResetsAt,
int? ThresholdPercent)
{
public bool IsWarnSeverity => !string.Equals(Severity, "normal", StringComparison.OrdinalIgnoreCase);
public string ResetText => ResetsAt is { } r ? Loc.T("modals.usageMonitor.resetIn", FormatRemaining(r)) : "";
// Matches the gauge card's inner track width in the view (240 card width - 12*2 padding).
private const double GaugeTrackWidthPx = 216;
public double ThresholdMarkerLeftPx =>
ThresholdPercent is { } t ? GaugeTrackWidthPx * Math.Clamp(t, 0, 100) / 100.0 : 0;
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);
}
}
public sealed record ModelUsageDisplayRow(
string Model,
long ClaudeDoInputTokens,
long ClaudeDoOutputTokens,
long ClaudeDoCacheTokens,
long OtherInputTokens,
long OtherOutputTokens,
long OtherCacheTokens)
{
public double SharePercent { get; init; }
public long ClaudeDoTotal => ClaudeDoInputTokens + ClaudeDoOutputTokens + ClaudeDoCacheTokens;
public long OtherTotal => OtherInputTokens + OtherOutputTokens + OtherCacheTokens;
public long GrandTotal => ClaudeDoTotal + OtherTotal;
}
public sealed record TaskUsageDisplayRow(
string TaskId,
string TaskTitle,
string ListName,
string? Model,
int Runs,
long TokensIn,
long TokensOut)
{
public long TotalTokens => TokensIn + TokensOut;
}
+2
View File
@@ -66,6 +66,8 @@
Command="{Binding RestartWorkerCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.checkForUpdates}"
Command="{Binding CheckForUpdatesCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.usageMonitor}"
Command="{Binding OpenUsageMonitorCommand}"/>
</MenuItem>
<MenuItem Header="{loc:Tr shell.menu.repositories}"
FontSize="{StaticResource FontSizeMono}"
@@ -0,0 +1,186 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:loc="using:ClaudeDo.Ui.Localization"
xmlns:conv="using:Avalonia.Data.Converters"
x:Class="ClaudeDo.Ui.Views.Modals.UsageMonitorModalView"
x:DataType="vm:UsageMonitorModalViewModel"
Title="{loc:Tr modals.usageMonitor.windowTitle}"
Width="920" Height="680" MinWidth="700" MinHeight="480"
CanResize="True"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
</Window.KeyBindings>
<Window.Styles>
<Style Selector="ProgressBar.usage-gauge">
<Setter Property="Height" Value="10"/>
<Setter Property="CornerRadius" Value="4"/>
<Setter Property="Minimum" Value="0"/>
<Setter Property="Maximum" Value="100"/>
<Setter Property="Foreground" Value="{DynamicResource AccentBrush}"/>
</Style>
<Style Selector="ProgressBar.usage-gauge.warn">
<Setter Property="Foreground" Value="{DynamicResource StatusReviewBrush}"/>
</Style>
</Window.Styles>
<ctl:ModalShell Title="{loc:Tr modals.usageMonitor.title}" CloseCommand="{Binding CloseCommand}">
<DockPanel>
<!-- Stale / gate bands -->
<StackPanel DockPanel.Dock="Top" Spacing="4" Margin="20,16,20,0">
<Border IsVisible="{Binding IsStale}"
Background="{DynamicResource ReviewTintBrush}"
BorderBrush="{DynamicResource StatusReviewBrush}"
BorderThickness="1" CornerRadius="6" Padding="12,8">
<StackPanel Spacing="2">
<TextBlock Classes="meta" Text="{Binding StaleBandText}"/>
<TextBlock Classes="meta" Text="{Binding LastError}" IsVisible="{Binding LastError, Converter={x:Static conv:StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Classes="meta" Text="{loc:Tr modals.usageMonitor.staleGateHint}"/>
</StackPanel>
</Border>
<Border IsVisible="{Binding IsGateBlocked}"
Background="{DynamicResource ErrorTintBrush}"
BorderBrush="{DynamicResource StatusErrorBrush}"
BorderThickness="1" CornerRadius="6" Padding="12,8">
<TextBlock Classes="meta" Text="{Binding GateBandText}"/>
</Border>
</StackPanel>
<!-- Gauges -->
<ItemsControl DockPanel.Dock="Top" Margin="20,12,20,0" ItemsSource="{Binding GaugeRows}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:UsageGaugeRowViewModel">
<Border Background="{DynamicResource DeepBrush}" BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1" CornerRadius="6" Padding="12,10" Margin="0,0,10,10" Width="240">
<StackPanel Spacing="6">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Classes="eyebrow" Text="{Binding Label}"/>
<TextBlock Classes="meta" Text="{Binding Percent, StringFormat={}{0:0}%}" HorizontalAlignment="Right"/>
</StackPanel>
<Grid Height="10">
<ProgressBar Classes="usage-gauge" Classes.warn="{Binding IsWarnSeverity}" Value="{Binding Percent}"/>
<Canvas IsHitTestVisible="False">
<Rectangle Canvas.Left="{Binding ThresholdMarkerLeftPx}" Width="2" Height="10"
Fill="{DynamicResource TextDimBrush}"
IsVisible="{Binding ThresholdPercent, Converter={x:Static conv:ObjectConverters.IsNotNull}}"/>
</Canvas>
</Grid>
<TextBlock Classes="meta" Text="{Binding ResetText}" IsVisible="{Binding ResetText, Converter={x:Static conv:StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock DockPanel.Dock="Top" Margin="20,12,20,0" Classes="meta"
Text="{loc:Tr modals.usageMonitor.noGauges}"
IsVisible="{Binding !GaugeRows.Count}"/>
<!-- Period picker -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="20,12,20,0">
<TextBlock Classes="meta" Text="{loc:Tr modals.usageMonitor.period}" VerticalAlignment="Center"/>
<Button Classes="btn" Content="{loc:Tr modals.usageMonitor.last7Days}"
Command="{Binding SetPresetCommand}" CommandParameter="7"/>
<Button Classes="btn" Content="{loc:Tr modals.usageMonitor.last30Days}"
Command="{Binding SetPresetCommand}" CommandParameter="30"/>
<TextBlock Classes="meta" Text="{loc:Tr modals.usageMonitor.fromDate}" VerticalAlignment="Center"/>
<ctl:ThemedDatePicker SelectedDate="{Binding StartDate}"/>
<TextBlock Classes="meta" Text="{loc:Tr modals.usageMonitor.toDate}" VerticalAlignment="Center"/>
<ctl:ThemedDatePicker SelectedDate="{Binding EndDate}"/>
</StackPanel>
<TabControl Padding="20,12" TabStripPlacement="Top">
<TabItem Header="{loc:Tr modals.usageMonitor.modelUsage}">
<ScrollViewer>
<StackPanel>
<TextBlock Classes="meta" Margin="0,16"
Text="{loc:Tr modals.usageMonitor.emptyStateHint}"
IsVisible="{Binding ModelsEmpty}"/>
<Grid ColumnDefinitions="*,80,80,80,80,80,80,60" Margin="12,0,12,4" IsVisible="{Binding !ModelsEmpty}">
<TextBlock Grid.Column="0" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnModel}"/>
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnIn}"/>
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnOut}"/>
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnCache}"/>
<TextBlock Grid.Column="4" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnOtherIn}"/>
<TextBlock Grid.Column="5" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnOtherOut}"/>
<TextBlock Grid.Column="6" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnOtherCache}"/>
<TextBlock Grid.Column="7" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnShare}"/>
</Grid>
<Border Height="1" Background="{DynamicResource LineBrush}" Margin="0,0,0,8" IsVisible="{Binding !ModelsEmpty}"/>
<ItemsControl ItemsSource="{Binding ModelRows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ModelUsageDisplayRow">
<Grid ColumnDefinitions="*,80,80,80,80,80,80,60" Margin="12,4">
<TextBlock Grid.Column="0" Classes="title" Text="{Binding Model}"/>
<TextBlock Grid.Column="1" Classes="meta" Text="{Binding ClaudeDoInputTokens}"/>
<TextBlock Grid.Column="2" Classes="meta" Text="{Binding ClaudeDoOutputTokens}"/>
<TextBlock Grid.Column="3" Classes="meta" Text="{Binding ClaudeDoCacheTokens}"/>
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding OtherInputTokens}"/>
<TextBlock Grid.Column="5" Classes="meta" Text="{Binding OtherOutputTokens}"/>
<TextBlock Grid.Column="6" Classes="meta" Text="{Binding OtherCacheTokens}"/>
<TextBlock Grid.Column="7" Classes="meta" Text="{Binding SharePercent, StringFormat={}{0:0}%}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem Header="{loc:Tr modals.usageMonitor.taskUsage}">
<ScrollViewer>
<StackPanel>
<TextBlock Classes="meta" Margin="0,16"
Text="{loc:Tr modals.usageMonitor.emptyStateHint}"
IsVisible="{Binding TasksEmpty}"/>
<Grid ColumnDefinitions="*,110,90,60,80,80,80" Margin="12,0,12,4" IsVisible="{Binding !TasksEmpty}">
<TextBlock Grid.Column="0" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnTask}"/>
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnList}"/>
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnModelShort}"/>
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnRuns}"/>
<TextBlock Grid.Column="4" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnIn}"/>
<TextBlock Grid.Column="5" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnOut}"/>
<TextBlock Grid.Column="6" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnTotal}"/>
</Grid>
<Border Height="1" Background="{DynamicResource LineBrush}" Margin="0,0,0,8" IsVisible="{Binding !TasksEmpty}"/>
<ItemsControl ItemsSource="{Binding TaskRows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TaskUsageDisplayRow">
<Grid ColumnDefinitions="*,110,90,60,80,80,80" Margin="12,4">
<TextBlock Grid.Column="0" Classes="title" Text="{Binding TaskTitle}"/>
<TextBlock Grid.Column="1" Classes="meta" Text="{Binding ListName}"/>
<TextBlock Grid.Column="2" Classes="meta" Text="{Binding Model}"/>
<TextBlock Grid.Column="3" Classes="meta" Text="{Binding Runs}"/>
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding TokensIn}"/>
<TextBlock Grid.Column="5" Classes="meta" Text="{Binding TokensOut}"/>
<TextBlock Grid.Column="6" Classes="meta" Text="{Binding TotalTokens}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</TabItem>
</TabControl>
<Ellipse Classes="spinner" Width="20" Height="20" Margin="0,0,0,16"
HorizontalAlignment="Center" DockPanel.Dock="Bottom"
IsVisible="{Binding IsBusy}"/>
</DockPanel>
</ctl:ModalShell>
</Window>
@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace ClaudeDo.Ui.Views.Modals;
public partial class UsageMonitorModalView : Window
{
public UsageMonitorModalView() => InitializeComponent();
}
@@ -58,6 +58,13 @@ public sealed class WindowDialogService : IDialogService
await dlg.ShowDialog(_owner);
}
public async Task ShowUsageMonitorAsync(UsageMonitorModalViewModel vm)
{
var dlg = new UsageMonitorModalView { DataContext = vm };
vm.CloseAction = () => dlg.Close();
await dlg.ShowDialog(_owner);
}
public async Task ShowSettingsAsync(SettingsModalViewModel vm)
{
var dlg = new SettingsModalView { DataContext = vm };