Files
claude-local-overview-display/MainWindow.xaml.cs
2026-06-25 09:59:54 +02:00

397 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
};
}