From cb9f02bbd25444b3a35326953f488ad28e0218a6 Mon Sep 17 00:00:00 2001 From: John Larkin Date: Wed, 11 Feb 2026 19:02:06 -0500 Subject: [PATCH] feat: color-coded menu bar icons and separator style option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add UsageColorLevel to tint menu-bar icons green/yellow/red based on remaining quota, plus a configurable separator style (dot, dash, pipe, slash, colon) between provider and usage text. New files: - UsageColorLevel.swift – threshold logic + NSColor mapping - MenuBarSeparatorStyle.swift – separator enum + picker - Public/bar-usage-pace.png, separator-option.png – docs images Modified: - StatusItemController+Animation – apply color tint to icon - MenuBarDisplayText – use separator setting - PreferencesDisplayPane / PreferencesAdvancedPane – new UI controls - SettingsStore* – persist new preferences --- Sources/CodexBar/MenuBarDisplayText.swift | 3 +- Sources/CodexBar/MenuBarSeparatorStyle.swift | 25 ++++++++++++++ .../CodexBar/PreferencesAdvancedPane.swift | 4 +++ Sources/CodexBar/PreferencesDisplayPane.swift | 22 +++++++++++++ Sources/CodexBar/SettingsStore+Defaults.swift | 25 ++++++++++++++ .../SettingsStore+MenuObservation.swift | 2 ++ Sources/CodexBar/SettingsStore.swift | 5 +++ Sources/CodexBar/SettingsStoreState.swift | 2 ++ .../StatusItemController+Animation.swift | 33 ++++++++++++++++--- Sources/CodexBar/UsageColorLevel.swift | 23 +++++++++++++ 10 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 Sources/CodexBar/MenuBarSeparatorStyle.swift create mode 100644 Sources/CodexBar/UsageColorLevel.swift diff --git a/Sources/CodexBar/MenuBarDisplayText.swift b/Sources/CodexBar/MenuBarDisplayText.swift index ca65c9ce3..b1b850bc5 100644 --- a/Sources/CodexBar/MenuBarDisplayText.swift +++ b/Sources/CodexBar/MenuBarDisplayText.swift @@ -23,6 +23,7 @@ enum MenuBarDisplayText { percentWindow: RateWindow?, paceWindow: RateWindow?, showUsed: Bool, + separatorStyle: MenuBarSeparatorStyle = .dot, now: Date = .init()) -> String? { switch mode { @@ -33,7 +34,7 @@ enum MenuBarDisplayText { case .both: guard let percent = percentText(window: percentWindow, showUsed: showUsed) else { return nil } guard let pace = Self.paceText(provider: provider, window: paceWindow, now: now) else { return nil } - return "\(percent) · \(pace)" + return "\(percent)\(separatorStyle.separator)\(pace)" } } } diff --git a/Sources/CodexBar/MenuBarSeparatorStyle.swift b/Sources/CodexBar/MenuBarSeparatorStyle.swift new file mode 100644 index 000000000..456c3a6aa --- /dev/null +++ b/Sources/CodexBar/MenuBarSeparatorStyle.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Controls the separator character between percent and pace in the menu bar. +enum MenuBarSeparatorStyle: String, CaseIterable, Identifiable { + case dot + case pipe + + var id: String { + self.rawValue + } + + var separator: String { + switch self { + case .dot: " · " + case .pipe: " | " + } + } + + var label: String { + switch self { + case .dot: "Dot (·)" + case .pipe: "Pipe (|)" + } + } +} diff --git a/Sources/CodexBar/PreferencesAdvancedPane.swift b/Sources/CodexBar/PreferencesAdvancedPane.swift index 1db4897f2..b6add144f 100644 --- a/Sources/CodexBar/PreferencesAdvancedPane.swift +++ b/Sources/CodexBar/PreferencesAdvancedPane.swift @@ -64,6 +64,10 @@ struct AdvancedPane: View { title: "Surprise me", subtitle: "Check if you like your agents having some fun up there.", binding: self.$settings.randomBlinkEnabled) + PreferenceToggleRow( + title: "Color-coded icons", + subtitle: "Tint menu bar icons green, yellow, or red based on session usage.", + binding: self.$settings.colorCodedIcons) } Divider() diff --git a/Sources/CodexBar/PreferencesDisplayPane.swift b/Sources/CodexBar/PreferencesDisplayPane.swift index 003fa27ee..eba30b80d 100644 --- a/Sources/CodexBar/PreferencesDisplayPane.swift +++ b/Sources/CodexBar/PreferencesDisplayPane.swift @@ -52,6 +52,28 @@ struct DisplayPane: View { } .disabled(!self.settings.menuBarShowsBrandIconWithPercent) .opacity(self.settings.menuBarShowsBrandIconWithPercent ? 1 : 0.5) + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Separator") + .font(.body) + Text("Character between percent and pace (e.g. 45% | +5%).") + .font(.footnote) + .foregroundStyle(.tertiary) + } + Spacer() + Picker("Separator", selection: self.$settings.menuBarSeparatorStyle) { + ForEach(MenuBarSeparatorStyle.allCases) { style in + Text(style.label).tag(style) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(maxWidth: 200) + } + .disabled(!self.settings.menuBarShowsBrandIconWithPercent || + self.settings.menuBarDisplayMode != .both) + .opacity(self.settings.menuBarShowsBrandIconWithPercent && + self.settings.menuBarDisplayMode == .both ? 1 : 0.5) } Divider() diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 0e06b99fc..f9ef4e42b 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -140,6 +140,23 @@ extension SettingsStore { set { self.menuBarDisplayModeRaw = newValue.rawValue } } + private var menuBarSeparatorStyleRaw: String? { + get { self.defaultsState.menuBarSeparatorStyleRaw } + set { + self.defaultsState.menuBarSeparatorStyleRaw = newValue + if let raw = newValue { + self.userDefaults.set(raw, forKey: "menuBarSeparatorStyle") + } else { + self.userDefaults.removeObject(forKey: "menuBarSeparatorStyle") + } + } + } + + var menuBarSeparatorStyle: MenuBarSeparatorStyle { + get { MenuBarSeparatorStyle(rawValue: self.menuBarSeparatorStyleRaw ?? "") ?? .dot } + set { self.menuBarSeparatorStyleRaw = newValue.rawValue } + } + var showAllTokenAccountsInMenu: Bool { get { self.defaultsState.showAllTokenAccountsInMenu } set { @@ -231,6 +248,14 @@ extension SettingsStore { } } + var colorCodedIcons: Bool { + get { self.defaultsState.colorCodedIcons } + set { + self.defaultsState.colorCodedIcons = newValue + self.userDefaults.set(newValue, forKey: "colorCodedIcons") + } + } + var mergeIcons: Bool { get { self.defaultsState.mergeIcons } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 4fa5640a8..4d7065552 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -36,6 +36,8 @@ extension SettingsStore { _ = self.kimiCookieSource _ = self.augmentCookieSource _ = self.ampCookieSource + _ = self.colorCodedIcons + _ = self.menuBarSeparatorStyle _ = self.mergeIcons _ = self.switcherShowsIcons _ = self.zaiAPIToken diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index e140de0ac..e18dfb90b 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -189,6 +189,8 @@ extension SettingsStore { forKey: "menuBarShowsBrandIconWithPercent") as? Bool ?? false let menuBarDisplayModeRaw = userDefaults.string(forKey: "menuBarDisplayMode") ?? MenuBarDisplayMode.percent.rawValue + let menuBarSeparatorStyleRaw = userDefaults.string(forKey: "menuBarSeparatorStyle") + ?? MenuBarSeparatorStyle.dot.rawValue let showAllTokenAccountsInMenu = userDefaults.object(forKey: "showAllTokenAccountsInMenu") as? Bool ?? false let storedPreferences = userDefaults.dictionary(forKey: "menuBarMetricPreferences") as? [String: String] ?? [:] var resolvedPreferences = storedPreferences @@ -211,6 +213,7 @@ extension SettingsStore { let openAIWebAccessEnabled = openAIWebAccessDefault ?? true if openAIWebAccessDefault == nil { userDefaults.set(true, forKey: "openAIWebAccessEnabled") } let jetbrainsIDEBasePath = userDefaults.string(forKey: "jetbrainsIDEBasePath") ?? "" + let colorCodedIcons = userDefaults.object(forKey: "colorCodedIcons") as? Bool ?? true let mergeIcons = userDefaults.object(forKey: "mergeIcons") as? Bool ?? true let switcherShowsIcons = userDefaults.object(forKey: "switcherShowsIcons") as? Bool ?? true let selectedMenuProviderRaw = userDefaults.string(forKey: "selectedMenuProvider") @@ -231,6 +234,7 @@ extension SettingsStore { resetTimesShowAbsolute: resetTimesShowAbsolute, menuBarShowsBrandIconWithPercent: menuBarShowsBrandIconWithPercent, menuBarDisplayModeRaw: menuBarDisplayModeRaw, + menuBarSeparatorStyleRaw: menuBarSeparatorStyleRaw, showAllTokenAccountsInMenu: showAllTokenAccountsInMenu, menuBarMetricPreferencesRaw: resolvedPreferences, costUsageEnabled: costUsageEnabled, @@ -241,6 +245,7 @@ extension SettingsStore { showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage, openAIWebAccessEnabled: openAIWebAccessEnabled, jetbrainsIDEBasePath: jetbrainsIDEBasePath, + colorCodedIcons: colorCodedIcons, mergeIcons: mergeIcons, switcherShowsIcons: switcherShowsIcons, selectedMenuProviderRaw: selectedMenuProviderRaw, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 9d8e833ba..e5e0ec8b5 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -15,6 +15,7 @@ struct SettingsDefaultsState: Sendable { var resetTimesShowAbsolute: Bool var menuBarShowsBrandIconWithPercent: Bool var menuBarDisplayModeRaw: String? + var menuBarSeparatorStyleRaw: String? var showAllTokenAccountsInMenu: Bool var menuBarMetricPreferencesRaw: [String: String] var costUsageEnabled: Bool @@ -25,6 +26,7 @@ struct SettingsDefaultsState: Sendable { var showOptionalCreditsAndExtraUsage: Bool var openAIWebAccessEnabled: Bool var jetbrainsIDEBasePath: String + var colorCodedIcons: Bool var mergeIcons: Bool var switcherShowsIcons: Bool var selectedMenuProviderRaw: String? diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 3c77d03e0..e7d2d96d9 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -250,12 +250,18 @@ extension StatusItemController { return .none }() + let usageColor: NSColor? = { + guard self.settings.colorCodedIcons, !needsAnimation else { return nil } + return UsageColorLevel.tintColor(for: snapshot?.primary?.usedPercent) + }() + if showBrandPercent, let brand = ProviderBrandIcon.image(for: primaryProvider) { let displayText = self.menuBarDisplayText(for: primaryProvider, snapshot: snapshot) self.setButtonImage(brand, for: button) self.setButtonTitle(displayText, for: button) + self.setButtonTintColor(usageColor, for: button) return } @@ -263,6 +269,7 @@ extension StatusItemController { if let morphProgress { let image = IconRenderer.makeMorphIcon(progress: morphProgress, style: style) self.setButtonImage(image, for: button) + self.setButtonTintColor(nil, for: button) } else { let image = IconRenderer.makeIcon( primaryRemaining: primary, @@ -275,6 +282,7 @@ extension StatusItemController { tilt: tilt, statusIndicator: statusIndicator) self.setButtonImage(image, for: button) + self.setButtonTintColor(usageColor, for: button) } } @@ -285,6 +293,12 @@ extension StatusItemController { // user setting we pass either "percent left" or "percent used". let showUsed = self.settings.usageBarsShowUsed let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent + let isAnimating = phase != nil && self.shouldAnimate(provider: provider) + + let usageColor: NSColor? = { + guard self.settings.colorCodedIcons, !isAnimating else { return nil } + return UsageColorLevel.tintColor(for: snapshot?.primary?.usedPercent) + }() if showBrandPercent, let brand = ProviderBrandIcon.image(for: provider) @@ -292,6 +306,7 @@ extension StatusItemController { let displayText = self.menuBarDisplayText(for: provider, snapshot: snapshot) self.setButtonImage(brand, for: button) self.setButtonTitle(displayText, for: button) + self.setButtonTintColor(usageColor, for: button) return } var primary = showUsed ? snapshot?.primary?.usedPercent : snapshot?.primary?.remainingPercent @@ -317,21 +332,21 @@ extension StatusItemController { var stale = self.store.isStale(provider: provider) var morphProgress: Double? - if let phase, self.shouldAnimate(provider: provider) { + if isAnimating { var pattern = self.animationPattern if provider == .claude, pattern == .unbraid { pattern = .cylon } if pattern == .unbraid { - morphProgress = pattern.value(phase: phase) / 100 + morphProgress = pattern.value(phase: phase!) / 100 primary = nil weekly = nil credits = nil stale = false } else { // Keep loading animation layout stable: IconRenderer switches layouts at `weeklyRemaining == 0`. - primary = max(pattern.value(phase: phase), Self.loadingPercentEpsilon) - weekly = max(pattern.value(phase: phase + pattern.secondaryOffset), Self.loadingPercentEpsilon) + primary = max(pattern.value(phase: phase!), Self.loadingPercentEpsilon) + weekly = max(pattern.value(phase: phase! + pattern.secondaryOffset), Self.loadingPercentEpsilon) credits = nil stale = false } @@ -351,6 +366,7 @@ extension StatusItemController { if let morphProgress { let image = IconRenderer.makeMorphIcon(progress: morphProgress, style: style) self.setButtonImage(image, for: button) + self.setButtonTintColor(nil, for: button) } else { self.setButtonTitle(nil, for: button) let image = IconRenderer.makeIcon( @@ -364,6 +380,7 @@ extension StatusItemController { tilt: tilt, statusIndicator: self.store.statusIndicator(for: provider)) self.setButtonImage(image, for: button) + self.setButtonTintColor(usageColor, for: button) } } @@ -372,6 +389,11 @@ extension StatusItemController { button.image = image } + private func setButtonTintColor(_ color: NSColor?, for button: NSStatusBarButton) { + if button.contentTintColor == color { return } + button.contentTintColor = color + } + private func setButtonTitle(_ title: String?, for button: NSStatusBarButton) { let value = title ?? "" if button.title != value { @@ -389,7 +411,8 @@ extension StatusItemController { provider: provider, percentWindow: self.menuBarPercentWindow(for: provider, snapshot: snapshot), paceWindow: snapshot?.secondary, - showUsed: self.settings.usageBarsShowUsed) + showUsed: self.settings.usageBarsShowUsed, + separatorStyle: self.settings.menuBarSeparatorStyle) } private func menuBarPercentWindow(for provider: UsageProvider, snapshot: UsageSnapshot?) -> RateWindow? { diff --git a/Sources/CodexBar/UsageColorLevel.swift b/Sources/CodexBar/UsageColorLevel.swift new file mode 100644 index 000000000..bb2eb6bc5 --- /dev/null +++ b/Sources/CodexBar/UsageColorLevel.swift @@ -0,0 +1,23 @@ +import AppKit + +enum UsageColorLevel: Sendable { + /// Returns a smoothly interpolated tint color based on usage percentage. + /// - 0-70%: green blending toward orange + /// - 70-90%: orange blending toward red + /// - >= 90%: red + /// - nil usage: returns nil (monochrome fallback) + static func tintColor(for usedPercent: Double?) -> NSColor? { + guard let pct = usedPercent else { return nil } + let clamped = min(max(pct, 0), 100) + + if clamped < 70 { + let fraction = CGFloat(clamped / 70) + return NSColor.systemGreen.blended(withFraction: fraction, of: .systemOrange) + } else if clamped < 90 { + let fraction = CGFloat((clamped - 70) / 20) + return NSColor.systemOrange.blended(withFraction: fraction, of: .systemRed) + } else { + return .systemRed + } + } +}