This commit is contained in:
2026-06-25 09:59:54 +02:00
commit bd2ecd0176
16 changed files with 2069 additions and 0 deletions

146
App.xaml Normal file
View File

@@ -0,0 +1,146 @@
<Application x:Class="ClaudeOverview.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
<!-- Palette -->
<SolidColorBrush x:Key="PanelBg" Color="#F21B1C20"/>
<SolidColorBrush x:Key="PanelSolid" Color="#FF1B1C20"/>
<SolidColorBrush x:Key="CardBg" Color="#2A2C33"/>
<SolidColorBrush x:Key="CardBgHover" Color="#34373F"/>
<SolidColorBrush x:Key="AccentBrush" Color="#D97757"/>
<SolidColorBrush x:Key="WaitingBrush" Color="#E8A33D"/>
<SolidColorBrush x:Key="PermissionBrush" Color="#D9576B"/>
<SolidColorBrush x:Key="WorkingBrush" Color="#5FB37A"/>
<SolidColorBrush x:Key="TextPrimary" Color="#F5F5F4"/>
<SolidColorBrush x:Key="TextMuted" Color="#9CA0AB"/>
<SolidColorBrush x:Key="BorderBrush" Color="#3A3D45"/>
<!-- Borderless icon-style button -->
<Style x:Key="IconButton" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Foreground" Value="{StaticResource TextMuted}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="bg" Background="{TemplateBinding Background}" CornerRadius="6">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bg" Property="Background" Value="#33FFFFFF"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Pill button for the settings screen -->
<Style x:Key="PillButton" TargetType="Button">
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
<Setter Property="Background" Value="{StaticResource CardBg}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="14,7"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="bg" Background="{TemplateBinding Background}"
CornerRadius="7" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bg" Property="Background" Value="{StaticResource CardBgHover}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Accent variant for the primary (Save) action -->
<Style x:Key="PillButtonAccent" TargetType="Button" BasedOn="{StaticResource PillButton}">
<Setter Property="Background" Value="{StaticResource AccentBrush}"/>
<Setter Property="Foreground" Value="#FFFFFF"/>
</Style>
<!-- ===== Slim dark scrollbar (matches the panel; no arrow buttons) ===== -->
<!-- Invisible button used for the track's page-up/page-down areas. -->
<Style x:Key="ScrollBarPageButton" TargetType="RepeatButton">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Focusable" Value="False"/>
<Setter Property="IsTabStop" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RepeatButton">
<Border Background="Transparent"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Rounded draggable thumb that brightens on hover/drag. -->
<Style x:Key="ScrollBarThumb" TargetType="Thumb">
<Setter Property="Focusable" Value="False"/>
<Setter Property="IsTabStop" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Border x:Name="thumb" CornerRadius="3" Margin="2"
Background="#66000000"/>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="thumb" Property="Background" Value="#99000000"/>
</Trigger>
<Trigger Property="IsDragging" Value="True">
<Setter TargetName="thumb" Property="Background" Value="#CC000000"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ScrollBar">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Width" Value="6"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Grid Background="Transparent">
<Track x:Name="PART_Track" IsDirectionReversed="True">
<Track.DecreaseRepeatButton>
<RepeatButton Command="ScrollBar.PageUpCommand"
Style="{StaticResource ScrollBarPageButton}"/>
</Track.DecreaseRepeatButton>
<Track.Thumb>
<Thumb Style="{StaticResource ScrollBarThumb}"/>
</Track.Thumb>
<Track.IncreaseRepeatButton>
<RepeatButton Command="ScrollBar.PageDownCommand"
Style="{StaticResource ScrollBarPageButton}"/>
</Track.IncreaseRepeatButton>
</Track>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Width" Value="Auto"/>
<Setter Property="Height" Value="6"/>
<Setter TargetName="PART_Track" Property="IsDirectionReversed" Value="False"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Application.Resources>
</Application>

7
App.xaml.cs Normal file
View File

@@ -0,0 +1,7 @@
namespace ClaudeOverview;
// Fully qualified: System.Windows.Forms is also in scope (UseWindowsForms) and
// defines its own Application type, so the bare name is ambiguous.
public partial class App : System.Windows.Application
{
}

30
ClaudeOverview.csproj Normal file
View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<!-- Needed for System.Windows.Forms.Screen (multi-monitor working areas) -->
<UseWindowsForms>true</UseWindowsForms>
<AssemblyName>ClaudeOverview</AssemblyName>
<RootNamespace>ClaudeOverview</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<!-- The WinForms DPI analyzer warns about dpiAware in the manifest, but for a
WPF app the manifest is the correct place to declare PerMonitorV2. Silence it. -->
<NoWarn>$(NoWarn);WFAC010</NoWarn>
<!-- NOTE: build output is redirected to a local drive in Directory.Build.props
(it must be set before the SDK imports), because Windows refuses to execute
binaries stored on the \\wsl.localhost share. -->
</PropertyGroup>
<ItemGroup>
<!-- Ship the default config next to the exe; don't overwrite a user-edited one -->
<None Update="config.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

15
Directory.Build.props Normal file
View File

@@ -0,0 +1,15 @@
<Project>
<!--
Windows blocks executing binaries stored on the \\wsl.localhost share, so the
build output (bin) and intermediate files (obj) are redirected to a local drive.
These must be set here (Directory.Build.props is imported before the SDK's
Microsoft.Common.props) rather than in the .csproj, or MSBuild writes generated
files to two locations and the compiler sees duplicate definitions.
Source stays solely in the WSL folder; this output folder is regenerated each build.
-->
<PropertyGroup>
<BaseOutputPath>C:\Tools\ClaudeOverview-build\bin\</BaseOutputPath>
<BaseIntermediateOutputPath>C:\Tools\ClaudeOverview-build\obj\</BaseIntermediateOutputPath>
</PropertyGroup>
</Project>

299
MainWindow.xaml Normal file
View File

