Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions Sources/CodexBar/PreferencesDisplayPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
17 changes: 17 additions & 0 deletions Sources/CodexBar/SettingsStore+Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/SettingsStore+MenuObservation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ extension SettingsStore {
_ = self.debugDisableKeychainAccess
_ = self.statusChecksEnabled
_ = self.sessionQuotaNotificationsEnabled
_ = self.hideStatusItemBelowThreshold
_ = self.statusItemThresholdPercent
_ = self.usageBarsShowUsed
_ = self.resetTimesShowAbsolute
_ = self.menuBarShowsBrandIconWithPercent
Expand Down
12 changes: 12 additions & 0 deletions Sources/CodexBar/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -220,6 +230,8 @@ extension SettingsStore {
debugLoadingPatternRaw: debugLoadingPatternRaw,
statusChecksEnabled: statusChecksEnabled,
sessionQuotaNotificationsEnabled: sessionQuotaNotificationsEnabled,
hideStatusItemBelowThreshold: hideStatusItemBelowThreshold,
statusItemThresholdPercent: statusItemThresholdPercent,
usageBarsShowUsed: usageBarsShowUsed,
resetTimesShowAbsolute: resetTimesShowAbsolute,
menuBarShowsBrandIconWithPercent: menuBarShowsBrandIconWithPercent,
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/SettingsStoreState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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?) {
Expand Down
73 changes: 65 additions & 8 deletions Sources/CodexBar/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin
var openMenus: [ObjectIdentifier: NSMenu] = [:]
var menuRefreshTasks: [ObjectIdentifier: Task<Void, Never>] = [:]
var blinkTask: Task<Void, Never>?
var launchVisibilityTask: Task<Void, Never>?
var loginTask: Task<Void, Never>? {
didSet { self.refreshMenusForLoginStateChange() }
}
Expand Down Expand Up @@ -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()
Comment on lines +176 to +179

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce launch grace before applying threshold

The 15‑second “show on launch” grace isn’t actually enforced: scheduleLaunchVisibilityUpdate() only schedules another updateVisibility() call, but updateVisibility() still runs immediately from store/settings observation. Since UsageStore.init kicks off a refresh right away, a fast refresh can set a snapshot below the threshold and hide the status item well before 15 seconds. To guarantee the grace period, gate meetsThreshold/updateVisibility with a launch timestamp or a flag that suppresses threshold checks until the timer expires.

Useful? React with 👍 / 👎.

}
}

private func wireBindings() {
Expand All @@ -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()
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -452,6 +508,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin

deinit {
self.blinkTask?.cancel()
self.launchVisibilityTask?.cancel()
self.loginTask?.cancel()
NotificationCenter.default.removeObserver(self)
}
Expand Down
92 changes: 92 additions & 0 deletions Tests/CodexBarTests/SettingsStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading