Skip to content

Commit af6ecd2

Browse files
committed
Changed refresh interval to 2 minutes. Better handling of missing credentials file.
1 parent e92db9d commit af6ecd2

6 files changed

Lines changed: 140 additions & 30 deletions

File tree

App.axaml.cs

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,31 @@ public override void OnFrameworkInitializationCompleted()
1919
{
2020
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
2121
{
22+
if (true || !UsageService.CredentialsFileExists())
23+
{
24+
var icons = TrayIcon.GetIcons(this);
25+
if (icons != null)
26+
{
27+
foreach (var icon in icons)
28+
{
29+
icon.IsVisible = false;
30+
}
31+
}
32+
33+
desktop.ShutdownMode = ShutdownMode.OnLastWindowClose;
34+
desktop.MainWindow = new NoCredentialsWindow();
35+
desktop.MainWindow.Show();
36+
base.OnFrameworkInitializationCompleted();
37+
return;
38+
}
39+
2240
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
2341
desktop.MainWindow = new MainWindow();
2442
desktop.MainWindow.Show();
2543
}
2644

2745
var menu = FindMinimizeMenuItem();
28-
if (menu != null)
29-
{
30-
menu.IsChecked = Settings.MinimizeToTray;
31-
}
46+
menu?.IsChecked = Settings.MinimizeToTray;
3247

3348
base.OnFrameworkInitializationCompleted();
3449
}

MainWindow.axaml.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ private void OnOpened(object? sender, EventArgs e)
7171

7272
_timer = new DispatcherTimer
7373
{
74-
Interval = TimeSpan.FromMinutes(1)
74+
Interval = TimeSpan.FromMinutes(2)
7575
};
7676
_timer.Tick += async (_, _) => await PollUsageAsync();
7777
_timer.Start();

NoCredentialsWindow.axaml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<Window xmlns="https://github.com/avaloniaui"
2+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
3+
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
4+
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
5+
mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="260"
6+
x:Class="ClaudeUsageMonitor.NoCredentialsWindow"
7+
Title="Claude Code Usage Monitor"
8+
Icon="avares://ClaudeUsageMonitor/Assets/icon.ico"
9+
Width="500" Height="260"
10+
Background="#1a1a2e"
11+
CanResize="False"
12+
WindowStartupLocation="CenterScreen">
13+
14+
<Grid RowDefinitions="Auto,*,Auto" Margin="24">
15+
<TextBlock Grid.Row="0"
16+
Text="Claude Code sign-in required"
17+
FontSize="18" FontWeight="Bold"
18+
Foreground="#e0e0f0"
19+
HorizontalAlignment="Center"
20+
Margin="0,0,0,16" />
21+
22+
<StackPanel Grid.Row="1" VerticalAlignment="Top">
23+
<TextBlock Foreground="#b0b0c0"
24+
FontSize="13"
25+
TextWrapping="Wrap">
26+
No Claude Code credentials were found.<LineBreak/>
27+
<LineBreak/>
28+
Claude Code Usage Monitor reuses the OAuth credentials that
29+
Claude Code stores on disk. Please run Claude Code and sign in
30+
at least once, then relaunch Claude Code Usage Monitor.
31+
</TextBlock>
32+
<TextBlock Foreground="#b0b0c0"
33+
FontSize="13"
34+
TextWrapping="Wrap"
35+
Margin="0,12,0,0">
36+
<Run Text="Expected credentials file: " />
37+
<Run x:Name="CredentialsPathRun" FontWeight="Bold" />
38+
</TextBlock>
39+
</StackPanel>
40+
41+
<Button Grid.Row="2"
42+
Content="Close"
43+
HorizontalAlignment="Right"
44+
Padding="20,6"
45+
Click="OnCloseClicked" />
46+
</Grid>
47+
</Window>