@@ -0,0 +1,299 @@
<Window x:Class="ClaudeOverview.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Claude Overview"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
ShowInTaskbar="False"
Topmost="True"
ResizeMode="NoResize"
SizeToContent="Manual"
TextOptions.TextFormattingMode="Display"
UseLayoutRounding="True"
FontFamily="Segoe UI">
<Grid>
<!-- ===== STATE 1: minimized arrow button (bottom-right) ===== -->
<Border x:Name="MinimizedState"
Width="44" Height="44"
HorizontalAlignment="Right" VerticalAlignment="Bottom"
Background="{StaticResource PanelBg}"
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"
CornerRadius="10">
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="Quit" Click="Quit_Click"/>
</ContextMenu>
</Border.ContextMenu>
<Border.Effect>
<DropShadowEffect BlurRadius="14" ShadowDepth="0" Opacity="0.5" Color="#000000"/>
</Border.Effect>
<Button Style="{StaticResource IconButton}" Click="Expand_Click"
ToolTip="Show active Claude chats">
<!-- up-left pointing arrow toward the corner widget; recolored when a chat waits -->
<TextBlock x:Name="MinArrow" Text="◤" FontSize="18"
Foreground="{StaticResource TextMuted}"/>
</Button>
</Border>
<!-- ===== STATE 2: the panel (bottom-right) ===== -->
<Border x:Name="ExpandedState"
Width="370" Height="520"
HorizontalAlignment="Right" VerticalAlignment="Bottom"
Background="{StaticResource PanelBg}"
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"
CornerRadius="12"
Visibility="Collapsed">
<Border.Effect>
<DropShadowEffect BlurRadius="18" ShadowDepth="0" Opacity="0.55" Color="#000000"/>
</Border.Effect>
<Grid Margin="14,12,14,12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Header (also draggable to move the window) -->
<Grid Grid.Row="0" Background="Transparent" MouseLeftButtonDown="Header_DragMove">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="Active Claude chats" FontSize="14" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimary}"/>
<TextBlock x:Name="SubtitleText" Text="VS Code · scanning…" FontSize="11"
Foreground="{StaticResource TextMuted}" Margin="0,1,0,0"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<Button Style="{StaticResource IconButton}"
Width="28" Height="28" Click="Settings_Click" ToolTip="Settings">
<TextBlock Text="⚙" FontSize="15" Foreground="{StaticResource TextMuted}"/>
</Button>
<Button Style="{StaticResource IconButton}"
Width="28" Height="28" Click="Collapse_Click" ToolTip="Minimize">
<TextBlock Text="▾" FontSize="16" Foreground="{StaticResource TextMuted}"/>
</Button>
<Button Style="{StaticResource IconButton}"
Width="28" Height="28" Click="Quit_Click" ToolTip="Quit">
<TextBlock Text="✕" FontSize="13" Foreground="{StaticResource TextMuted}"/>
</Button>
</StackPanel>
</Grid>
<!-- Subscription usage meter -->
<Border x:Name="UsagePanel" Grid.Row="1" Margin="0,10,0,0"
Background="{StaticResource CardBg}" CornerRadius="9" Padding="11,9">
<StackPanel>
<Grid>
<TextBlock Text="Session limit" FontSize="11"
Foreground="{StaticResource TextMuted}" HorizontalAlignment="Left"/>
<TextBlock x:Name="SessionPctText" FontSize="11" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimary}" HorizontalAlignment="Right"/>
</Grid>
<ProgressBar x:Name="SessionBar" Height="6" Minimum="0" Maximum="100"
Margin="0,5,0,0" BorderThickness="0"
Background="#33000000"
Foreground="{StaticResource AccentBrush}"/>
<Grid Margin="0,5,0,0">
<TextBlock x:Name="WeeklyText" FontSize="10"
Foreground="{StaticResource TextMuted}" HorizontalAlignment="Left"/>
<TextBlock x:Name="SessionResetText" FontSize="10"
Foreground="{StaticResource TextMuted}" HorizontalAlignment="Right"/>
</Grid>
</StackPanel>
</Border>
<!-- Session list -->
<ScrollViewer Grid.Row="2" Margin="0,10,0,0"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<Grid>
<ItemsControl x:Name="SessionsList">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border x:Name="CardRoot" Background="{StaticResource CardBg}" CornerRadius="9"
Padding="11,9" Margin="0,0,0,8">
<StackPanel>
<!-- project + status -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal" Grid.Column="0">
<Ellipse x:Name="StatusDot" Width="7" Height="7"
Fill="{StaticResource WorkingBrush}"
VerticalAlignment="Center" Margin="0,0,7,0"/>
<TextBlock Text="{Binding ProjectName}" FontSize="13"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimary}"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
<TextBlock x:Name="StatusLabel" Grid.Column="1"
Text="{Binding StatusText}"
FontSize="10" FontWeight="SemiBold"
Foreground="{StaticResource WorkingBrush}"
VerticalAlignment="Center"/>
</Grid>
<!-- chat name (VS Code's generated title, else first prompt) -->
<TextBlock Text="{Binding DisplayTitle}" FontSize="11"
Foreground="{StaticResource TextMuted}"
TextTrimming="CharacterEllipsis"
Margin="14,2,0,0"/>
<!-- usage + last activity -->
<StackPanel Orientation="Horizontal" Margin="14,6,0,0">
<TextBlock Text="{Binding ModelShort}" FontSize="11"
Foreground="{StaticResource TextMuted}"/>
<TextBlock Text=" · " FontSize="11"
Foreground="{StaticResource TextMuted}"/>
<TextBlock Text="{Binding LastActivityLocal}" FontSize="11"
Foreground="{StaticResource TextMuted}"/>
</StackPanel>
<!-- share of this session-limit window's total tokens -->
<Grid Margin="14,6,0,0"
ToolTip="Share of all tokens used this session-limit window">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<ProgressBar Grid.Column="0" Height="4" Minimum="0" Maximum="100"
Value="{Binding SessionSharePercent}"
BorderThickness="0" Background="#33000000"
Foreground="{StaticResource AccentBrush}"
VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding SessionShareText}"
FontSize="10" Foreground="{StaticResource TextMuted}"
Margin="8,0,0,0" VerticalAlignment="Center"/>
</Grid>
</StackPanel>
</Border>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding IsWaiting}" Value="True">
<Setter TargetName="StatusDot" Property="Fill"
Value="{StaticResource WaitingBrush}"/>
<Setter TargetName="StatusLabel" Property="Foreground"
Value="{StaticResource WaitingBrush}"/>
</DataTrigger>
<DataTrigger Binding="{Binding NeedsPermission}" Value="True">
<Setter TargetName="StatusDot" Property="Fill"
Value="{StaticResource PermissionBrush}"/>
<Setter TargetName="StatusLabel" Property="Foreground"
Value="{StaticResource PermissionBrush}"/>
</DataTrigger>
<!-- Cold (inactive) chats: dimmed, no status colors. Last so it
overrides the waiting/permission colors above. -->
<DataTrigger Binding="{Binding IsCold}" Value="True">
<Setter TargetName="CardRoot" Property="Opacity" Value="0.45"/>
<Setter TargetName="StatusDot" Property="Fill"
Value="{StaticResource TextMuted}"/>
<Setter TargetName="StatusLabel" Property="Visibility"
Value="Collapsed"/>
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Empty / error state -->
<TextBlock x:Name="EmptyText"
Text="No active VS Code chats right now."
FontSize="12" Foreground="{StaticResource TextMuted}"
TextWrapping="Wrap" Margin="2,8,2,0"
Visibility="Collapsed"/>
</Grid>
</ScrollViewer>
<!-- Footer totals -->
<Border Grid.Row="3" Margin="0,8,0,0" Padding="0,8,0,0"
BorderBrush="{StaticResource BorderBrush}" BorderThickness="0,1,0,0">
<TextBlock x:Name="FooterText" FontSize="11"
Foreground="{StaticResource TextMuted}"/>
</Border>
<!-- ===== Settings overlay (covers usage + list + footer) ===== -->
<Border x:Name="SettingsPanel" Grid.Row="1" Grid.RowSpan="3"
Background="{StaticResource PanelSolid}" Panel.ZIndex="10"
Margin="0,10,0,0" Visibility="Collapsed">
<StackPanel>
<TextBlock Text="Settings" FontSize="13" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimary}" Margin="0,0,0,12"/>
<!-- Monitor -->
<TextBlock Text="Monitor" FontSize="11"
Foreground="{StaticResource TextMuted}" Margin="0,0,0,4"/>
<ComboBox x:Name="MonitorCombo" Height="30"
DisplayMemberPath="Label" SelectedValuePath="Index"
Foreground="#202020" FontSize="12" Margin="0,0,0,16"/>
<!-- Active threshold -->
<Grid Margin="0,0,0,4">
<TextBlock Text="Active threshold" FontSize="11"
Foreground="{StaticResource TextMuted}"
HorizontalAlignment="Left"/>
<TextBlock x:Name="ThresholdLabel" FontSize="11" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimary}"
HorizontalAlignment="Right"/>
</Grid>
<Slider x:Name="ThresholdSlider" Minimum="1" Maximum="60"
IsSnapToTickEnabled="True" TickFrequency="1"
ValueChanged="ThresholdSlider_ValueChanged" Margin="0,0,0,6"/>
<TextBlock Text="A chat counts as active if its transcript changed within this window."
FontSize="10" Foreground="{StaticResource TextMuted}"
TextWrapping="Wrap" Margin="0,0,0,16"/>
<!-- Refresh interval -->
<Grid Margin="0,0,0,4">
<TextBlock Text="Refresh interval" FontSize="11"
Foreground="{StaticResource TextMuted}"
HorizontalAlignment="Left"/>
<TextBlock x:Name="RefreshLabel" FontSize="11" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimary}"
HorizontalAlignment="Right"/>
</Grid>
<Slider x:Name="RefreshSlider" Minimum="1" Maximum="30"
IsSnapToTickEnabled="True" TickFrequency="1"
ValueChanged="RefreshSlider_ValueChanged" Margin="0,0,0,18"/>
<!-- Notifications -->
<CheckBox x:Name="FlashCheck" Content="Flash when a chat needs input"
Foreground="{StaticResource TextPrimary}" FontSize="12"
Margin="0,0,0,8"/>
<CheckBox x:Name="SoundCheck" Content="Play a sound too"
Foreground="{StaticResource TextPrimary}" FontSize="12"
Margin="0,0,0,18"/>
<!-- Actions -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="Cancel" Style="{StaticResource PillButton}"
Click="SettingsCancel_Click" Margin="0,0,8,0"/>
<Button Content="Save" Style="{StaticResource PillButtonAccent}"
Click="SettingsSave_Click"/>
</StackPanel>
</StackPanel>
</Border>
</Grid>
</Border>
<!-- ===== Attention flash (pulses when a chat starts waiting) ===== -->
<Border x:Name="FlashOverlay" IsHitTestVisible="False"
CornerRadius="12" Opacity="0"
BorderBrush="{StaticResource WaitingBrush}" BorderThickness="3"
Background="#33E8A33D">
<Border.Effect>
<DropShadowEffect BlurRadius="22" ShadowDepth="0"
Color="#E8A33D" Opacity="0.9"/>
</Border.Effect>
</Border>
</Grid>
</Window>

396
MainWindow.xaml.cs Normal file
View File

