Files
claude-local-overview-display/MainWindow.xaml.cs

397 lines
14 KiB
C#
Raw Normal View History

2026-06-25 09:59:54 +02:00
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()
};
}