397 lines
14 KiB
C#
397 lines
14 KiB
C#
|
|
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()
|
|||
|
|
};
|
|||
|
|
}
|