@@ -0,0 +1,396 @@
using System.Collections.ObjectModel;
using System.Media;
using System.Windows;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Threading;
using ClaudeOverview.Models;
using ClaudeOverview.Services;
using WinForms = System.Windows.Forms;
namespace ClaudeOverview;
public partial class MainWindow : Window
{
private const double MinimizedW = 44, MinimizedH = 44;
private const double ExpandedW = 370, ExpandedH = 520;
private readonly AppConfig _config;
private readonly SessionScanner _scanner;
private readonly UsageService _usage;
private readonly DispatcherTimer _timer;
private readonly DispatcherTimer _usageTimer;
private readonly ObservableCollection<ClaudeSession> _sessions = new();
private bool _expanded;
private bool _scanning;
// Exponential backoff for the usage poll when the API returns HTTP 429.
private int _usageBackoffStep;
private static readonly TimeSpan UsageBackoffFloor = TimeSpan.FromMinutes(5);
private static readonly TimeSpan UsageBackoffCeiling = TimeSpan.FromMinutes(30);
// Tracks which chats we've seen and which were waiting, to detect new transitions.
private readonly HashSet<string> _seenIds = new();
private readonly HashSet<string> _waitingIds = new();
private bool _firstRefresh = true;
public MainWindow()
{
InitializeComponent();
_config = AppConfig.Load();
ClaudeSession.PendingToolStaleSeconds = Math.Max(1, _config.PendingPermissionSeconds);
_scanner = new SessionScanner(_config);
SessionsList.ItemsSource = _sessions;
_usage = new UsageService(_config);
_timer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(Math.Max(1, _config.RefreshSeconds))
};
_timer.Tick += async (_, _) => await RefreshAsync();
_usageTimer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(Math.Max(15, _config.UsageRefreshSeconds))
};
_usageTimer.Tick += async (_, _) => await RefreshUsageAsync();
Loaded += OnLoaded;
DpiChanged += (_, _) => PositionWindow();
}
private async void OnLoaded(object sender, RoutedEventArgs e)
{
SetState(expanded: !_config.StartMinimized, reposition: false);
PositionWindow();
_timer.Start();
await RefreshAsync();
UsagePanel.Visibility = _config.ShowSessionLimit ? Visibility.Visible : Visibility.Collapsed;
if (_config.ShowSessionLimit)
{
_usageTimer.Start();
await RefreshUsageAsync();
}
}
// ----- Subscription usage -----
private async Task RefreshUsageAsync()
{
var info = await _usage.FetchAsync();
// Drive the cold-chat cutoff from the current session-limit window when we have it.
if (info.Ok && info.SessionWindowStartUtc is { } start)
_scanner.ColdCutoffUtc = start;
ApplyUsageBackoff(info);
UpdateUsageUi(info);
}
/// <summary>
/// On a 429 we slow the usage poll down (exponential, honouring any Retry-After header)
/// so we stop adding to the rate-limit pressure on the shared OAuth token; the first
/// clean response restores the configured interval.
/// </summary>
private void ApplyUsageBackoff(UsageInfo info)
{
var normal = TimeSpan.FromSeconds(Math.Max(15, _config.UsageRefreshSeconds));
if (info.RateLimited)
{
_usageBackoffStep = Math.Min(_usageBackoffStep + 1, 4);
// Exponential 5 → 10 → 20 → 30 min, capped, but never below Retry-After.
var ticks = UsageBackoffFloor.Ticks * (long)Math.Pow(2, _usageBackoffStep - 1);
var backoff = new TimeSpan(Math.Min(ticks, UsageBackoffCeiling.Ticks));
if (info.RetryAfter is { } ra && ra > backoff) backoff = ra;
if (_usageTimer.Interval != backoff) _usageTimer.Interval = backoff;
}
else if (_usageBackoffStep > 0)
{
_usageBackoffStep = 0;
if (_usageTimer.Interval != normal) _usageTimer.Interval = normal;
}
}
private void UpdateUsageUi(UsageInfo info)
{
if (!info.Ok)
{
SessionPctText.Text = "n/a";
SessionBar.Value = 0;
SessionResetText.Text = "";
WeeklyText.Text = info.Error;
return;
}
SessionBar.Value = Math.Clamp(info.SessionPercent, 0, 100);
SessionPctText.Text = $"{info.SessionPercent:0}%";
SessionResetText.Text = UsageInfo.ResetsIn(info.SessionResetsAt);
WeeklyText.Text = $"Weekly {info.WeeklyPercent:0}%";
// Bar goes amber once you're getting close to the limit.
bool near = info.SessionPercent >= 75;
SessionBar.Foreground = (System.Windows.Media.Brush)FindResource(near ? "WaitingBrush" : "AccentBrush");
SessionPctText.Foreground = (System.Windows.Media.Brush)FindResource(near ? "WaitingBrush" : "TextPrimary");
}
// ----- State switching -----
private void Expand_Click(object sender, RoutedEventArgs e) => SetState(true);
private void Collapse_Click(object sender, RoutedEventArgs e) => SetState(false);
private void Quit_Click(object sender, RoutedEventArgs e) => Close();
// ----- Settings screen -----
private sealed record MonitorOption(int Index, string Label);
private void Settings_Click(object sender, RoutedEventArgs e)
{
var options = new List<MonitorOption>();
var screens = WinForms.Screen.AllScreens;
for (int i = 0; i < screens.Length; i++)
{
var b = screens[i].Bounds;
var primary = screens[i].Primary ? " (primary)" : "";
options.Add(new MonitorOption(i, $"{i}: {b.Width}×{b.Height}{primary}"));
}
options.Add(new MonitorOption(-1, "Last screen (auto)"));
MonitorCombo.ItemsSource = options;
MonitorCombo.SelectedValue = _config.MonitorIndex;
if (MonitorCombo.SelectedItem is null)
MonitorCombo.SelectedValue = options[^1].Index;
ThresholdSlider.Value = Math.Clamp(_config.ActiveMinutes, 1, 60);
UpdateThresholdLabel(_config.ActiveMinutes);
RefreshSlider.Value = Math.Clamp(_config.RefreshSeconds, 1, 30);
UpdateRefreshLabel(_config.RefreshSeconds);
FlashCheck.IsChecked = _config.FlashOnAttention;
SoundCheck.IsChecked = _config.SoundOnAttention;
SettingsPanel.Visibility = Visibility.Visible;
}
private void ThresholdSlider_ValueChanged(object sender,
RoutedPropertyChangedEventArgs<double> e) => UpdateThresholdLabel((int)Math.Round(e.NewValue));
private void RefreshSlider_ValueChanged(object sender,
RoutedPropertyChangedEventArgs<double> e) => UpdateRefreshLabel((int)Math.Round(e.NewValue));
private void UpdateThresholdLabel(int minutes)
{
if (ThresholdLabel is not null)
ThresholdLabel.Text = minutes == 1 ? "1 min" : $"{minutes} min";
}
private void UpdateRefreshLabel(int seconds)
{
if (RefreshLabel is not null)
RefreshLabel.Text = seconds == 1 ? "1 s" : $"{seconds} s";
}
private void SettingsCancel_Click(object sender, RoutedEventArgs e)
=> SettingsPanel.Visibility = Visibility.Collapsed;
private async void SettingsSave_Click(object sender, RoutedEventArgs e)
{
if (MonitorCombo.SelectedValue is int idx)
_config.MonitorIndex = idx;
_config.ActiveMinutes = Math.Max(1, (int)Math.Round(ThresholdSlider.Value));
_config.RefreshSeconds = Math.Max(1, (int)Math.Round(RefreshSlider.Value));
_config.FlashOnAttention = FlashCheck.IsChecked == true;
_config.SoundOnAttention = SoundCheck.IsChecked == true;
_config.Save();
// Apply the new refresh interval to the running timer.
_timer.Interval = TimeSpan.FromSeconds(_config.RefreshSeconds);
SettingsPanel.Visibility = Visibility.Collapsed;
PositionWindow(); // apply monitor change immediately
await RefreshAsync(); // apply threshold change immediately
}
private void SetState(bool expanded, bool reposition = true)
{
_expanded = expanded;
ExpandedState.Visibility = expanded ? Visibility.Visible : Visibility.Collapsed;
MinimizedState.Visibility = expanded ? Visibility.Collapsed : Visibility.Visible;
Width = expanded ? ExpandedW : MinimizedW;
Height = expanded ? ExpandedH : MinimizedH;
if (reposition) PositionWindow();
}
// ----- Corner docking (DPI aware, configurable monitor) -----
private void PositionWindow()
{
var screens = WinForms.Screen.AllScreens;
if (screens.Length == 0) return;
int idx = _config.MonitorIndex;
WinForms.Screen screen = idx switch
{
< 0 => screens[^1], // last screen (typical side monitor)
_ when idx < screens.Length => screens[idx],
_ => WinForms.Screen.PrimaryScreen ?? screens[0]
};
var dpi = VisualTreeHelper.GetDpi(this);
double sx = dpi.DpiScaleX <= 0 ? 1 : dpi.DpiScaleX;
double sy = dpi.DpiScaleY <= 0 ? 1 : dpi.DpiScaleY;
var wa = screen.WorkingArea; // physical pixels
double rightDip = wa.Right / sx;
double bottomDip = wa.Bottom / sy;
double marginDip = _config.EdgeMargin / sx;
Left = rightDip - Width - marginDip;
Top = bottomDip - Height - marginDip;
}
private void Header_DragMove(object sender, MouseButtonEventArgs e)
{
if (e.ButtonState == MouseButtonState.Pressed) DragMove();
}
// ----- Data refresh -----
private async Task RefreshAsync()
{
if (_scanning) return;
_scanning = true;
try
{
// Scan off the UI thread — it reads the \\wsl.localhost share.
var (sessions, error) = await Task.Run(() =>
{
var list = _scanner.Scan(out var err);
return (list, err);
});
UpdateUi(sessions, error);
}
catch (Exception ex)
{
UpdateUi(Array.Empty<ClaudeSession>(), ex.Message);
}
finally
{
_scanning = false;
}
}
private void UpdateUi(IReadOnlyList<ClaudeSession> sessions, string? error)
{
_sessions.Clear();
foreach (var s in sessions) _sessions.Add(s);
bool hasError = !string.IsNullOrEmpty(error);
bool empty = sessions.Count == 0;
EmptyText.Visibility = (empty || hasError) ? Visibility.Visible : Visibility.Collapsed;
EmptyText.Text = hasError
? error!
: $"No active VS Code chats in the last {_config.ActiveMinutes} min.";
int active = 0, cold = 0, waiting = 0;
long totalTokens = 0;
foreach (var s in sessions)
{
if (s.IsActive) { active++; if (s.IsWaiting) waiting++; }
else cold++;
totalTokens += s.TotalTokens;
}
SubtitleText.Text = hasError
? "error — check config.json"
: waiting > 0
? $"{waiting} waiting for you · {DateTime.Now:HH:mm:ss}"
: $"VS Code · last {_config.ActiveMinutes} min · {DateTime.Now:HH:mm:ss}";
var coldNote = cold > 0 ? $" · {cold} cold" : "";
FooterText.Text = $"{active} active{coldNote} · {waiting} waiting · {Human(totalTokens)} tokens";
// Recolor the minimized arrow so a waiting chat is visible even while collapsed.
MinArrow.Foreground = waiting > 0
? (System.Windows.Media.Brush)FindResource("WaitingBrush")
: (System.Windows.Media.Brush)FindResource("TextMuted");
if (!hasError) DetectAttention(sessions);
}
/// <summary>
/// Flashes the widget when a chat newly transitions into the waiting state — i.e. it
/// just finished or asked for input. A chat we've previously seen working that is now
/// waiting always counts; a brand-new chat only counts if it became active just now
/// (so old waiting chats scrolling into the active window don't trigger a flash).
/// </summary>
private void DetectAttention(IReadOnlyList<ClaudeSession> sessions)
{
var recency = TimeSpan.FromSeconds(_config.RefreshSeconds * 2 + 3);
bool trigger = false;
foreach (var s in sessions)
{
if (!s.IsActive) continue; // cold chats never demand attention
if (!s.IsWaiting || _waitingIds.Contains(s.SessionId)) continue;
bool seenBefore = _seenIds.Contains(s.SessionId);
if (seenBefore || (DateTime.UtcNow - s.LastActivityUtc) < recency)
trigger = true;
}
// Refresh the tracking sets.
_waitingIds.Clear();
foreach (var s in sessions)
{
_seenIds.Add(s.SessionId);
if (s.IsWaiting) _waitingIds.Add(s.SessionId);
}
// Don't flash for everything that's already waiting when the app first starts.
if (trigger && !_firstRefresh)
FlashAttention();
_firstRefresh = false;
}
private void FlashAttention()
{
if (_config.FlashOnAttention)
{
var pulse = new DoubleAnimation
{
From = 0,
To = 1,
Duration = TimeSpan.FromMilliseconds(260),
AutoReverse = true,
RepeatBehavior = new RepeatBehavior(3)
};
FlashOverlay.BeginAnimation(OpacityProperty, pulse);
}
if (_config.SoundOnAttention)
SystemSounds.Asterisk.Play();
}
private static string Human(long n) => n switch
{
>= 1_000_000 => $"{n / 1_000_000.0:0.0}M",
>= 1_000 => $"{n / 1_000.0:0.0}k",
_ => n.ToString()
};
}