NoCredentialsWindow.axaml.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using Avalonia.Controls;
2+
using Avalonia.Controls.ApplicationLifetimes;
3+
using ClaudeUsageMonitor.Services;
4+
5+
6+
namespace ClaudeUsageMonitor;
7+
8+
public partial class NoCredentialsWindow : Window
9+
{
10+
public NoCredentialsWindow()
11+
{
12+
InitializeComponent();
13+
CredentialsPathRun.Text = UsageService.CredentialsFilePath;
14+
}
15+
16+
private void OnCloseClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
17+
{
18+
if (Avalonia.Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
19+
{
20+
desktop.Shutdown();
21+
}
22+
else
23+
{
24+
Close();
25+
}
26+
}
27+
}

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Claude Code Usage Monitor
22

3-
A small desktop app that shows your current Claude Code usage limits at a glance. It reads the OAuth credentials that Claude Code already stores on disk, polls Anthropic's usage endpoint once a minute, and renders three gauges: the rolling 5-hour window, the 7-day overall window, and the 7-day Sonnet-specific window.
3+
A small desktop app that shows your current Claude Code usage limits at a glance. It reads the OAuth credentials that Claude Code already stores on disk, polls Anthropic's usage endpoint once every two minutes, and renders three gauges: the rolling 5-hour window, the 7-day overall window, and the 7-day Sonnet-specific window.
44

55
![Claude Code Usage Monitor](media/ClaudeUsageMonitor.png)
66

Services/UsageService.cs

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ public class UsageService : IDisposable
2626

2727
public void Dispose() => _httpClient.Dispose();
2828

29+
public static bool CredentialsFileExists() => File.Exists(CredentialsPath);
30+
31+
public static string CredentialsFilePath => CredentialsPath;
32+
2933
public async Task<UsageResponse?> GetUsageAsync()
3034
{
3135
await LoadCredentialsAsync();
@@ -43,43 +47,60 @@ public class UsageService : IDisposable
4347
}
4448
}
4549

46-
using var request = new HttpRequestMessage(HttpMethod.Get, UsageUrl);
47-
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _credentials.AccessToken);
48-
request.Headers.Add("anthropic-beta", "oauth-2025-04-20");
49-
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
50-
51-
var response = await _httpClient.SendAsync(request);
50+
var result = await SendUsageRequestAsync();
5251

53-
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
52+
if (result.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
5453
{
5554
await RefreshTokenAsync();
56-
57-
using var retryRequest = new HttpRequestMessage(HttpMethod.Get, UsageUrl);
58-
retryRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _credentials.AccessToken);
59-
retryRequest.Headers.Add("anthropic-beta", "oauth-2025-04-20");
60-
retryRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
61-
62-
response = await _httpClient.SendAsync(retryRequest);
55+
result = await SendUsageRequestAsync();
6356
}
6457

65-
if (response.StatusCode == HttpStatusCode.TooManyRequests)
58+
if (result.StatusCode == HttpStatusCode.TooManyRequests)
6659
{
67-
var retryAfter = response.Headers.RetryAfter?.Delta
68-
?? (response.Headers.RetryAfter?.Date - DateTimeOffset.UtcNow)
69-
?? TimeSpan.FromMinutes(5);
70-
if (retryAfter <= TimeSpan.Zero)
60+
await RefreshTokenAsync();
61+
result = await SendUsageRequestAsync();
62+
63+
if (result.StatusCode == HttpStatusCode.TooManyRequests)
7164
{
72-
retryAfter = TimeSpan.FromMinutes(5);
65+
var retryAfter = result.RetryAfter ?? TimeSpan.FromMinutes(5);
66+
if (retryAfter <= TimeSpan.Zero)
67+
{
68+
retryAfter = TimeSpan.FromMinutes(5);
69+
}
70+
throw new RateLimitedException(retryAfter);
7371
}
74-
throw new RateLimitedException(retryAfter);
7572
}
7673

77-
response.EnsureSuccessStatusCode();
74+
if (!result.IsSuccess)
75+
{
76+
throw new HttpRequestException(
77+
$"Usage request failed with status {(int) result.StatusCode} {result.StatusCode}");
78+
}
7879

79-
var json = await response.Content.ReadAsStringAsync();
80-
return JsonSerializer.Deserialize(json, UsageJsonContext.Default.UsageResponse);
80+
return JsonSerializer.Deserialize(result.Body, UsageJsonContext.Default.UsageResponse);
8181
}
8282

83+
private async Task<UsageRequestResult> SendUsageRequestAsync()
84+
{
85+
using var request = new HttpRequestMessage(HttpMethod.Get, UsageUrl);
86+
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _credentials!.AccessToken);
87+
request.Headers.Add("anthropic-beta", "oauth-2025-04-20");
88+
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
89+
90+
using var response = await _httpClient.SendAsync(request);
91+
var body = await response.Content.ReadAsStringAsync();
92+
var retryAfter = response.Headers.RetryAfter?.Delta
93+
?? (response.Headers.RetryAfter?.Date - DateTimeOffset.UtcNow);
94+
95+
return new UsageRequestResult(response.StatusCode, response.IsSuccessStatusCode, body, retryAfter);
96+
}
97+
98+
private readonly record struct UsageRequestResult(
99+
HttpStatusCode StatusCode,
100+
bool IsSuccess,
101+
string Body,
102+
TimeSpan? RetryAfter);
103+
83104
private async Task LoadCredentialsAsync()
84105
{
85106
// Always read fresh from Claude Code's credentials file so we pick up any

0 commit comments

Comments
 (0)