diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index add4bfd..6bf4331 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -107,8 +107,31 @@ jobs: Compress-Archive -Path ./publish-hookbridge-self-contained/* -DestinationPath ./HookBridge-${{ github.ref_name }}-self-contained-win-x64.zip Compress-Archive -Path ./publish-hookbridge-framework-dependent/* -DestinationPath ./HookBridge-${{ github.ref_name }}-framework-dependent-win-x64.zip - - name: Create GitHub Release with auto-generated notes - run: gh release create ${{ github.ref_name }} --title "Claude Tracker ${{ github.ref_name }}" --generate-notes + - name: Generate release notes from merged PR + id: release_notes + shell: bash + run: | + # Find the PR that was merged for this tag's commit + COMMIT_SHA=$(git rev-list -1 ${{ github.ref_name }}) + PR_BODY=$(gh api repos/TobiiNT/ClaudeTracker/commits/$COMMIT_SHA/pulls --jq '.[0].body // empty' 2>/dev/null || echo "") + if [ -z "$PR_BODY" ]; then + # Fallback: auto-generate from commits + echo "notes<> $GITHUB_OUTPUT + echo "## What's Changed" >> $GITHUB_OUTPUT + gh api repos/TobiiNT/ClaudeTracker/compare/$(git describe --tags --abbrev=0 ${{ github.ref_name }}^)...${{ github.ref_name }} --jq '.commits[].commit.message' | head -20 | while read msg; do echo "- $msg"; done >> $GITHUB_OUTPUT + echo "NOTES_EOF" >> $GITHUB_OUTPUT + else + # Strip test plan and everything after it + CLEAN_BODY=$(echo "$PR_BODY" | sed '/^## Test plan/,$d' | sed -e :a -e '/^\n*$/{$d;N;ba}') + echo "notes<> $GITHUB_OUTPUT + echo "$CLEAN_BODY" >> $GITHUB_OUTPUT + echo "NOTES_EOF" >> $GITHUB_OUTPUT + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Create GitHub Release + run: gh release create ${{ github.ref_name }} --title "Claude Tracker ${{ github.ref_name }}" --notes "${{ steps.release_notes.outputs.notes }}" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/src/ClaudeTracker.HookBridge/Program.cs b/src/ClaudeTracker.HookBridge/Program.cs index 1af28cd..2a73bc7 100644 --- a/src/ClaudeTracker.HookBridge/Program.cs +++ b/src/ClaudeTracker.HookBridge/Program.cs @@ -36,19 +36,28 @@ private struct PROCESS_BASIC_INFORMATION private static string ClaudeSettingsPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude", "settings.json"); - private static readonly string[] AllEvents = + // Pre-v2.1.37: core hook events (existed from early hooks support) + private static readonly string[] CoreEvents = { "PreToolUse", "PostToolUse", "PostToolUseFailure", "PermissionRequest", "Notification", "Stop", "SessionStart", "SessionEnd", "UserPromptSubmit", "SubagentStart", "SubagentStop", - "PreCompact", "PostCompact", - "WorktreeCreate", "WorktreeRemove", - "InstructionsLoaded", "ConfigChange", - "Elicitation", "ElicitationResult", "TeammateIdle", "TaskCompleted" }; + // Version-gated events with the version they were introduced + private static readonly (string Event, int Major, int Minor, int Patch)[] VersionedEvents = + { + ("ConfigChange", 2, 1, 49), + ("WorktreeCreate", 2, 1, 50), + ("WorktreeRemove", 2, 1, 50), + ("InstructionsLoaded",2, 1, 69), + ("PostCompact", 2, 1, 76), + ("Elicitation", 2, 1, 76), + ("ElicitationResult", 2, 1, 76), + }; + private static readonly HashSet AsyncEvents = new() { "PostToolUse", "PostToolUseFailure", @@ -190,6 +199,69 @@ private static async Task HandleHookEvent() return 0; } + private static string? DetectClaudeCodeVersion() + { + try + { + var process = Process.Start(new ProcessStartInfo + { + FileName = "claude", + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true + }); + if (process == null) return null; + var output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(3000); + return output; + } + catch { return null; } + } + + private static bool VersionAtLeast(int major, int minor, int patch, int reqMajor, int reqMinor, int reqPatch) + { + if (major != reqMajor) return major > reqMajor; + if (minor != reqMinor) return minor > reqMinor; + return patch >= reqPatch; + } + + private static string[] GetSupportedEvents() + { + var version = DetectClaudeCodeVersion(); + var events = new List(CoreEvents); + + if (version == null) + { + Console.WriteLine(" Could not detect Claude Code version — installing core events only"); + return events.ToArray(); + } + + Console.WriteLine($" Claude Code version: {version}"); + + try + { + var parts = version.Split('.'); + if (parts.Length >= 3 && int.TryParse(parts[0], out var major) + && int.TryParse(parts[1], out var minor) + && int.TryParse(parts[2], out var patch)) + { + foreach (var (evt, reqMajor, reqMinor, reqPatch) in VersionedEvents) + { + if (VersionAtLeast(major, minor, patch, reqMajor, reqMinor, reqPatch)) + events.Add(evt); + else + Console.WriteLine($" Skipping {evt} (requires v{reqMajor}.{reqMinor}.{reqPatch})"); + } + return events.ToArray(); + } + } + catch { /* fall through */ } + + Console.WriteLine(" Could not parse version — installing core events only"); + return events.ToArray(); + } + // --- Install --- private static int Install() { @@ -228,8 +300,10 @@ private static int Install() settings["hooks"] = hooksObj; } + var eventsToInstall = GetSupportedEvents(); + // Register each event — preserve existing hooks from other tools - foreach (var eventName in AllEvents) + foreach (var eventName in eventsToInstall) { var hookConfig = new JsonObject { @@ -294,7 +368,7 @@ private static int Install() Console.WriteLine($"ClaudeTracker hooks installed successfully."); Console.WriteLine($" Settings: {settingsPath}"); Console.WriteLine($" Bridge: {exePath}"); - Console.WriteLine($" Events: {AllEvents.Length} registered"); + Console.WriteLine($" Events: {eventsToInstall.Length} registered"); return 0; } catch (Exception ex) diff --git a/src/ClaudeTracker/App.xaml.cs b/src/ClaudeTracker/App.xaml.cs index 6c14dee..7d6b72e 100644 --- a/src/ClaudeTracker/App.xaml.cs +++ b/src/ClaudeTracker/App.xaml.cs @@ -237,8 +237,12 @@ Events.Notification when entry.Summary.Contains("idle", StringComparison.Ordinal var body = !string.IsNullOrEmpty(entry.Detail) ? $"{entry.Summary}\n{entry.Detail}" : entry.Summary; + // Find cwd from session so clicking notification focuses the right terminal + var sessionCwd = sessionTracking.ActiveSessions + .FirstOrDefault(s => s.SessionId == entry.SessionId)?.Cwd; + ((NotificationService)notificationServiceForHooks).SendNotification( - title, body, level); + title, body, level, cwd: sessionCwd); } } }; @@ -263,6 +267,9 @@ Events.Notification when entry.Summary.Contains("idle", StringComparison.Ordinal }); }); + // Check Claude Code version for update notification + _ = Task.Run(() => CheckClaudeCodeVersion(settingsService)); + // Listen for system theme changes SystemEvents.UserPreferenceChanged += OnUserPreferenceChanged; @@ -402,6 +409,77 @@ public static void ApplyTheme(string theme) paletteHelper.SetTheme(mdTheme); } + private void CheckClaudeCodeVersion(ISettingsService settingsService) + { + try + { + var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "claude", + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true + }); + if (process == null) return; + var output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(3000); + + if (string.IsNullOrEmpty(output)) return; + + LoggingService.Instance.Log($"Claude Code version detected: {output}"); + + // Check if there's a newer version available via WebFetch to GitHub releases API + var latestVersion = GetLatestClaudeCodeVersion(); + if (latestVersion != null && latestVersion != output && IsNewerVersion(latestVersion, output)) + { + Dispatcher.Invoke(() => + { + var notificationService = _services!.GetRequiredService(); + ((NotificationService)notificationService).SendNotification( + "Claude Code Update Available", + $"Version {latestVersion} is available (current: {output}). Run 'npm update -g @anthropic-ai/claude-code' to update.", + Views.NotificationPopup.NotificationLevel.Info); + }); + } + } + catch (Exception ex) + { + LoggingService.Instance.LogWarning($"Claude Code version check failed: {ex.Message}"); + } + } + + private static string? GetLatestClaudeCodeVersion() + { + try + { + using var client = new System.Net.Http.HttpClient(); + client.DefaultRequestHeaders.UserAgent.ParseAdd("ClaudeTracker"); + var response = client.GetAsync("https://registry.npmjs.org/@anthropic-ai/claude-code/latest").Result; + if (!response.IsSuccessStatusCode) return null; + var json = response.Content.ReadAsStringAsync().Result; + using var doc = System.Text.Json.JsonDocument.Parse(json); + return doc.RootElement.GetProperty("version").GetString(); + } + catch { return null; } + } + + private static bool IsNewerVersion(string latest, string current) + { + try + { + var l = latest.Split('.').Select(int.Parse).ToArray(); + var c = current.Split('.').Select(int.Parse).ToArray(); + for (int i = 0; i < Math.Min(l.Length, c.Length); i++) + { + if (l[i] > c[i]) return true; + if (l[i] < c[i]) return false; + } + return false; + } + catch { return false; } + } + private void OnUserPreferenceChanged(object sender, UserPreferenceChangedEventArgs e) { if (e.Category == UserPreferenceCategory.General) diff --git a/src/ClaudeTracker/ClaudeTracker.csproj b/src/ClaudeTracker/ClaudeTracker.csproj index fb01065..b39f6ff 100644 --- a/src/ClaudeTracker/ClaudeTracker.csproj +++ b/src/ClaudeTracker/ClaudeTracker.csproj @@ -9,9 +9,9 @@ Assets\app_icon.ico ClaudeTracker ClaudeTracker - 2.0.4 - 2.0.4.0 - 2.0.4.0 + 2.1.0 + 2.1.0.0 + 2.1.0.0 Claude AI Usage Tracker for Windows TobiiNT Tobii Development diff --git a/src/ClaudeTracker/Models/AppSettings.cs b/src/ClaudeTracker/Models/AppSettings.cs index ddc6ed0..ef5e8fe 100644 --- a/src/ClaudeTracker/Models/AppSettings.cs +++ b/src/ClaudeTracker/Models/AppSettings.cs @@ -82,6 +82,9 @@ public class AppSettings [JsonPropertyName("hookPopupPosition")] public string HookPopupPosition { get; set; } = "BottomRight"; + [JsonPropertyName("hookPopupMonitor")] + public int HookPopupMonitor { get; set; } = 0; + [JsonPropertyName("hooksEnabled")] public bool HooksEnabled { get; set; } diff --git a/src/ClaudeTracker/Models/ClaudeUsage.cs b/src/ClaudeTracker/Models/ClaudeUsage.cs index 86365a8..10a4acc 100644 --- a/src/ClaudeTracker/Models/ClaudeUsage.cs +++ b/src/ClaudeTracker/Models/ClaudeUsage.cs @@ -58,6 +58,10 @@ public class ClaudeUsage [JsonPropertyName("costCurrency")] public string? CostCurrency { get; set; } + /// Error message if cost data fetch failed (e.g., no permission in org). + [JsonIgnore] + public string? CostFetchError { get; set; } + // Metadata [JsonPropertyName("lastUpdated")] public DateTime LastUpdated { get; set; } diff --git a/src/ClaudeTracker/Services/ClaudeApiService.cs b/src/ClaudeTracker/Services/ClaudeApiService.cs index a0abc88..da8b7e1 100644 --- a/src/ClaudeTracker/Services/ClaudeApiService.cs +++ b/src/ClaudeTracker/Services/ClaudeApiService.cs @@ -200,7 +200,16 @@ public async Task FetchUsageData() claudeUsage.CostCurrency = overage.Currency; } } - catch { /* overage is optional */ } + catch (HttpRequestException ex) when (ex.Message.Contains("Unauthorized")) + { + claudeUsage.CostFetchError = "No permission to access cost data"; + LoggingService.Instance.LogWarning("Cost data fetch: no permission (organization restriction)"); + } + catch (Exception ex) + { + claudeUsage.CostFetchError = ex.Message; + LoggingService.Instance.LogWarning($"Cost data fetch failed: {ex.Message}"); + } } return claudeUsage; diff --git a/src/ClaudeTracker/Services/Handlers/StopHandler.cs b/src/ClaudeTracker/Services/Handlers/StopHandler.cs index 873c324..6d1d54c 100644 --- a/src/ClaudeTracker/Services/Handlers/StopHandler.cs +++ b/src/ClaudeTracker/Services/Handlers/StopHandler.cs @@ -35,14 +35,15 @@ public Task HandleAsync(HookEvent evt) var prefs = _settingsService.Settings.HookNotificationPreferences; if (prefs.TryGetValue("stop", out var enabled) && enabled) { - var projectName = ParseProjectName(evt.Payload); + var cwd = ParseCwd(evt.Payload); + var projectName = string.IsNullOrEmpty(cwd) ? "" : Path.GetFileName(cwd) ?? cwd; var title = "Task Complete"; var message = string.IsNullOrEmpty(projectName) ? "Claude Code has finished processing." : $"Claude Code has finished processing in {projectName}."; ((NotificationService)_notificationService).SendNotification( - title, message, NotificationPopup.NotificationLevel.Info); + title, message, NotificationPopup.NotificationLevel.Info, cwd: cwd); LoggingService.Instance.Log($"StopHandler: Sent completion notification for project '{projectName}'"); } @@ -60,7 +61,7 @@ public Task HandleAsync(HookEvent evt) }); } - private static string ParseProjectName(string payload) + private static string ParseCwd(string payload) { if (string.IsNullOrWhiteSpace(payload)) return string.Empty; @@ -68,12 +69,7 @@ private static string ParseProjectName(string payload) try { var node = JsonNode.Parse(payload); - var cwd = node?[Fields.Cwd]?.GetValue(); - - if (string.IsNullOrEmpty(cwd)) - return string.Empty; - - return Path.GetFileName(cwd) ?? cwd; + return node?[Fields.Cwd]?.GetValue() ?? string.Empty; } catch { diff --git a/src/ClaudeTracker/Services/NotificationService.cs b/src/ClaudeTracker/Services/NotificationService.cs index e2feb88..8d377f1 100644 --- a/src/ClaudeTracker/Services/NotificationService.cs +++ b/src/ClaudeTracker/Services/NotificationService.cs @@ -104,14 +104,21 @@ private void SendThresholdNotification(string profileKey, int threshold, double } public void SendNotification(string title, string message, - NotificationPopup.NotificationLevel level = NotificationPopup.NotificationLevel.Warning) + NotificationPopup.NotificationLevel level = NotificationPopup.NotificationLevel.Warning, + string? cwd = null) { try { Application.Current.Dispatcher.Invoke(() => { var popup = new NotificationPopup(title, message, level); - popup.NotificationClicked += (_, _) => NotificationClicked?.Invoke(this, EventArgs.Empty); + popup.NotificationClicked += (_, _) => + { + if (!string.IsNullOrEmpty(cwd)) + Utilities.TerminalFocusHelper.BringToFront(cwd); + else + NotificationClicked?.Invoke(this, EventArgs.Empty); + }; popup.Show(); }); diff --git a/src/ClaudeTracker/Services/UsageRefreshCoordinator.cs b/src/ClaudeTracker/Services/UsageRefreshCoordinator.cs index ef87e97..c96428e 100644 --- a/src/ClaudeTracker/Services/UsageRefreshCoordinator.cs +++ b/src/ClaudeTracker/Services/UsageRefreshCoordinator.cs @@ -13,6 +13,7 @@ public class UsageRefreshCoordinator : IUsageRefreshCoordinator, IDisposable private readonly INotificationService _notificationService; private readonly IClaudeStatusService _statusService; private DispatcherTimer? _timer; + private DispatcherTimer? _resetTimer; private ClaudeStatus _cachedStatus = ClaudeStatus.Unknown; private DateTime _lastStatusFetch = DateTime.MinValue; private bool _isRefreshing; @@ -110,6 +111,9 @@ private async Task RefreshAsync() var usage = await _apiService.FetchUsageData(); _profileService.UpdateUsageData(profile.Id, claudeUsage: usage); _notificationService.CheckAndNotify(profile, usage); + + // Schedule auto-refresh when session resets (limit lifts) + ScheduleResetRefresh(usage.SessionResetTime); } _notificationService.CheckKeyExpiry(profile); @@ -149,6 +153,25 @@ private async Task RefreshAsync() } } + private void ScheduleResetRefresh(DateTime resetTime) + { + var delay = resetTime - DateTime.UtcNow; + if (delay.TotalSeconds <= 0 || delay.TotalHours > 6) return; // already passed or too far out + + // Cancel any existing reset timer + _resetTimer?.Stop(); + + _resetTimer = new DispatcherTimer { Interval = delay + TimeSpan.FromSeconds(2) }; // 2s buffer + _resetTimer.Tick += async (_, _) => + { + _resetTimer?.Stop(); + LoggingService.Instance.Log("Session reset time reached — auto-refreshing"); + await RefreshAsync(); + }; + _resetTimer.Start(); + LoggingService.Instance.Log($"Scheduled auto-refresh at session reset ({resetTime:HH:mm:ss} UTC)"); + } + private async void OnPowerModeChanged(object sender, Microsoft.Win32.PowerModeChangedEventArgs e) { if (e.Mode == Microsoft.Win32.PowerModes.Resume) diff --git a/src/ClaudeTracker/Utilities/PopupStackManager.cs b/src/ClaudeTracker/Utilities/PopupStackManager.cs index 10a1527..82bb267 100644 --- a/src/ClaudeTracker/Utilities/PopupStackManager.cs +++ b/src/ClaudeTracker/Utilities/PopupStackManager.cs @@ -22,14 +22,62 @@ public static string GetPosition() try { var settings = App.Services.GetRequiredService(); - return settings.Settings.HookPopupPosition ?? "TopRight"; + return settings.Settings.HookPopupPosition ?? "BottomRight"; } catch { return "BottomRight"; } } + /// Returns the work area for the configured monitor (WPF device-independent units). + public static Rect GetWorkArea() + { + try + { + var settings = App.Services.GetRequiredService(); + var monitorIndex = settings.Settings.HookPopupMonitor; + + var screens = System.Windows.Forms.Screen.AllScreens; + if (monitorIndex >= 0 && monitorIndex < screens.Length) + { + var screen = screens[monitorIndex]; + var wa = screen.WorkingArea; + // Convert from physical pixels to WPF device-independent units + var dpi = GetDpiScale(); + return new Rect(wa.X / dpi, wa.Y / dpi, wa.Width / dpi, wa.Height / dpi); + } + } + catch { /* fall through */ } + + return SystemParameters.WorkArea; // primary monitor fallback + } + + /// Returns the list of monitor display names for settings UI. + public static string[] GetMonitorNames() + { + var screens = System.Windows.Forms.Screen.AllScreens; + var names = new string[screens.Length]; + for (int i = 0; i < screens.Length; i++) + { + var s = screens[i]; + names[i] = s.Primary ? $"Monitor {i + 1} (Primary)" : $"Monitor {i + 1}"; + } + return names; + } + + private static double GetDpiScale() + { + try + { + var source = PresentationSource.FromVisual(Application.Current.MainWindow); + if (source?.CompositionTarget != null) + return source.CompositionTarget.TransformToDevice.M11; + } + catch { /* fall through */ } + return 1.0; + } + public static void PositionWindow(Window popup) { - var workArea = SystemParameters.WorkArea; + var workArea = GetWorkArea(); var pos = GetPosition(); popup.Left = pos.Contains("Left") @@ -43,7 +91,7 @@ public static void PositionWindow(Window popup) public static void RepositionAll() { - var workArea = SystemParameters.WorkArea; + var workArea = GetWorkArea(); var pos = GetPosition(); var isBottom = pos.Contains("Bottom"); var isLeft = pos.Contains("Left"); diff --git a/src/ClaudeTracker/Utilities/TerminalFocusHelper.cs b/src/ClaudeTracker/Utilities/TerminalFocusHelper.cs new file mode 100644 index 0000000..78c1c19 --- /dev/null +++ b/src/ClaudeTracker/Utilities/TerminalFocusHelper.cs @@ -0,0 +1,83 @@ +using System.Runtime.InteropServices; +using System.Text; + +namespace ClaudeTracker.Utilities; + +/// +/// Finds and brings to foreground the terminal window matching a project directory. +/// Used by permission popup "Terminal" button and notification click handlers. +/// +public static class TerminalFocusHelper +{ + [DllImport("user32.dll")] + private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + + [DllImport("user32.dll")] + private static extern bool IsWindowVisible(IntPtr hWnd); + + [DllImport("user32.dll")] + private static extern bool SetForegroundWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + + [DllImport("user32.dll")] + private static extern bool IsIconic(IntPtr hWnd); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); + + private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + + private static readonly HashSet TerminalProcesses = new(StringComparer.OrdinalIgnoreCase) + { + "WindowsTerminal", "cmd", "powershell", "pwsh", + "Code", // VS Code + "Hyper", "Alacritty", "wezterm-gui", "ConEmu", "ConEmu64" + }; + + public static void BringToFront(string cwd) + { + if (string.IsNullOrEmpty(cwd)) return; + + var projectName = System.IO.Path.GetFileName(cwd) ?? cwd; + IntPtr found = IntPtr.Zero; + + EnumWindows((hWnd, _) => + { + if (!IsWindowVisible(hWnd)) return true; + + try + { + GetWindowThreadProcessId(hWnd, out uint pid); + var proc = System.Diagnostics.Process.GetProcessById((int)pid); + if (!TerminalProcesses.Contains(proc.ProcessName)) + return true; + } + catch { return true; } + + var sb = new StringBuilder(512); + GetWindowText(hWnd, sb, sb.Capacity); + var title = sb.ToString(); + + if (title.Contains(cwd, StringComparison.OrdinalIgnoreCase) || + title.Contains(projectName, StringComparison.OrdinalIgnoreCase)) + { + found = hWnd; + return false; + } + return true; + }, IntPtr.Zero); + + if (found != IntPtr.Zero) + { + // Only restore if minimized — otherwise just bring to front without resizing + if (IsIconic(found)) + ShowWindow(found, 9); // SW_RESTORE + SetForegroundWindow(found); + } + } +} diff --git a/src/ClaudeTracker/ViewModels/HooksSettingsViewModel.cs b/src/ClaudeTracker/ViewModels/HooksSettingsViewModel.cs index 8d9b64d..b99773e 100644 --- a/src/ClaudeTracker/ViewModels/HooksSettingsViewModel.cs +++ b/src/ClaudeTracker/ViewModels/HooksSettingsViewModel.cs @@ -15,6 +15,7 @@ public partial class HooksSettingsViewModel : ObservableObject [ObservableProperty] private bool _permissionPopupsEnabled; [ObservableProperty] private bool _elicitationPopupsEnabled; [ObservableProperty] private string _popupPosition = "BottomRight"; + [ObservableProperty] private int _popupMonitor; [ObservableProperty] private bool _activityFeedEnabled; [ObservableProperty] private int _maxFeedEntries; [ObservableProperty] private bool _notifyStop; @@ -35,6 +36,7 @@ public partial class HooksSettingsViewModel : ObservableObject private bool _initialPermissionPopups; private bool _initialElicitationPopups; private string _initialPopupPosition = "BottomRight"; + private int _initialPopupMonitor; private bool _initialActivityFeed; private int _initialMaxFeedEntries; private bool _initialNotifyStop; @@ -61,6 +63,7 @@ public HooksSettingsViewModel( PermissionPopupsEnabled = settings.HookPermissionPopupsEnabled; ElicitationPopupsEnabled = settings.HookElicitationPopupsEnabled; PopupPosition = settings.HookPopupPosition; + PopupMonitor = settings.HookPopupMonitor; ActivityFeedEnabled = settings.HookActivityFeedEnabled; MaxFeedEntries = settings.HookMaxFeedEntries; @@ -78,6 +81,7 @@ public HooksSettingsViewModel( _initialPermissionPopups = PermissionPopupsEnabled; _initialElicitationPopups = ElicitationPopupsEnabled; _initialPopupPosition = PopupPosition; + _initialPopupMonitor = PopupMonitor; _initialActivityFeed = ActivityFeedEnabled; _initialMaxFeedEntries = MaxFeedEntries; _initialNotifyStop = NotifyStop; @@ -119,6 +123,7 @@ public void CheckInstallStatus() partial void OnPermissionPopupsEnabledChanged(bool value) => DetectChanges(); partial void OnElicitationPopupsEnabledChanged(bool value) => DetectChanges(); partial void OnPopupPositionChanged(string value) => DetectChanges(); + partial void OnPopupMonitorChanged(int value) => DetectChanges(); partial void OnActivityFeedEnabledChanged(bool value) => DetectChanges(); partial void OnMaxFeedEntriesChanged(int value) => DetectChanges(); partial void OnNotifyStopChanged(bool value) => DetectChanges(); @@ -137,6 +142,7 @@ private void DetectChanges() PermissionPopupsEnabled != _initialPermissionPopups || ElicitationPopupsEnabled != _initialElicitationPopups || PopupPosition != _initialPopupPosition || + PopupMonitor != _initialPopupMonitor || ActivityFeedEnabled != _initialActivityFeed || MaxFeedEntries != _initialMaxFeedEntries || NotifyStop != _initialNotifyStop || @@ -158,6 +164,7 @@ private void Save() settings.HookPermissionPopupsEnabled = PermissionPopupsEnabled; settings.HookElicitationPopupsEnabled = ElicitationPopupsEnabled; settings.HookPopupPosition = PopupPosition; + settings.HookPopupMonitor = PopupMonitor; settings.HookActivityFeedEnabled = ActivityFeedEnabled; settings.HookMaxFeedEntries = MaxFeedEntries; @@ -193,6 +200,7 @@ private void Save() _initialPermissionPopups = PermissionPopupsEnabled; _initialElicitationPopups = ElicitationPopupsEnabled; _initialPopupPosition = PopupPosition; + _initialPopupMonitor = PopupMonitor; _initialActivityFeed = ActivityFeedEnabled; _initialMaxFeedEntries = MaxFeedEntries; _initialNotifyStop = NotifyStop; diff --git a/src/ClaudeTracker/ViewModels/PopoverViewModel.cs b/src/ClaudeTracker/ViewModels/PopoverViewModel.cs index 80867e6..b71cd0a 100644 --- a/src/ClaudeTracker/ViewModels/PopoverViewModel.cs +++ b/src/ClaudeTracker/ViewModels/PopoverViewModel.cs @@ -108,6 +108,8 @@ public PopoverViewModel( activityService.RecentFeed.CollectionChanged += (_, _) => { + // Don't track new events if feed is disabled + if (!_settingsService.Settings.HookActivityFeedEnabled) return; System.Windows.Application.Current?.Dispatcher.Invoke(() => { ActivityFeed.Clear(); @@ -116,6 +118,16 @@ public PopoverViewModel( }); }; + // Listen for settings changes to update feed visibility immediately + settingsService.SettingsChanged += (_, _) => + { + ShowActivityFeed = settingsService.Settings.HookActivityFeedEnabled; + if (!ShowActivityFeed) + { + System.Windows.Application.Current?.Dispatcher.Invoke(() => ActivityFeed.Clear()); + } + }; + ShowActivityFeed = settingsService.Settings.HookActivityFeedEnabled; UpdateProfilesList(); @@ -165,28 +177,40 @@ public void RefreshData() WeeklyResetText = FormatResetText(usage.WeeklyResetTime, timeDisplay, use24Hour); WeeklyStatus = UsageStatusCalculator.CalculateStatus(usage.WeeklyPercentage, showRemaining); - // Pace calculation + // Pace calculation — hide when at limit (100%) TimeSpan? sessionEta = null; - var sessionElapsed = PaceStatusCalculator.CalculateSessionElapsed(usage.SessionResetTime); - SessionElapsedFraction = sessionElapsed; - SessionPaceStatus = PaceStatusCalculator.Calculate(usage.EffectiveSessionPercentage, sessionElapsed); - if (SessionPaceStatus.HasValue) + if (usage.EffectiveSessionPercentage >= 99.5) { - SessionPaceLabel = FormatPaceLabel(SessionPaceStatus.Value); - SessionPaceColorHex = PaceStatusCalculator.GetColorHex(SessionPaceStatus.Value); - sessionEta = PaceStatusCalculator.EstimateTimeToLimit( - usage.EffectiveSessionPercentage, sessionElapsed, usage.SessionResetTime); - var sessionWillExceed = PaceStatusCalculator.WillExceedBeforeReset(sessionEta, usage.SessionResetTime); - SessionEstimateText = ShouldShowEstimateInline(SessionPaceStatus.Value, sessionWillExceed) - ? FormatEstimate(sessionEta) : ""; - SessionPaceTooltip = FormatPaceTooltip(SessionPaceStatus.Value, sessionEta, - usage.SessionResetTime, isWeekly: false); + // At limit — show critical indicator, hide pace estimate + SessionPaceLabel = "Limit reached"; + SessionPaceColorHex = "#F44336"; + SessionEstimateText = ""; + SessionPaceTooltip = $"Usage limit reached. Resets at {usage.SessionResetTime:HH:mm}"; + SessionPaceStatus = null; } else { - SessionPaceLabel = ""; - SessionPaceTooltip = ""; - SessionEstimateText = ""; + var sessionElapsed = PaceStatusCalculator.CalculateSessionElapsed(usage.SessionResetTime); + SessionElapsedFraction = sessionElapsed; + SessionPaceStatus = PaceStatusCalculator.Calculate(usage.EffectiveSessionPercentage, sessionElapsed); + if (SessionPaceStatus.HasValue) + { + SessionPaceLabel = FormatPaceLabel(SessionPaceStatus.Value); + SessionPaceColorHex = PaceStatusCalculator.GetColorHex(SessionPaceStatus.Value); + sessionEta = PaceStatusCalculator.EstimateTimeToLimit( + usage.EffectiveSessionPercentage, sessionElapsed, usage.SessionResetTime); + var sessionWillExceed = PaceStatusCalculator.WillExceedBeforeReset(sessionEta, usage.SessionResetTime); + SessionEstimateText = ShouldShowEstimateInline(SessionPaceStatus.Value, sessionWillExceed) + ? FormatEstimate(sessionEta) : ""; + SessionPaceTooltip = FormatPaceTooltip(SessionPaceStatus.Value, sessionEta, + usage.SessionResetTime, isWeekly: false); + } + else + { + SessionPaceLabel = ""; + SessionPaceTooltip = ""; + SessionEstimateText = ""; + } } var weeklyElapsed = PaceStatusCalculator.CalculateWeeklyElapsed(usage.WeeklyResetTime); diff --git a/src/ClaudeTracker/Views/FloatingUsageWindow.xaml.cs b/src/ClaudeTracker/Views/FloatingUsageWindow.xaml.cs index 8dfc09c..07100e4 100644 --- a/src/ClaudeTracker/Views/FloatingUsageWindow.xaml.cs +++ b/src/ClaudeTracker/Views/FloatingUsageWindow.xaml.cs @@ -49,6 +49,20 @@ public FloatingUsageWindow() _isDocked = _settingsService.Settings.IsFloatingWidgetDocked; UpdateDockVisual(); + // Refresh "Updated X ago" text every 30 seconds + var lastUpdatedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(30) }; + lastUpdatedTimer.Tick += (_, _) => + { + if (_viewModel.HasClaudeUsage && !_viewModel.IsRefreshing) + { + var profile = App.Services.GetRequiredService().ActiveProfile; + var usage = profile?.ClaudeUsage; + if (usage != null) + LastUpdatedText.Text = $"Updated {Utilities.FormatterHelper.FormatTimeAgo(usage.LastUpdated)}"; + } + }; + lastUpdatedTimer.Start(); + UpdateUI(); } diff --git a/src/ClaudeTracker/Views/NotificationPopup.xaml.cs b/src/ClaudeTracker/Views/NotificationPopup.xaml.cs index a77878d..672b074 100644 --- a/src/ClaudeTracker/Views/NotificationPopup.xaml.cs +++ b/src/ClaudeTracker/Views/NotificationPopup.xaml.cs @@ -40,15 +40,21 @@ public NotificationPopup(string title, string message, NotificationLevel level = private void OnLoaded(object sender, RoutedEventArgs e) { - // Position at bottom-right of work area - var workArea = SystemParameters.WorkArea; - Left = workArea.Right - ActualWidth - 16; - Top = workArea.Bottom - ActualHeight - 16; + // Position using configured monitor + position + var workArea = Utilities.PopupStackManager.GetWorkArea(); + var pos = Utilities.PopupStackManager.GetPosition(); + Left = pos.Contains("Left") + ? workArea.Left + 16 + : workArea.Right - ActualWidth - 16; + Top = pos.Contains("Bottom") + ? workArea.Bottom - ActualHeight - 16 + : workArea.Top + 16; // Slide-in animation + var isBottom = pos.Contains("Bottom"); var slideAnim = new DoubleAnimation { - From = Top + 40, + From = isBottom ? Top + 40 : Top - 40, To = Top, Duration = TimeSpan.FromMilliseconds(250), EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } diff --git a/src/ClaudeTracker/Views/PermissionRequestPopup.xaml.cs b/src/ClaudeTracker/Views/PermissionRequestPopup.xaml.cs index 0b76483..565f903 100644 --- a/src/ClaudeTracker/Views/PermissionRequestPopup.xaml.cs +++ b/src/ClaudeTracker/Views/PermissionRequestPopup.xaml.cs @@ -1,6 +1,4 @@ using System.Media; -using System.Runtime.InteropServices; -using System.Text; using System.Text.Json; using System.Windows; using System.Windows.Controls; @@ -169,7 +167,7 @@ private void ShowDiffPreview(string oldText, string newText) Text = line, FontFamily = new FontFamily("Cascadia Code, Consolas, Segoe UI"), FontSize = 10, - Foreground = new SolidColorBrush(Color.FromRgb(0xFF, 0x8A, 0x80)), + Foreground = new SolidColorBrush(Color.FromRgb(0xE5, 0x73, 0x73)), TextWrapping = TextWrapping.Wrap }); } @@ -181,7 +179,7 @@ private void ShowDiffPreview(string oldText, string newText) Text = line, FontFamily = new FontFamily("Cascadia Code, Consolas, Segoe UI"), FontSize = 10, - Foreground = new SolidColorBrush(Color.FromRgb(0x69, 0xF0, 0xAE)), + Foreground = new SolidColorBrush(Color.FromRgb(0x66, 0xBB, 0x6A)), TextWrapping = TextWrapping.Wrap }); } @@ -356,14 +354,14 @@ private void ShowAskPreview(Dictionary toolInput) var otherTextBox = new TextBox { FontSize = 11, - Background = new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x1E)), - Foreground = new SolidColorBrush(Color.FromRgb(0xCC, 0xCC, 0xCC)), - BorderBrush = new SolidColorBrush(Color.FromRgb(0x55, 0x55, 0x55)), + Background = SurfaceBrush, + Foreground = FgBrush, + BorderBrush = FgLightBrush, BorderThickness = new Thickness(1), Padding = new Thickness(4, 2, 4, 2), Margin = new Thickness(0, 4, 0, 0), Visibility = Visibility.Collapsed, - CaretBrush = new SolidColorBrush(Colors.White) + CaretBrush = FgBrush }; otherStack.Children.Add(otherTextBox); @@ -585,12 +583,14 @@ private Dictionary BuildAskUserAnswers() private void BuildAlwaysAllowButtons(List suggestions) { - var seenLabels = new HashSet(); + var seenKeys = new HashSet(); foreach (var suggestion in suggestions) { LoggingService.Instance.Log($"[PermPopup] Suggestion: type={suggestion.Type}, behavior={suggestion.Behavior}, tool={suggestion.Tool}, prefix={suggestion.Prefix}, rules={suggestion.Rules.Count}{(suggestion.Rules.Count > 0 ? $" [{suggestion.Rules[0].ToolName}:{suggestion.Rules[0].RuleContent}]" : "")}, dirs={suggestion.Directories.Count}"); var label = suggestion.DisplayLabel; - if (string.IsNullOrWhiteSpace(label) || !seenLabels.Add(label)) continue; + // Dedup by label + tool + first rule to catch different suggestion types that display identically + var dedupKey = $"{label}|{suggestion.Tool}|{(suggestion.Rules.Count > 0 ? suggestion.Rules[0].ToolName : "")}"; + if (string.IsNullOrWhiteSpace(label) || !seenKeys.Add(dedupKey)) continue; var btn = new Button { @@ -683,9 +683,11 @@ private void Deny_Click(object sender, RoutedEventArgs e) private void Terminal_Click(object sender, RoutedEventArgs e) { - // Try to bring the matching terminal window to foreground - BringTerminalToFront(_info.Cwd); + var cwd = _info.Cwd; SetDecision(new PermissionDecisionResult { Decision = PermissionDecision.HandleInTerminal }); + // Bring terminal to front AFTER popup closes to avoid focus fight + Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Background, + () => TerminalFocusHelper.BringToFront(cwd)); } protected override void OnClosed(EventArgs e) @@ -781,77 +783,6 @@ private static string FormatDictionaryCompact(Dictionary dict) return string.Join("\n", parts); } - // ── Bring terminal window to foreground ── - - [DllImport("user32.dll")] - private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); - - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); - - [DllImport("user32.dll")] - private static extern bool IsWindowVisible(IntPtr hWnd); - - [DllImport("user32.dll")] - private static extern bool SetForegroundWindow(IntPtr hWnd); - - [DllImport("user32.dll")] - private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); - - [DllImport("user32.dll")] - private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); - - private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); - - // Terminal process names that could host Claude Code - private static readonly HashSet TerminalProcesses = new(StringComparer.OrdinalIgnoreCase) - { - "WindowsTerminal", "cmd", "powershell", "pwsh", - "Code", // VS Code - "Hyper", "Alacritty", "wezterm-gui", "ConEmu", "ConEmu64" - }; - - private static void BringTerminalToFront(string cwd) - { - if (string.IsNullOrEmpty(cwd)) return; - - var projectName = System.IO.Path.GetFileName(cwd) ?? cwd; - IntPtr found = IntPtr.Zero; - - EnumWindows((hWnd, _) => - { - if (!IsWindowVisible(hWnd)) return true; - - // Only consider terminal processes - try - { - GetWindowThreadProcessId(hWnd, out uint pid); - var proc = System.Diagnostics.Process.GetProcessById((int)pid); - if (!TerminalProcesses.Contains(proc.ProcessName)) - return true; - } - catch { return true; } - - var sb = new StringBuilder(512); - GetWindowText(hWnd, sb, sb.Capacity); - var title = sb.ToString(); - - if (title.Contains(cwd, StringComparison.OrdinalIgnoreCase) || - title.Contains(projectName, StringComparison.OrdinalIgnoreCase)) - { - found = hWnd; - return false; - } - return true; - }, IntPtr.Zero); - - if (found != IntPtr.Zero) - { - ShowWindow(found, 9); // SW_RESTORE - SetForegroundWindow(found); - } - } - // Helper types for AskUserQuestion parsing private class AskQuestion { diff --git a/src/ClaudeTracker/Views/Settings/HooksSettingsView.xaml b/src/ClaudeTracker/Views/Settings/HooksSettingsView.xaml index b011a0d..8526868 100644 --- a/src/ClaudeTracker/Views/Settings/HooksSettingsView.xaml +++ b/src/ClaudeTracker/Views/Settings/HooksSettingsView.xaml @@ -50,6 +50,11 @@ + + + + + { + _vm.PopupMonitor = PopupMonitorCombo.SelectedIndex; + }; + // Notifications NotifyStopToggle.IsChecked = _vm.NotifyStop; NotifyStopToggle.Checked += (_, _) => _vm.NotifyStop = true;