207
Models/ClaudeSession.cs Normal file
View File

@@ -0,0 +1,207 @@
namespace ClaudeOverview.Models;
/// <summary>Whether the chat is mid-work or has handed the turn back to the human.</summary>
public enum ChatStatus
{
Working,
WaitingForUser,
/// <summary>Claude is blocked on a tool-permission prompt, waiting for the human to approve.</summary>
WaitingForPermission
}
/// <summary>
/// A single Claude Code chat (one transcript .jsonl file) plus its aggregated usage.
/// </summary>
public sealed class ClaudeSession
{
public string SessionId { get; init; } = "";
public string FilePath { get; init; } = "";
/// <summary>Working directory the chat ran in (cwd from the transcript).</summary>
public string Cwd { get; set; } = "";
public string GitBranch { get; set; } = "";
public string Model { get; set; } = "";
public string Entrypoint { get; set; } = "";
/// <summary>First human message, used as a fallback human-readable title.</summary>
public string Title { get; set; } = "";
/// <summary>
/// The chat name VS Code generates and shows in its tab (an "ai-title" entry in the
/// transcript). Empty until the extension has generated one for the chat.
/// </summary>
public string AiTitle { get; set; } = "";
/// <summary>The name to show for the chat: the VS Code title when available, else the first prompt.</summary>
public string DisplayTitle => AiTitle.Length > 0 ? AiTitle : Title;
public DateTime LastActivityUtc { get; set; }
/// <summary>
/// Set by the scanner: true when the chat changed within the active window. Cold (false) chats
/// are still listed, but rendered dimmed and without status colors.
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>True for cold chats — listed for context but no longer actively running.</summary>
public bool IsCold => !IsActive;
// Aggregated token usage across all assistant messages in this chat.
public long InputTokens { get; set; }
public long OutputTokens { get; set; }
public long CacheReadTokens { get; set; }
public long CacheCreationTokens { get; set; }
public int MessageCount { get; set; }
public long TotalTokens => InputTokens + OutputTokens + CacheReadTokens + CacheCreationTokens;
/// <summary>
/// Tokens this chat consumed since the current session-limit window started (set by the scanner).
/// Used to approximate each chat's slice of the session limit.
/// </summary>
public long SessionTokens { get; set; }
/// <summary>This chat's share (0100) of all tokens consumed across listed chats this window.</summary>
public double SessionSharePercent { get; set; }
public string SessionShareText => $"{SessionSharePercent:0}%";
// ----- State of the last conversational turn (set by the scanner) -----
/// <summary>Role of the last main-chain message ("assistant" or "user").</summary>
public string LastRole { get; set; } = "";
/// <summary>stop_reason of the last assistant message (e.g. "end_turn", "tool_use").</summary>
public string LastStopReason { get; set; } = "";
/// <summary>Tool names invoked by the last assistant message.</summary>
public List<string> LastToolNames { get; set; } = new();
/// <summary>Primary argument of each tool in <see cref="LastToolNames"/> (command / path / url), index-aligned.</summary>
public List<string> LastToolArgs { get; set; } = new();
/// <summary>
/// Set by the scanner: true when every pending tool of the last assistant turn is auto-approved
/// by the user's Claude Code permission settings. When so, a stale tool_use means a long-running
/// approved tool, not a prompt — so we keep showing "working…" instead of "needs permission".
/// </summary>
public bool PendingToolsAutoApproved { get; set; }
/// <summary>
/// Set by the scanner: true when the last assistant tool_use has NOT yet been answered by a
/// tool_result. Once a result arrives (even if Claude then thinks for a while before its next
/// line), the tool clearly ran — so a quick command followed by long thinking is never mistaken
/// for a permission prompt.
/// </summary>
public bool AwaitingToolResult { get; set; }
/// <summary>Text of the last assistant message (used to detect a literal question).</summary>
public string LastAssistantText { get; set; } = "";
/// <summary>
/// How long a pending tool_use must sit without a following tool_result before we treat it
/// as a permission prompt (Claude blocked, waiting on the human) rather than a tool still
/// running. Set once at startup from config. Kept small so the widget flags prompts quickly.
/// </summary>
public static int PendingToolStaleSeconds = 8;
public ChatStatus Status
{
get
{
if (LastRole == "assistant")
{
// An interactive prompt tool means Claude is explicitly asking the user.
if (LastToolNames.Contains("AskUserQuestion") || LastToolNames.Contains("ExitPlanMode"))
return ChatStatus.WaitingForUser;
// Claude ended its turn and yielded back to the human.
if (LastStopReason is "end_turn" or "stop_sequence")
return ChatStatus.WaitingForUser;
// A trailing tool_use with no tool_result yet: the tool is either executing or
// Claude Code is blocked on a permission prompt. The transcript can't tell these
// apart directly, so we combine three signals: the tool_use is still unanswered
// (a returned result means it ran — long post-tool thinking is NOT a prompt), it
// isn't auto-approved by the user's permission settings (otherwise it'd just run),
// AND the file has sat untouched past the stale threshold.
if (AwaitingToolResult && LastToolNames.Count > 0 &&
!PendingToolsAutoApproved &&
DateTime.UtcNow - LastActivityUtc > TimeSpan.FromSeconds(PendingToolStaleSeconds))
return ChatStatus.WaitingForPermission;
// tool_use (still fresh) / max_tokens etc. — still mid-work.
return ChatStatus.Working;
}
// Last entry is a user/tool-result message: Claude is generating.
return ChatStatus.Working;
}
}
/// <summary>True whenever the chat needs the human — finished, asking, or awaiting permission.</summary>
public bool IsWaiting => Status != ChatStatus.Working;
/// <summary>True specifically when Claude is blocked on a tool-permission prompt.</summary>
public bool NeedsPermission => Status == ChatStatus.WaitingForPermission;
/// <summary>True when Claude is specifically posing a question (vs just finishing).</summary>
public bool AsksQuestion =>
IsWaiting &&
(LastToolNames.Contains("AskUserQuestion") ||
LastAssistantText.TrimEnd().EndsWith("?"));
public string StatusText => Status switch
{
ChatStatus.WaitingForPermission => LastToolNames.Count > 0
? $"allow {LastToolNames[^1]}?"
: "needs permission",
ChatStatus.WaitingForUser => AsksQuestion ? "question for you" : "idle",
_ => "working…"
};
// ----- Display helpers (bound directly in XAML) -----
public string ProjectName
{
get
{
if (string.IsNullOrEmpty(Cwd)) return "(unknown)";
var trimmed = Cwd.TrimEnd('/', '\\');
var idx = trimmed.LastIndexOfAny(new[] { '/', '\\' });
return idx >= 0 && idx < trimmed.Length - 1 ? trimmed[(idx + 1)..] : trimmed;
}
}
public bool IsVsCode => Entrypoint == "claude-vscode";
public string ModelShort => Model
.Replace("claude-", "")
.Replace("-20", " 20"); // cosmetic
public string LastActivityLocal
{
get
{
var span = DateTime.UtcNow - LastActivityUtc;
if (span < TimeSpan.FromSeconds(60)) return "just now";
if (span < TimeSpan.FromMinutes(60)) return $"{(int)span.TotalMinutes}m ago";
if (span < TimeSpan.FromHours(24)) return $"{(int)span.TotalHours}h ago";
return LastActivityUtc.ToLocalTime().ToString("MMM d HH:mm");
}
}
public string UsageSummary =>
$"{Human(TotalTokens)} tok · {MessageCount} msg";
public string UsageDetail =>
$"in {Human(InputTokens)} · out {Human(OutputTokens)} · cache {Human(CacheReadTokens)}";
private static string Human(long n) => n switch
{
>= 1_000_000 => $"{n / 1_000_000.0:0.0}M",
>= 1_000 => $"{n / 1_000.0:0.0}k",
_ => n.ToString()
};
}

