diff --git a/Sources/CodexBar/PreferencesDisplayPane.swift b/Sources/CodexBar/PreferencesDisplayPane.swift index 003fa27e..bcc06ef8 100644 --- a/Sources/CodexBar/PreferencesDisplayPane.swift +++ b/Sources/CodexBar/PreferencesDisplayPane.swift @@ -52,6 +52,45 @@ struct DisplayPane: View { } .disabled(!self.settings.menuBarShowsBrandIconWithPercent) .opacity(self.settings.menuBarShowsBrandIconWithPercent ? 1 : 0.5) + + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + Toggle(isOn: self.$settings.hideStatusItemBelowThreshold) { + Text("Hide status item below threshold") + .font(.body) + } + .toggleStyle(.checkbox) + + Text("Only show status items when session usage exceeds the threshold.") + .font(.footnote) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + + if self.settings.hideStatusItemBelowThreshold { + HStack(spacing: 8) { + Text("Threshold:") + .font(.footnote) + .foregroundStyle(.secondary) + + TextField( + "80", + value: Binding( + get: { self.settings.statusItemThresholdPercent }, + set: { self.settings.statusItemThresholdPercent = max(0, min(100, $0)) } + ), + format: .number + ) + .textFieldStyle(.roundedBorder) + .frame(width: 60) + + Text("%") + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding(.leading, 20) + } + } + } } Divider() diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 5ca2c167..fd028b21 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -75,6 +75,23 @@ extension SettingsStore { } } + var hideStatusItemBelowThreshold: Bool { + get { self.defaultsState.hideStatusItemBelowThreshold } + set { + self.defaultsState.hideStatusItemBelowThreshold = newValue + self.userDefaults.set(newValue, forKey: "hideStatusItemBelowThreshold") + } + } + + var statusItemThresholdPercent: Int { + get { self.defaultsState.statusItemThresholdPercent } + set { + let clamped = max(0, min(100, newValue)) + self.defaultsState.statusItemThresholdPercent = clamped + self.userDefaults.set(clamped, forKey: "statusItemThresholdPercent") + } + } + var usageBarsShowUsed: Bool { get { self.defaultsState.usageBarsShowUsed } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 626da28b..508c8dc6 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -9,6 +9,8 @@ extension SettingsStore { _ = self.debugDisableKeychainAccess _ = self.statusChecksEnabled _ = self.sessionQuotaNotificationsEnabled + _ = self.hideStatusItemBelowThreshold + _ = self.statusItemThresholdPercent _ = self.usageBarsShowUsed _ = self.resetTimesShowAbsolute _ = self.menuBarShowsBrandIconWithPercent diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 5f9a03f5..3fd19cb0 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -115,6 +115,11 @@ final class SettingsStore { copilotTokenStore: any CopilotTokenStoring = KeychainCopilotTokenStore(), tokenAccountStore: any ProviderTokenAccountStoring = FileTokenAccountStore()) { + // Register defaults first, before any properties are initialized or accessed + userDefaults.register(defaults: [ + "hideStatusItemBelowThreshold": false, + "statusItemThresholdPercent": 80, + ]) let legacyStores = CodexBarConfigMigrator.LegacyStores( zaiTokenStore: zaiTokenStore, syntheticTokenStore: syntheticTokenStore, @@ -178,6 +183,11 @@ extension SettingsStore { if sessionQuotaDefault == nil { userDefaults.set(true, forKey: "sessionQuotaNotificationsEnabled") } + let hideStatusItemBelowThreshold = userDefaults.bool(forKey: "hideStatusItemBelowThreshold") + let statusItemThresholdPercent = max( + 0, + min(100, userDefaults.integer(forKey: "statusItemThresholdPercent")) + ) let usageBarsShowUsed = userDefaults.object(forKey: "usageBarsShowUsed") as? Bool ?? false let resetTimesShowAbsolute = userDefaults.object(forKey: "resetTimesShowAbsolute") as? Bool ?? false let menuBarShowsBrandIconWithPercent = userDefaults.object( @@ -220,6 +230,8 @@ extension SettingsStore { debugLoadingPatternRaw: debugLoadingPatternRaw, statusChecksEnabled: statusChecksEnabled, sessionQuotaNotificationsEnabled: sessionQuotaNotificationsEnabled, + hideStatusItemBelowThreshold: hideStatusItemBelowThreshold, + statusItemThresholdPercent: statusItemThresholdPercent, usageBarsShowUsed: usageBarsShowUsed, resetTimesShowAbsolute: resetTimesShowAbsolute, menuBarShowsBrandIconWithPercent: menuBarShowsBrandIconWithPercent, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 7f44de16..e572c492 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -9,6 +9,8 @@ struct SettingsDefaultsState: Sendable { var debugLoadingPatternRaw: String? var statusChecksEnabled: Bool var sessionQuotaNotificationsEnabled: Bool + var hideStatusItemBelowThreshold: Bool + var statusItemThresholdPercent: Int var usageBarsShowUsed: Bool var resetTimesShowAbsolute: Bool var menuBarShowsBrandIconWithPercent: Bool diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index eba0f384..10ed5d7b 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -43,6 +43,8 @@ extension StatusItemController { if self.isHostedSubviewMenu(menu) { self.refreshHostedSubviewHeights(in: menu) self.openMenus[ObjectIdentifier(menu)] = menu + self.scheduleOpenMenuRefresh(for: menu) + self.updateVisibility() // Removed redundant async refresh - single pass is sufficient after initial layout return } @@ -75,6 +77,7 @@ extension StatusItemController { self.openMenus[ObjectIdentifier(menu)] = menu // Only schedule refresh after menu is registered as open - refreshNow is called async self.scheduleOpenMenuRefresh(for: menu) + self.updateVisibility() } func menuDidClose(_ menu: NSMenu) { @@ -93,6 +96,7 @@ extension StatusItemController { for menuItem in menu.items { (menuItem.view as? MenuCardHighlighting)?.setHighlighted(false) } + self.updateVisibility() } func menu(_ menu: NSMenu, willHighlight item: NSMenuItem?) { diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 6a191bc3..f4672195 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -42,6 +42,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var openMenus: [ObjectIdentifier: NSMenu] = [:] var menuRefreshTasks: [ObjectIdentifier: Task] = [:] var blinkTask: Task? + var launchVisibilityTask: Task? var loginTask: Task? { didSet { self.refreshMenusForLoginStateChange() } } @@ -169,6 +170,14 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin selector: #selector(self.handleProviderConfigDidChange), name: .codexbarProviderConfigDidChange, object: nil) + self.scheduleLaunchVisibilityUpdate() + } + + private func scheduleLaunchVisibilityUpdate() { + self.launchVisibilityTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(15)) + self.updateVisibility() + } } private func wireBindings() { @@ -186,6 +195,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin guard let self else { return } self.observeStoreChanges() self.invalidateMenus() + self.updateVisibility() self.updateIcons() self.updateBlinkingState() } @@ -328,12 +338,11 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin return item } - private func updateVisibility() { - let anyEnabled = !self.store.enabledProviders().isEmpty + func updateVisibility() { let force = self.store.debugForceAnimation - let mergeIcons = self.shouldMergeIcons - if mergeIcons { - self.statusItem.isVisible = anyEnabled || force + if self.shouldMergeIcons { + let shouldBeVisible = self.mergedIconMeetsThreshold() || force + self.statusItem.isVisible = shouldBeVisible for item in self.statusItems.values { item.isVisible = false } @@ -342,8 +351,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.statusItem.isVisible = false let fallback = self.fallbackProvider for provider in UsageProvider.allCases { - let isEnabled = self.isEnabled(provider) - let shouldBeVisible = isEnabled || fallback == provider || force + let shouldBeVisible = self.isVisible(provider) || force if shouldBeVisible { let item = self.lazyStatusItem(for: provider) item.isVisible = true @@ -431,7 +439,55 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } func isVisible(_ provider: UsageProvider) -> Bool { - self.store.debugForceAnimation || self.isEnabled(provider) || self.fallbackProvider == provider + if self.store.debugForceAnimation || self.fallbackProvider == provider { + return true + } + guard self.isEnabled(provider) else { + return false + } + return self.meetsThreshold(provider) + } + + private func meetsThreshold(_ provider: UsageProvider) -> Bool { + guard self.settings.hideStatusItemBelowThreshold else { + return true + } + // Show while any menu is open + if !self.openMenus.isEmpty { + return true + } + guard let snapshot = self.store.snapshot(for: provider), + let primary = snapshot.primary + else { + return true + } + return primary.usedPercent >= Double(self.settings.statusItemThresholdPercent) + } + + private func mergedIconMeetsThreshold() -> Bool { + let enabledProviders = self.store.enabledProviders() + guard !enabledProviders.isEmpty else { + return !self.settings.hideStatusItemBelowThreshold + } + + guard self.settings.hideStatusItemBelowThreshold else { + return true + } + + // Show while any menu is open + if !self.openMenus.isEmpty { + return true + } + + // Show if at least one enabled provider meets the threshold + return enabledProviders.contains { provider in + guard let snapshot = self.store.snapshot(for: provider), + let primary = snapshot.primary + else { + return true + } + return primary.usedPercent >= Double(self.settings.statusItemThresholdPercent) + } } var shouldMergeIcons: Bool { @@ -452,6 +508,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin deinit { self.blinkTask?.cancel() + self.launchVisibilityTask?.cancel() self.loginTask?.cancel() NotificationCenter.default.removeObserver(self) } diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 40c7777a..8d81521d 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -274,4 +274,96 @@ struct SettingsStoreTests { #expect(storeB.orderedProviders().first == .antigravity) } + + @Test + func defaultsHideStatusItemBelowThresholdToFalse() { + let suite = "SettingsStoreTests-hideStatusItem" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + + let store = SettingsStore(userDefaults: defaults, zaiTokenStore: NoopZaiTokenStore()) + + #expect(store.hideStatusItemBelowThreshold == false) + } + + @Test + func defaultsStatusItemThresholdPercentTo80() { + let suite = "SettingsStoreTests-thresholdPercent" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + + let store = SettingsStore(userDefaults: defaults, zaiTokenStore: NoopZaiTokenStore()) + + #expect(store.statusItemThresholdPercent == 80) + } + + @Test + func persistsHideStatusItemBelowThresholdAcrossInstances() { + let suite = "SettingsStoreTests-hideStatusItemPersist" + let defaultsA = UserDefaults(suiteName: suite)! + defaultsA.removePersistentDomain(forName: suite) + let storeA = SettingsStore(userDefaults: defaultsA, zaiTokenStore: NoopZaiTokenStore()) + + storeA.hideStatusItemBelowThreshold = true + + let defaultsB = UserDefaults(suiteName: suite)! + let storeB = SettingsStore(userDefaults: defaultsB, zaiTokenStore: NoopZaiTokenStore()) + + #expect(storeB.hideStatusItemBelowThreshold == true) + } + + @Test + func persistsStatusItemThresholdPercentAcrossInstances() { + let suite = "SettingsStoreTests-thresholdPercentPersist" + let defaultsA = UserDefaults(suiteName: suite)! + defaultsA.removePersistentDomain(forName: suite) + let storeA = SettingsStore(userDefaults: defaultsA, zaiTokenStore: NoopZaiTokenStore()) + + storeA.statusItemThresholdPercent = 90 + + let defaultsB = UserDefaults(suiteName: suite)! + let storeB = SettingsStore(userDefaults: defaultsB, zaiTokenStore: NoopZaiTokenStore()) + + #expect(storeB.statusItemThresholdPercent == 90) + } + + @Test + func statusItemThresholdPercentClampsValueAbove100() { + let suite = "SettingsStoreTests-thresholdClampHigh" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let store = SettingsStore(userDefaults: defaults, zaiTokenStore: NoopZaiTokenStore()) + + store.statusItemThresholdPercent = 150 + + #expect(store.statusItemThresholdPercent == 100) + #expect(defaults.integer(forKey: "statusItemThresholdPercent") == 100) + } + + @Test + func statusItemThresholdPercentClampsValueBelow0() { + let suite = "SettingsStoreTests-thresholdClampLow" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let store = SettingsStore(userDefaults: defaults, zaiTokenStore: NoopZaiTokenStore()) + + store.statusItemThresholdPercent = -10 + + #expect(store.statusItemThresholdPercent == 0) + #expect(defaults.integer(forKey: "statusItemThresholdPercent") == 0) + } + + @Test + func statusItemThresholdPercentAcceptsBoundaryValues() { + let suite = "SettingsStoreTests-thresholdBoundary" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let store = SettingsStore(userDefaults: defaults, zaiTokenStore: NoopZaiTokenStore()) + + store.statusItemThresholdPercent = 0 + #expect(store.statusItemThresholdPercent == 0) + + store.statusItemThresholdPercent = 100 + #expect(store.statusItemThresholdPercent == 100) + } } diff --git a/Tests/CodexBarTests/StatusItemVisibilityTests.swift b/Tests/CodexBarTests/StatusItemVisibilityTests.swift new file mode 100644 index 00000000..4ac1f6c7 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemVisibilityTests.swift @@ -0,0 +1,528 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite +struct StatusItemVisibilityTests { + @Test + func statusItemVisibleWhenThresholdDisabled() { + let settings = SettingsStore(zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.hideStatusItemBelowThreshold = false + settings.statusItemThresholdPercent = 80 + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection()) + + // Usage at 50% (below 80% threshold), but threshold is disabled + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setErrorForTesting(nil, provider: .codex) + + #expect(controller.isVisible(.codex) == true) + } + + @Test + func statusItemHiddenWhenBelowThreshold() { + let settings = SettingsStore(zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.hideStatusItemBelowThreshold = true + settings.statusItemThresholdPercent = 80 + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection()) + + // Cancel the launch visibility task to immediately apply threshold + controller.launchVisibilityTask?.cancel() + + // Usage at 50% (below 80% threshold) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setErrorForTesting(nil, provider: .codex) + + #expect(controller.isVisible(.codex) == false) + } + + @Test + func statusItemVisibleWhenAboveThreshold() { + let settings = SettingsStore(zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.hideStatusItemBelowThreshold = true + settings.statusItemThresholdPercent = 80 + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection()) + + // Cancel the launch visibility task to immediately apply threshold + controller.launchVisibilityTask?.cancel() + + // Usage at 90% (above 80% threshold) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 90, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setErrorForTesting(nil, provider: .codex) + + #expect(controller.isVisible(.codex) == true) + } + + @Test + func statusItemHiddenWhenProviderDisabled() { + let settings = SettingsStore(zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.hideStatusItemBelowThreshold = true + settings.statusItemThresholdPercent = 80 + + let registry = ProviderRegistry.shared + // Enable claude so codex isn't the fallback when disabled + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + } + // Disable codex + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: false) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection()) + + // Cancel the launch visibility task to immediately apply threshold + controller.launchVisibilityTask?.cancel() + + // Usage at 50% (below 80% threshold), provider disabled + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setErrorForTesting(nil, provider: .codex) + + // Should be hidden because provider is disabled (not because of threshold) + #expect(controller.isVisible(.codex) == false) + } + + @Test + func statusItemVisibleWhenNoSnapshot() { + let settings = SettingsStore(zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.hideStatusItemBelowThreshold = true + settings.statusItemThresholdPercent = 80 + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection()) + + // Cancel the launch visibility task to immediately apply threshold + controller.launchVisibilityTask?.cancel() + + // No snapshot available + store._setSnapshotForTesting(nil, provider: .codex) + store._setErrorForTesting(nil, provider: .codex) + + // Should be visible when no data is available (default to visible) + #expect(controller.isVisible(.codex) == true) + } + + @Test + func statusItemVisibleWhenMenuOpen() { + let settings = SettingsStore(zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.hideStatusItemBelowThreshold = true + settings.statusItemThresholdPercent = 80 + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection()) + + // Cancel the launch visibility task to immediately apply threshold + controller.launchVisibilityTask?.cancel() + + // Usage at 50% (below 80% threshold) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setErrorForTesting(nil, provider: .codex) + + // Simulate a menu being open + let menu = NSMenu() + controller.openMenus[ObjectIdentifier(menu)] = menu + + // Should be visible while menu is open, even though below threshold + #expect(controller.isVisible(.codex) == true) + } + + @Test + func mergedIconVisibleWhenThresholdDisabled() { + let settings = SettingsStore(zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.hideStatusItemBelowThreshold = false + settings.statusItemThresholdPercent = 80 + settings.mergeIcons = true + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection()) + + // Both providers at 50% (below 80% threshold), but threshold is disabled + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setErrorForTesting(nil, provider: .codex) + store._setErrorForTesting(nil, provider: .claude) + + #expect(controller.statusItem.isVisible == true) + } + + @Test + func mergedIconHiddenWhenAllProvidersBelowThreshold() { + let settings = SettingsStore(zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.hideStatusItemBelowThreshold = true + settings.statusItemThresholdPercent = 80 + settings.mergeIcons = true + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection()) + + // Cancel the launch visibility task to immediately apply threshold + controller.launchVisibilityTask?.cancel() + + // Both providers at 50% (below 80% threshold) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setErrorForTesting(nil, provider: .codex) + store._setErrorForTesting(nil, provider: .claude) + + controller.updateVisibility() + + #expect(controller.statusItem.isVisible == false) + } + + @Test + func mergedIconVisibleWhenOneProviderAboveThreshold() { + let settings = SettingsStore(zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.hideStatusItemBelowThreshold = true + settings.statusItemThresholdPercent = 80 + settings.mergeIcons = true + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection()) + + // Cancel the launch visibility task to immediately apply threshold + controller.launchVisibilityTask?.cancel() + + // Codex at 50% (below), Claude at 90% (above) + let lowSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let highSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 90, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(lowSnapshot, provider: .codex) + store._setSnapshotForTesting(highSnapshot, provider: .claude) + store._setErrorForTesting(nil, provider: .codex) + store._setErrorForTesting(nil, provider: .claude) + + controller.updateVisibility() + + #expect(controller.statusItem.isVisible == true) + } + + @Test + func mergedIconVisibleWhenMenuOpenEvenIfAllBelowThreshold() { + let settings = SettingsStore(zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.hideStatusItemBelowThreshold = true + settings.statusItemThresholdPercent = 80 + settings.mergeIcons = true + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection()) + + // Cancel the launch visibility task to immediately apply threshold + controller.launchVisibilityTask?.cancel() + + // Both providers at 50% (below 80% threshold) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setErrorForTesting(nil, provider: .codex) + store._setErrorForTesting(nil, provider: .claude) + + // Simulate a menu being open + let menu = NSMenu() + controller.openMenus[ObjectIdentifier(menu)] = menu + + controller.updateVisibility() + + #expect(controller.statusItem.isVisible == true) + } + + @Test + func mergedIconVisibleWhenAllProvidersAboveThreshold() { + let settings = SettingsStore(zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.hideStatusItemBelowThreshold = true + settings.statusItemThresholdPercent = 80 + settings.mergeIcons = true + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection()) + + // Cancel the launch visibility task to immediately apply threshold + controller.launchVisibilityTask?.cancel() + + // Both providers at 90% (above 80% threshold) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 90, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setErrorForTesting(nil, provider: .codex) + store._setErrorForTesting(nil, provider: .claude) + + controller.updateVisibility() + + #expect(controller.statusItem.isVisible == true) + } + + @Test + func mergedIconVisibleWhenNoProvidersEnabledAndThresholdDisabled() { + let settings = SettingsStore(zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.hideStatusItemBelowThreshold = false + settings.statusItemThresholdPercent = 80 + settings.mergeIcons = true + + let registry = ProviderRegistry.shared + // Disable all providers + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: false) + } + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection()) + + controller.launchVisibilityTask?.cancel() + controller.updateVisibility() + + // Should be visible because threshold setting is disabled + #expect(controller.statusItem.isVisible == true) + } + + @Test + func mergedIconHiddenWhenNoProvidersEnabledAndThresholdEnabled() { + let settings = SettingsStore(zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.hideStatusItemBelowThreshold = true + settings.statusItemThresholdPercent = 80 + settings.mergeIcons = true + + let registry = ProviderRegistry.shared + // Disable all providers + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: false) + } + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection()) + + controller.launchVisibilityTask?.cancel() + controller.updateVisibility() + + // Should be hidden because threshold setting is enabled + #expect(controller.statusItem.isVisible == false) + } +}