Init
This commit is contained in:
396
MainWindow.xaml.cs
Normal file
396
MainWindow.xaml.cs
Normal 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()
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user