40
Models/UsageInfo.cs Normal file
View File

@@ -0,0 +1,40 @@
namespace ClaudeOverview.Models;
/// <summary>
/// Subscription usage pulled from the Claude /api/oauth/usage endpoint.
/// Percentages are 0100.
/// </summary>
public sealed class UsageInfo
{
/// <summary>Length of the rolling session-limit block (the API's "five_hour" bucket).</summary>
public static readonly TimeSpan SessionWindow = TimeSpan.FromHours(5);
public double SessionPercent { get; set; }
public DateTime? SessionResetsAt { get; set; }
/// <summary>UTC start of the current session-limit window (its reset time minus the 5-hour block).</summary>
public DateTime? SessionWindowStartUtc =>
SessionResetsAt is { } r ? r.ToUniversalTime() - SessionWindow : null;
public double WeeklyPercent { get; set; }
public DateTime? WeeklyResetsAt { get; set; }
public string? Error { get; set; }
public bool Ok => Error is null;
/// <summary>True when the last fetch was rejected with HTTP 429 (rate limited).</summary>
public bool RateLimited { get; set; }
/// <summary>Server-suggested wait before retrying (from the Retry-After header), if any.</summary>
public TimeSpan? RetryAfter { get; set; }
public static string ResetsIn(DateTime? resetsAtUtc)
{
if (resetsAtUtc is null) return "";
var span = resetsAtUtc.Value.ToUniversalTime() - DateTime.UtcNow;
if (span <= TimeSpan.Zero) return "resetting…";
if (span < TimeSpan.FromHours(1)) return $"resets in {(int)span.TotalMinutes}m";
if (span < TimeSpan.FromDays(1)) return $"resets in {(int)span.TotalHours}h{span.Minutes:00}m";
return $"resets in {(int)span.TotalDays}d{span.Hours}h";
}
}

95
README.md Normal file
View File

@@ -0,0 +1,95 @@
# Claude Overview
A small native Windows widget that shows your **active Claude Code chats started in VS Code** (running inside WSL) and their token usage. It docks to the bottom-right corner of a chosen monitor and toggles between two states:
- **State 1 (minimized):** a 44×44 arrow button (`◤`) in the bottom-right corner.
- **State 2 (expanded):** a borderless panel listing each active chat — project, last activity, token usage, model — with a per-session total in the footer.
Click the arrow to expand; click `▾` to minimize. The window is borderless, always-on-top, and has no taskbar entry.
## How it works
Claude Code writes a transcript (`.jsonl`) per chat under `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl`. Since you run Claude in **WSL (Ubuntu)**, the app reads those files over the Windows share:
```
\\wsl.localhost\Ubuntu\home\philipp\.claude\projects
```
For each transcript it:
- keeps only chats with `entrypoint = "claude-vscode"` (VS Code),
- treats a chat as **active** if the file was written within `activeMinutes` (default 5),
- sums `usage` tokens (input / output / cache read / cache creation) across assistant messages.
Only recently-modified files are fully parsed, so the scan stays fast regardless of history size. It rescans every `refreshSeconds` (default 4).
### Status states
Each chat shows one of three states, colour-coded by its status dot:
- **working…** (green) — Claude is generating or running an auto-approved tool.
- **waiting for you / question for you** (amber) — Claude finished its turn or posed a question.
- **needs permission** (red, e.g. `allow Bash?`) — Claude is blocked on a tool-permission prompt.
The permission state can't be read directly from the transcript (a pending tool call looks the same whether it's running or awaiting approval), so it's inferred: a tool call that **isn't** auto-approved by your permission settings and has sat without completing for `pendingPermissionSeconds` (default 8) is treated as a prompt. Auto-approval is checked against your `~/.claude/settings.json` and the project's `.claude/settings.json` / `settings.local.json` `permissions.allow` rules — so an allowlisted long-running command (a build, a test run) stays **working…** instead of being mislabelled.
## Requirements
- Windows 10/11 with WSL2 (you already run Claude in WSL).
- **.NET SDK 8** on the **Windows** side (not in WSL — WPF is Windows-only):
```powershell
winget install Microsoft.DotNet.SDK.8
```
## Build & run
From a **Windows** terminal (PowerShell or cmd), navigate to this folder via the WSL share and run it:
```powershell
cd \\wsl.localhost\Ubuntu\home\philipp\git\claude-local-overview-display
dotnet run -c Release
```
…or just double-click **`run-on-windows.cmd`** from Explorer.
> If building directly on the `\\wsl.localhost` UNC path misbehaves, copy the folder to a local path (e.g. `C:\Tools\ClaudeOverview`) and build there. It will still read the WSL transcripts over the share.
### A standalone exe (optional)
```powershell
dotnet publish -c Release -r win-x64 --self-contained false -o publish
```
Then run `publish\ClaudeOverview.exe`. To launch at login, drop a shortcut to it in `shell:startup`.
## Configuration — `config.json`
Lives next to the exe; edit and restart.
| Key | Default | Meaning |
|---|---|---|
| `wslDistro` | `Ubuntu` | WSL distro name (`wsl -l -q` to list). |
| `linuxHome` | `/home/philipp` | Your Linux home dir. |
| `projectsPathOverride` | `""` | Full Windows path to `.claude/projects`; overrides the two above. |
| `activeMinutes` | `5` | A chat is "active" if written within this many minutes. |
| `refreshSeconds` | `4` | Rescan interval. |
| `monitorIndex` | `-1` | Which monitor to dock to. `-1` = last screen (typical side monitor), `0` = primary, `1` = second… |
| `edgeMargin` | `12` | Gap (px) from the corner. |
| `onlyVsCode` | `true` | Only show VS Code chats. Set `false` to include CLI chats too. |
| `startMinimized` | `true` | Start in State 1. |
## Project layout
| File | Purpose |
|---|---|
| `ClaudeOverview.csproj` / `app.manifest` | WPF app targeting `net8.0-windows`, per-monitor DPI aware. |
| `App.xaml` | Theme/brushes and shared styles. |
| `MainWindow.xaml` / `.cs` | The widget: both states, corner docking, refresh timer. |
| `Models/ClaudeSession.cs` | One parsed chat + usage totals and display helpers. |
| `Services/SessionScanner.cs` | Enumerates and parses transcripts. |
| `Services/AppConfig.cs` | Loads `config.json`. |
## Troubleshooting
- **"Projects path not found"** in the panel: WSL may be stopped — open a WSL terminal once, or check `wslDistro` / `linuxHome` in `config.json`.
- **Nothing listed:** no VS Code chat has been touched in the last `activeMinutes`. Start/continue a chat in VS Code, or raise `activeMinutes`.
- **Wrong monitor / corner:** adjust `monitorIndex`. You can also drag the expanded panel by its header.

140
Services/AppConfig.cs Normal file
View File

