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 _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 _seenIds = new(); private readonly HashSet _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); } /// /// 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. /// 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(); 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 e) => UpdateThresholdLabel((int)Math.Round(e.NewValue)); private void RefreshSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs 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(), ex.Message); } finally { _scanning = false; } } private void UpdateUi(IReadOnlyList 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); } /// /// 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). /// private void DetectAttention(IReadOnlyList 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() }; }