diff --git a/Sources/CodeCaps/AppDelegate.swift b/Sources/CodeCaps/AppDelegate.swift index 0fe6a5f..d9fc157 100644 --- a/Sources/CodeCaps/AppDelegate.swift +++ b/Sources/CodeCaps/AppDelegate.swift @@ -53,7 +53,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { } @MainActor -final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMenuDelegate, NSMenuItemValidation { +final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMenuDelegate, NSMenuItemValidation, NSPopoverDelegate { let model = MonitorModel() let consoleState = ConsoleState() private var statusItem: NSStatusItem? @@ -69,6 +69,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMe return image }() private let popover = NSPopover() + private var outsideClickMonitor: Any? private var consoleWindow: NSWindow? private var statusMenu: NSMenu? private var subscriptions = Set() @@ -81,6 +82,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMe AppUpdater.shared.start() configureMenu() popover.behavior = .transient + popover.delegate = self let glance = NSHostingController(rootView: GlancePopover(model: model, openConsole: { [weak self] page in self?.showConsole(page: page) }, @@ -361,6 +363,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMe } } + // MARK: - Popover outside-click dismissal + + private func startOutsideClickMonitor() { + stopOutsideClickMonitor() + outsideClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self, self.popover.isShown else { return } + self.popover.performClose(nil) + } + } + } + + private func stopOutsideClickMonitor() { + if let monitor = outsideClickMonitor { + NSEvent.removeMonitor(monitor) + outsideClickMonitor = nil + } + } + + func popoverWillShow(_ notification: Notification) { + startOutsideClickMonitor() + } + + func popoverDidClose(_ notification: Notification) { + stopOutsideClickMonitor() + } + // MARK: - Console /// The single window. `page` nil means "leave the selection alone", which diff --git a/Sources/CodeCaps/MonitorModel.swift b/Sources/CodeCaps/MonitorModel.swift index 13ae50a..dd462be 100644 --- a/Sources/CodeCaps/MonitorModel.swift +++ b/Sources/CodeCaps/MonitorModel.swift @@ -1891,7 +1891,7 @@ final class MonitorModel: ObservableObject { for (key, message) in file?.issues ?? [:] where issues[key] == nil { issues[key] = message } - if !fileWindows.filter({ $0.boundedRemainingPercent != nil }).isEmpty { + if !windows.filter({ $0.canonicalProviderKey == "openai" && $0.boundedRemainingPercent != nil }).isEmpty { issues["openai"] = nil } if currentCodexAccountID == nil { diff --git a/Sources/CodeCaps/QuotaComponents.swift b/Sources/CodeCaps/QuotaComponents.swift index efdac67..b37a356 100644 --- a/Sources/CodeCaps/QuotaComponents.swift +++ b/Sources/CodeCaps/QuotaComponents.swift @@ -106,9 +106,17 @@ enum Theme { NSColor.black.withAlphaComponent(0.7)) /// The share of a quota window already used: the left segment of the bar. static let barUsed = danger - /// The share still available: the right segment of the bar. This is the - /// same teal as the "% remaining" text, which the owner reads as green. - static let barRemaining = accent + /// The share still available: the right segment of the bar. Healthy capacity + /// uses teal (or green if chosen), keeping the bar distinguishable from the red + /// `barUsed` segment even if the user picks a warm or danger-like UI accent (e.g. magenta, orange). + static var barRemaining: Color { + switch AccentChoice.current { + case .green: + return dyn(hex(AccentChoice.green.lightHex), hex(AccentChoice.green.darkHex)) + default: + return dyn(hex(AccentChoice.teal.lightHex), hex(AccentChoice.teal.darkHex)) + } + } static let fleet = dyn(hex(0x4B4FA8), hex(0x8A8EE0)) /// Unfilled portion of any progress bar. A black 6% track disappears on a diff --git a/Sources/CodeCaps/ResetAlarmManager.swift b/Sources/CodeCaps/ResetAlarmManager.swift index 79f62aa..98c12f3 100644 --- a/Sources/CodeCaps/ResetAlarmManager.swift +++ b/Sources/CodeCaps/ResetAlarmManager.swift @@ -396,7 +396,7 @@ public final class ResetAlarmManager: ObservableObject { private func deliver(_ payload: ResetAlarmNotification) { // Only deliver alerts if the master top bell is armed (All) or this specific quota's bell is on guard isAlarmEnabled(for: payload.providerId) else { return } - if payload.isVendorReset && payload.sound.isAudible { + if payload.sound.isAudible { playAlarmSound(payload.sound) } // Deliver via custom test handler if installed diff --git a/Sources/QuotaCore/ResetAlarmTracker.swift b/Sources/QuotaCore/ResetAlarmTracker.swift index 0edf9d7..d858b5b 100644 --- a/Sources/QuotaCore/ResetAlarmTracker.swift +++ b/Sources/QuotaCore/ResetAlarmTracker.swift @@ -509,11 +509,19 @@ public struct ResetAlarmTracker: Sendable { nextResetAt = observation.resetAt.flatMap { $0 > readAt ? $0 : nil } ?? window.periodResetAt } } - } else if let reported = observation.resetAt, reported > readAt { - // No period end known — a reset was just detected without the next - // one being published. Adopt the first reset time still ahead; a - // stale one would read as a second reset of the same period. - nextResetAt = reported + } else { + if let reported = observation.resetAt, reported > readAt { + // No period end known — adopt the first reset time still ahead. + nextResetAt = reported + } + if let reading, let last = window.lastRemaining { + let jumpedToFull = reading >= 99.5 && last < 99.5 + let surgedMidWindow = last < 80.0 && reading >= 95.0 + if jumpedToFull || surgedMidWindow { + isReset = true + isMidWindow = true + } + } } let previous = window diff --git a/Tests/CodeCapsTests/ThemePreferenceTests.swift b/Tests/CodeCapsTests/ThemePreferenceTests.swift index be02e6b..b08e2c1 100644 --- a/Tests/CodeCapsTests/ThemePreferenceTests.swift +++ b/Tests/CodeCapsTests/ThemePreferenceTests.swift @@ -81,4 +81,13 @@ final class ThemePreferenceTests: XCTestCase { AccentChoice.current = .teal XCTAssertEqual(AccentChoice.current, .teal) } + + func testBarRemainingRemainsHealthyToneAndDoesNotTurnRedWhenMagentaSelected() { + AccentChoice.current = .magenta + XCTAssertNotNil(Theme.barRemaining) + AccentChoice.current = .green + XCTAssertNotNil(Theme.barRemaining) + AccentChoice.current = .teal + XCTAssertNotNil(Theme.barRemaining) + } } diff --git a/Tests/QuotaCoreTests/ResetAlarmTrackerTests.swift b/Tests/QuotaCoreTests/ResetAlarmTrackerTests.swift index 92c4035..58a501f 100644 --- a/Tests/QuotaCoreTests/ResetAlarmTrackerTests.swift +++ b/Tests/QuotaCoreTests/ResetAlarmTrackerTests.swift @@ -871,4 +871,16 @@ final class ResetAlarmTrackerTests: XCTestCase { let decoded = ResetAlarmTrackerState.decoded(from: tracker.state.encoded()) XCTAssertEqual(decoded, tracker.state) } + + func testWindowWithoutInitialResetAtFiresWhenJumpingToFull() { + var tracker = ResetAlarmTracker() + let obs1 = reading("primary", period: 7 * day, resetAt: nil, remaining: 20, observedAt: t0, provider: "openai") + _ = tracker.process([obs1], now: t0) + let t1 = t0 + 2 * hour + let obs2 = reading("primary", period: 7 * day, resetAt: nil, remaining: 100, observedAt: t1, provider: "openai") + let events = tracker.process([obs2], now: t1) + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first?.providerId, "openai") + XCTAssertEqual(events.first?.remainingPercent, 100) + } }