@@ -0,0 +1,140 @@
using System.IO;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace ClaudeOverview.Services;
/// <summary>Settings loaded from config.json next to the executable.</summary>
public sealed class AppConfig
{
public string WslDistro { get; set; } = "Ubuntu";
public string LinuxHome { get; set; } = "/home/philipp";
public string ProjectsPathOverride { get; set; } = "";
public int ActiveMinutes { get; set; } = 5;
/// <summary>
/// Fallback window (minutes) for showing "cold" (inactive) chats when the session-limit reset
/// time isn't available. Normally the cold cutoff is the start of the current session-limit
/// window (its reset time minus the 5-hour block); this is only used when that's unknown.
/// </summary>
public int ColdMinutes { get; set; } = 1440;
public int RefreshSeconds { get; set; } = 4;
/// <summary>Seconds a pending tool may sit before we treat it as a permission prompt (vs still running).</summary>
public int PendingPermissionSeconds { get; set; } = 8;
public int MonitorIndex { get; set; } = -1;
public int EdgeMargin { get; set; } = 12;
public bool OnlyVsCode { get; set; } = true;
public bool StartMinimized { get; set; } = true;
public bool FlashOnAttention { get; set; } = true;
public bool SoundOnAttention { get; set; } = false;
public bool ShowSessionLimit { get; set; } = true;
/// <summary>How often to poll the subscription usage endpoint (changes slowly).</summary>
public int UsageRefreshSeconds { get; set; } = 60;
/// <summary>Path to .credentials.json (sibling of the projects dir, i.e. inside .claude).</summary>
public string CredentialsPath
{
get
{
var claudeDir = Path.GetDirectoryName(ProjectsPath);
return claudeDir is null
? ""
: Path.Combine(claudeDir, ".credentials.json");
}
}
/// <summary>Resolves the Windows path to the .claude/projects directory.</summary>
public string ProjectsPath
{
get
{
if (!string.IsNullOrWhiteSpace(ProjectsPathOverride))
return ProjectsPathOverride;
// \\wsl.localhost\<Distro>\<linuxHome>\.claude\projects
var home = LinuxHome.Replace('/', '\\').TrimStart('\\');
return $@"\\wsl.localhost\{WslDistro}\{home}\.claude\projects";
}
}
/// <summary>
/// Translates a Linux path from a transcript (e.g. <c>/home/philipp/git/foo</c>) to the Windows
/// UNC path that reaches it over the WSL share (<c>\\wsl.localhost\Ubuntu\home\philipp\git\foo</c>).
/// </summary>
public string ToWindowsPath(string linuxPath)
{
var rel = linuxPath.Replace('/', '\\').TrimStart('\\');
return $@"\\wsl.localhost\{WslDistro}\{rel}";
}
public static AppConfig Load()
{
try
{
var path = Path.Combine(AppContext.BaseDirectory, "config.json");
if (!File.Exists(path)) return new AppConfig();
var json = File.ReadAllText(path);
var opts = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
return JsonSerializer.Deserialize<AppConfig>(json, opts) ?? new AppConfig();
}
catch
{
return new AppConfig();
}
}
private static string ConfigFilePath => Path.Combine(AppContext.BaseDirectory, "config.json");
/// <summary>
/// Persists the runtime-editable settings back to config.json, updating values in place
/// so the file's comments and other keys are preserved.
/// </summary>
public void Save()
{
string text = File.Exists(ConfigFilePath)
? File.ReadAllText(ConfigFilePath)
: "{\n}\n";
text = UpsertInt(text, "monitorIndex", MonitorIndex);
text = UpsertInt(text, "activeMinutes", ActiveMinutes);
text = UpsertInt(text, "refreshSeconds", RefreshSeconds);
text = UpsertBool(text, "flashOnAttention", FlashOnAttention);
text = UpsertBool(text, "soundOnAttention", SoundOnAttention);
File.WriteAllText(ConfigFilePath, text);
}
private static string UpsertBool(string json, string key, bool value)
{
var rx = new Regex($"(?<!//\\s)(\"{Regex.Escape(key)}\"\\s*:\\s*)(?:true|false)");
var literal = value ? "true" : "false";
if (rx.IsMatch(json))
return rx.Replace(json, m => m.Groups[1].Value + literal, 1);
int brace = json.IndexOf('{');
if (brace < 0) return json;
return json.Insert(brace + 1, $"\n \"{key}\": {literal},");
}
/// <summary>Replaces the integer value of a JSON key, or inserts the key if missing.</summary>
private static string UpsertInt(string json, string key, int value)
{
// Match the *real* key, not a "// key" comment line: require it isn't preceded by "// ".
var rx = new Regex($"(?<!//\\s)(\"{Regex.Escape(key)}\"\\s*:\\s*)-?\\d+");
if (rx.IsMatch(json))
return rx.Replace(json, m => m.Groups[1].Value + value, 1);
// Not present — insert right after the opening brace.
int brace = json.IndexOf('{');
if (brace < 0) return json;
return json.Insert(brace + 1, $"\n \"{key}\": {value},");
}
}

169
Services/PermissionRules.cs Normal file
View File

@@ -0,0 +1,169 @@
using System.IO;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace ClaudeOverview.Services;
/// <summary>
/// A merged view of the user's Claude Code permission settings, used to decide whether a given
/// tool call would run silently (auto-approved) or pop a permission prompt. This is an
/// approximation of Claude Code's own matcher — good enough to keep the overview widget from
/// mislabelling an allowlisted long-running tool as "needs permission".
/// </summary>
public sealed class PermissionRules
{
// Tools allowed for any input, e.g. an "Edit" rule with no parens.
private readonly HashSet<string> _allowAll = new(StringComparer.OrdinalIgnoreCase);
// Tool -> specifiers it's allowed for, e.g. Bash -> ["npm run build:*", "ls:*"].
private readonly Dictionary<string, List<string>> _allowSpec = new(StringComparer.OrdinalIgnoreCase);
private bool _bypassAll; // defaultMode = bypassPermissions
private bool _acceptEdits; // defaultMode = acceptEdits (file edits don't prompt)
private static readonly string[] EditTools =
{ "Edit", "Write", "MultiEdit", "NotebookEdit" };
/// <summary>True when <paramref name="tool"/> with <paramref name="arg"/> would run without prompting.</summary>
public bool Allows(string tool, string arg)
{
if (_bypassAll) return true;
if (_acceptEdits && Array.Exists(EditTools, t => t.Equals(tool, StringComparison.OrdinalIgnoreCase)))
return true;
if (_allowAll.Contains(tool)) return true;
if (_allowSpec.TryGetValue(tool, out var specs))
foreach (var spec in specs)
if (Matches(tool, spec, arg))
return true;
return false;
}
/// <summary>True only when every pending tool/arg pair is auto-approved.</summary>
public bool AllowsEvery(IReadOnlyList<string> tools, IReadOnlyList<string> args)
{
if (tools.Count == 0) return false;
for (int i = 0; i < tools.Count; i++)
{
var arg = i < args.Count ? args[i] : "";
if (!Allows(tools[i], arg)) return false;
}
return true;
}
private static bool Matches(string tool, string spec, string arg)
{
if (spec is "*" or "") return true;
// Bash specifiers are command prefixes; "git push:*" allows any command starting "git push".
if (tool.Equals("Bash", StringComparison.OrdinalIgnoreCase))
{
if (spec.EndsWith(":*"))
return arg.StartsWith(spec[..^2], StringComparison.Ordinal);
return arg.Equals(spec, StringComparison.Ordinal);
}
// Everything else (paths, urls): treat the specifier as a glob over the argument.
return GlobMatch(spec, arg);
}
private static bool GlobMatch(string pattern, string text)
{
// Translate a gitignore-ish glob to a regex: ** = any, * = any except '/'.
var rx = "^" + Regex.Escape(pattern)
.Replace(@"\*\*", ".*")
.Replace(@"\*", "[^/]*")
.Replace(@"\?", ".") + "$";
try { return Regex.IsMatch(text, rx); }
catch { return false; }
}
// ----- Loading -----
/// <summary>
/// Loads and merges permission rules for a chat with the given working directory: user-level
/// settings plus the project's settings.json and settings.local.json. Missing files are ignored.
/// </summary>
public static PermissionRules Load(AppConfig config, string cwd)
{
var rules = new PermissionRules();
// ~/.claude/settings.json
rules.MergeFile(config.ToWindowsPath($"{config.LinuxHome}/.claude/settings.json"));
if (!string.IsNullOrWhiteSpace(cwd))
{
var projectDir = config.ToWindowsPath($"{cwd.TrimEnd('/')}/.claude");
rules.MergeFile(Path.Combine(projectDir, "settings.json"));
rules.MergeFile(Path.Combine(projectDir, "settings.local.json"));
}
return rules;
}
private void MergeFile(string path)
{
JsonDocument doc;
try
{
if (!File.Exists(path)) return;
doc = JsonDocument.Parse(File.ReadAllText(path));
}
catch { return; }
using (doc)
{
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return;
if (root.TryGetProperty("permissions", out var perms) && perms.ValueKind == JsonValueKind.Object)
{
AddRules(perms, "allow");
if (perms.TryGetProperty("defaultMode", out var modeEl) &&
modeEl.ValueKind == JsonValueKind.String)
ApplyMode(modeEl.GetString());
}
// Legacy / top-level forms.
AddRules(root, "allowedTools");
if (root.TryGetProperty("defaultMode", out var topMode) &&
topMode.ValueKind == JsonValueKind.String)
ApplyMode(topMode.GetString());
}
}
private void ApplyMode(string? mode)
{
if (string.Equals(mode, "bypassPermissions", StringComparison.OrdinalIgnoreCase)) _bypassAll = true;
else if (string.Equals(mode, "acceptEdits", StringComparison.OrdinalIgnoreCase)) _acceptEdits = true;
}
private void AddRules(JsonElement parent, string key)
{
if (!parent.TryGetProperty(key, out var arr) || arr.ValueKind != JsonValueKind.Array) return;
foreach (var item in arr.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.String) continue;
var rule = item.GetString();
if (string.IsNullOrWhiteSpace(rule)) continue;
// "Tool" or "Tool(specifier)".
int open = rule.IndexOf('(');
if (open < 0)
{
_allowAll.Add(rule.Trim());
continue;
}
var tool = rule[..open].Trim();
int close = rule.LastIndexOf(')');
var spec = close > open ? rule[(open + 1)..close].Trim() : "";
if (!_allowSpec.TryGetValue(tool, out var list))
_allowSpec[tool] = list = new List<string>();
list.Add(spec);
}
}
}

335
Services/SessionScanner.cs Normal file
View File

@@ -0,0 +1,335 @@
using System.IO;
using System.Text.Json;
using ClaudeOverview.Models;
namespace ClaudeOverview.Services;
/// <summary>
/// Scans the .claude/projects directory for transcript (.jsonl) files that were
/// written recently, and parses each into a <see cref="ClaudeSession"/> with usage totals.
/// Only "active" files (modified within the configured window) are fully parsed, so the
/// scan stays cheap even with a large history.
/// </summary>
public sealed class SessionScanner
{
private readonly AppConfig _config;
public SessionScanner(AppConfig config) => _config = config;
public string ProjectsPath => _config.ProjectsPath;
/// <summary>
/// Start of the current session-limit window (set from usage data). Cold chats are shown back
/// to this point, and per-chat session-token shares are measured from here; when null we fall
/// back to <see cref="AppConfig.ColdMinutes"/> for the cutoff and count all tokens for shares.
/// </summary>
public DateTime? ColdCutoffUtc { get; set; }
/// <summary>Returns the active VS Code chats, most recently active first.</summary>
public IReadOnlyList<ClaudeSession> Scan(out string? error)
{
error = null;
var results = new List<ClaudeSession>();
var root = _config.ProjectsPath;
if (!Directory.Exists(root))
{
error = $"Projects path not found:\n{root}\n\nIs WSL running? Check config.json.";
return results;
}
var now = DateTime.UtcNow;
var activeCutoff = now - TimeSpan.FromMinutes(_config.ActiveMinutes);
// Cold chats are listed (dimmed) back to the start of the current session-limit window;
// if that's unknown, fall back to the configured cold window.
var coldCutoff = ColdCutoffUtc ?? (now - TimeSpan.FromMinutes(_config.ColdMinutes));
if (coldCutoff > activeCutoff) coldCutoff = activeCutoff; // never narrower than the active window
// Permission rules are loaded lazily and cached per working directory for this scan, so we
// read each project's settings files at most once even when several chats share a cwd.
var rulesByCwd = new Dictionary<string, PermissionRules>(StringComparer.OrdinalIgnoreCase);
IEnumerable<string> files;
try
{
files = Directory.EnumerateFiles(root, "*.jsonl", SearchOption.AllDirectories);
}
catch (Exception ex)
{
error = $"Could not read transcripts: {ex.Message}";
return results;
}
foreach (var file in files)
{
DateTime mtimeUtc;
try { mtimeUtc = File.GetLastWriteTimeUtc(file); }
catch { continue; }
if (mtimeUtc < coldCutoff) continue; // older than the cold window — skip the expensive parse
var session = TryParse(file, mtimeUtc, ColdCutoffUtc);
if (session is null) continue;
if (_config.OnlyVsCode && !session.IsVsCode) continue;
session.IsActive = session.LastActivityUtc >= activeCutoff;
ResolvePendingPermission(session, rulesByCwd);
results.Add(session);
}
// Each chat's slice of the total tokens consumed across all listed chats this window.
long sessionSum = 0;
foreach (var s in results) sessionSum += s.SessionTokens;
if (sessionSum > 0)
foreach (var s in results)
s.SessionSharePercent = 100.0 * s.SessionTokens / sessionSum;
// Active chats first, then cold ones. Among active, those waiting on the user lead;
// within each group, most recently active first.
results.Sort((a, b) =>
{
if (a.IsActive != b.IsActive) return a.IsActive ? -1 : 1;
if (a.IsActive && a.IsWaiting != b.IsWaiting) return a.IsWaiting ? -1 : 1;
return b.LastActivityUtc.CompareTo(a.LastActivityUtc);
});
return results;
}
private static ClaudeSession? TryParse(string file, DateTime mtimeUtc, DateTime? sessionWindowStartUtc)
{
var session = new ClaudeSession
{
SessionId = Path.GetFileNameWithoutExtension(file),
FilePath = file,
LastActivityUtc = mtimeUtc
};
var sawAnything = false;
try
{
// FileShare.ReadWrite so we can read while Claude Code is still appending.
using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream);
string? line;
while ((line = reader.ReadLine()) is not null)
{
if (line.Length == 0) continue;
JsonDocument doc;
try { doc = JsonDocument.Parse(line); }
catch { continue; } // tolerate a half-written trailing line
using (doc)
{
ApplyLine(session, doc.RootElement, sessionWindowStartUtc);
sawAnything = true;
}
}
}
catch
{
return sawAnything ? session : null;
}
return sawAnything ? session : null;
}
private static void ApplyLine(ClaudeSession s, JsonElement root, DateTime? sessionWindowStartUtc)
{
if (root.ValueKind != JsonValueKind.Object) return;
// VS Code writes the generated chat name as its own "ai-title" entry (no message body).
if (TryStr(root, "type", out var lineType) && lineType == "ai-title" &&
TryStr(root, "aiTitle", out var aiTitle) && aiTitle.Length > 0)
{
s.AiTitle = aiTitle;
return;
}
if (TryStr(root, "entrypoint", out var ep) && ep.Length > 0) s.Entrypoint = ep;
if (TryStr(root, "cwd", out var cwd) && cwd.Length > 0) s.Cwd = cwd;
if (TryStr(root, "gitBranch", out var gb) && gb.Length > 0) s.GitBranch = gb;
DateTime? lineTimeUtc = null;
if (TryStr(root, "timestamp", out var ts) &&
DateTime.TryParse(ts, null, System.Globalization.DateTimeStyles.AdjustToUniversal |
System.Globalization.DateTimeStyles.AssumeUniversal, out var dt))
{
lineTimeUtc = dt;
if (dt > s.LastActivityUtc) s.LastActivityUtc = dt;
}
if (!root.TryGetProperty("message", out var msg) || msg.ValueKind != JsonValueKind.Object)
return;
var role = TryStr(msg, "role", out var r) ? r : "";
// Sub-agent (sidechain) turns consume tokens but don't reflect the main thread's
// wait state — count their usage, but don't let them drive the status.
var isSidechain = root.TryGetProperty("isSidechain", out var sc) &&
sc.ValueKind == JsonValueKind.True;
if (role == "assistant")
{
if (TryStr(msg, "model", out var model) && model.Length > 0) s.Model = model;
if (msg.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object)
{
var input = GetLong(usage, "input_tokens");
var output = GetLong(usage, "output_tokens");
var cacheRead = GetLong(usage, "cache_read_input_tokens");
var cacheCreate = GetLong(usage, "cache_creation_input_tokens");
s.InputTokens += input;
s.OutputTokens += output;
s.CacheReadTokens += cacheRead;
s.CacheCreationTokens += cacheCreate;
s.MessageCount++;
// Count toward this session's window only for messages sent after it started
// (or everything when the window start is unknown).
if (sessionWindowStartUtc is null ||
(lineTimeUtc is { } lt && lt >= sessionWindowStartUtc))
s.SessionTokens += input + output + cacheRead + cacheCreate;
}
if (!isSidechain)
{
s.LastRole = "assistant";
s.LastStopReason = TryStr(msg, "stop_reason", out var sr) ? sr : "";
(s.LastToolNames, s.LastToolArgs, s.LastAssistantText) = ReadAssistantContent(msg);
// A turn streams as several lines (think → text → tool_use); only the tool_use line
// leaves a call awaiting a result. Any later result/human line clears this below.
s.AwaitingToolResult = s.LastToolNames.Count > 0;
}
}
else if (role == "user")
{
if (!isSidechain)
{
// Whether it's a tool_result or genuine human input, the prior tool_use is now
// resolved — so long thinking after a quick command isn't read as a permission wait.
s.AwaitingToolResult = false;
if (IsHumanTurn(msg))
s.LastRole = "user";
}
if (s.Title.Length == 0)
{
var text = ExtractUserText(msg);
if (!string.IsNullOrWhiteSpace(text) && !text.StartsWith("<") && !text.StartsWith("/"))
s.Title = Truncate(text.Trim(), 90);
}
}
}
/// <summary>Extracts tool-call names, their primary arguments, and the trailing text from an assistant message.</summary>
private static (List<string> tools, List<string> args, string text) ReadAssistantContent(JsonElement msg)
{
var tools = new List<string>();
var args = new List<string>();
var text = "";
if (msg.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Array)
{
foreach (var part in content.EnumerateArray())
{
if (part.ValueKind != JsonValueKind.Object) continue;
var type = TryStr(part, "type", out var t) ? t : "";
if (type == "tool_use" && TryStr(part, "name", out var name))
{
tools.Add(name);
args.Add(part.TryGetProperty("input", out var input) ? PrimaryArg(input) : "");
}
else if (type == "text" && TryStr(part, "text", out var txt))
text = txt;
}
}
return (tools, args, text);
}
/// <summary>Picks the field of a tool's input that permission rules match on (command / path / url / pattern).</summary>
private static string PrimaryArg(JsonElement input)
{
if (input.ValueKind != JsonValueKind.Object) return "";
foreach (var key in new[] { "command", "file_path", "path", "url", "pattern", "notebook_path" })
if (TryStr(input, key, out var v) && v.Length > 0)
return v;
return "";
}
/// <summary>
/// Decides whether a chat sitting on a pending tool_use is auto-approved by the user's permission
/// settings (so a stale file means a long-running tool, not a prompt awaiting approval).
/// </summary>
private void ResolvePendingPermission(ClaudeSession s, Dictionary<string, PermissionRules> cache)
{
if (!s.AwaitingToolResult || s.LastToolNames.Count == 0)
return;
if (!cache.TryGetValue(s.Cwd, out var rules))
cache[s.Cwd] = rules = PermissionRules.Load(_config, s.Cwd);
s.PendingToolsAutoApproved = rules.AllowsEvery(s.LastToolNames, s.LastToolArgs);
}
/// <summary>
/// True when a user message is genuine human input (not a tool_result fed back to Claude).
/// </summary>
private static bool IsHumanTurn(JsonElement msg)
{
if (!msg.TryGetProperty("content", out var content)) return true;
if (content.ValueKind == JsonValueKind.String) return true;
if (content.ValueKind == JsonValueKind.Array)
{
foreach (var part in content.EnumerateArray())
{
if (part.ValueKind == JsonValueKind.Object &&
TryStr(part, "type", out var t) && t == "tool_result")
return false; // tool output, Claude will continue
}
}
return true;
}
private static string ExtractUserText(JsonElement msg)
{
if (!msg.TryGetProperty("content", out var content)) return "";
if (content.ValueKind == JsonValueKind.String)
return content.GetString() ?? "";
if (content.ValueKind == JsonValueKind.Array)
{
foreach (var part in content.EnumerateArray())
{
if (part.ValueKind == JsonValueKind.Object &&
TryStr(part, "type", out var t) && t == "text" &&
TryStr(part, "text", out var txt))
return txt;
}
}
return "";
}
private static bool TryStr(JsonElement e, string name, out string value)
{
value = "";
if (e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String)
{
value = p.GetString() ?? "";
return true;
}
return false;
}
private static long GetLong(JsonElement e, string name) =>
e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.Number && p.TryGetInt64(out var v)
? v : 0;
private static string Truncate(string s, int max) =>
s.Length <= max ? s : s[..max].TrimEnd() + "…";
}

113
Services/UsageService.cs Normal file
View File

@@ -0,0 +1,113 @@
using System.IO;
using System.Net.Http;
using System.Text.Json;
using ClaudeOverview.Models;
namespace ClaudeOverview.Services;
/// <summary>
/// Fetches subscription usage (session / weekly limits) from the same endpoint Claude
/// Code's /usage command uses, authenticating with the OAuth token stored in
/// ~/.claude/.credentials.json (read fresh each call so it tracks token refreshes).
/// </summary>
public sealed class UsageService
{
private const string Endpoint = "https://api.anthropic.com/api/oauth/usage";
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(12) };
private readonly AppConfig _config;
public UsageService(AppConfig config) => _config = config;
public async Task<UsageInfo> FetchAsync()
{
try
{
var token = ReadAccessToken(out var tokenError);
if (token is null) return new UsageInfo { Error = tokenError };
using var req = new HttpRequestMessage(HttpMethod.Get, Endpoint);
req.Headers.TryAddWithoutValidation("Authorization", "Bearer " + token);
req.Headers.TryAddWithoutValidation("anthropic-beta", "oauth-2025-04-20");
using var resp = await Http.SendAsync(req);
if (!resp.IsSuccessStatusCode)
{
if (resp.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
return new UsageInfo
{
Error = "rate limited (429 — backing off)",
RateLimited = true,
RetryAfter = resp.Headers.RetryAfter?.Delta
?? (resp.Headers.RetryAfter?.Date is { } retryAt
? retryAt.ToUniversalTime() - DateTimeOffset.UtcNow
: null),
};
}
var hint = resp.StatusCode == System.Net.HttpStatusCode.Unauthorized
? " (token expired — open Claude Code to refresh)"
: "";
return new UsageInfo { Error = $"usage HTTP {(int)resp.StatusCode}{hint}" };
}
await using var stream = await resp.Content.ReadAsStreamAsync();
using var doc = await JsonDocument.ParseAsync(stream);
var root = doc.RootElement;
var info = new UsageInfo();
if (root.TryGetProperty("five_hour", out var fh) && fh.ValueKind == JsonValueKind.Object)
{
info.SessionPercent = GetDouble(fh, "utilization");
info.SessionResetsAt = GetDate(fh, "resets_at");
}
if (root.TryGetProperty("seven_day", out var sd) && sd.ValueKind == JsonValueKind.Object)
{
info.WeeklyPercent = GetDouble(sd, "utilization");
info.WeeklyResetsAt = GetDate(sd, "resets_at");
}
return info;
}
catch (Exception ex)
{
return new UsageInfo { Error = ex.Message };
}
}
private string? ReadAccessToken(out string? error)
{
error = null;
var path = _config.CredentialsPath;
if (!File.Exists(path)) { error = "not signed in (no credentials)"; return null; }
try
{
using var fs = File.OpenRead(path);
using var doc = JsonDocument.Parse(fs);
if (!doc.RootElement.TryGetProperty("claudeAiOauth", out var oauth))
{
error = "no OAuth session";
return null;
}
var token = oauth.TryGetProperty("accessToken", out var t) ? t.GetString() : null;
if (string.IsNullOrEmpty(token)) { error = "no access token"; return null; }
return token;
}
catch (Exception ex)
{
error = $"credentials read failed: {ex.Message}";
return null;
}
}
private static double GetDouble(JsonElement e, string name) =>
e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.Number ? p.GetDouble() : 0;
private static DateTime? GetDate(JsonElement e, string name) =>
e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String &&
DateTime.TryParse(p.GetString(), null,
System.Globalization.DateTimeStyles.AdjustToUniversal, out var dt)
? dt : null;
}

19
app.manifest Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="ClaudeOverview.app"/>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- Per-monitor DPI aware so corner docking is correct on a scaled side monitor -->
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10/11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
</assembly>

41
config.json Normal file
View File

@@ -0,0 +1,41 @@
{
"// comment": "Where to read Claude Code transcripts from. Default points at the WSL Ubuntu home over the wsl.localhost share.",
"wslDistro": "Ubuntu",
"linuxHome": "/home/philipp",
"// projectsPathOverride": "Set this to a full Windows path to bypass distro/home composition, e.g. \\\\\\\\wsl.localhost\\\\Ubuntu\\\\home\\\\philipp\\\\.claude\\\\projects",
"projectsPathOverride": "",
"// activeMinutes": "A chat counts as 'active' if its transcript was written within this many minutes.",
"activeMinutes": 5,
"// refreshSeconds": "How often to rescan transcripts.",
"refreshSeconds": 4,
"// pendingPermissionSeconds": "If a chat's last action is a tool call that hasn't completed within this many seconds, treat it as blocked on a permission prompt (needs your approval) rather than still running.",
"pendingPermissionSeconds": 8,
"// monitorIndex": "Which monitor to dock to. -1 = last screen (typical side monitor), 0 = primary, 1 = second, etc.",
"monitorIndex": 1,
"// edgeMargin": "Gap in pixels from the bottom-right corner of the monitor's working area.",
"edgeMargin": 12,
"// onlyVsCode": "If true, only show chats started in VS Code (entrypoint = claude-vscode).",
"onlyVsCode": true,
"// startMinimized": "If true, start in State 1 (arrow button).",
"startMinimized": true,
"// flashOnAttention": "Flash the widget when a chat finishes or asks for input.",
"flashOnAttention": true,
"// soundOnAttention": "Also play a short system sound on that event.",
"soundOnAttention": false,
"// showSessionLimit": "Show the Pro session/weekly usage meter (calls the Claude usage API with your OAuth token).",
"showSessionLimit": true,
"// usageRefreshSeconds": "How often to poll the usage API. The endpoint is shared with Claude Code itself, so keep this generous (the limit windows move slowly anyway). On an HTTP 429 the app automatically backs off well beyond this value.",
"usageRefreshSeconds": 150
}

17
run-on-windows.cmd Normal file
View File

@@ -0,0 +1,17 @@
@echo off
REM Build + run the Claude Overview widget on Windows.
REM Double-click this from Explorer at \\wsl.localhost\Ubuntu\home\philipp\git\claude-local-overview-display
REM or run it from a Windows terminal (cmd/PowerShell) in this folder.
setlocal
cd /d "%~dp0"
where dotnet >nul 2>nul
if errorlevel 1 (
echo .NET SDK not found. Install it first:
echo winget install Microsoft.DotNet.SDK.8
pause
exit /b 1
)
dotnet run -c Release