diff --git a/Sources/CodexBar/MenuBarLayoutEditor.swift b/Sources/CodexBar/MenuBarLayoutEditor.swift index d588770274..e7f295179d 100644 --- a/Sources/CodexBar/MenuBarLayoutEditor.swift +++ b/Sources/CodexBar/MenuBarLayoutEditor.swift @@ -693,8 +693,12 @@ struct MenuBarLayoutPreview: View { snapshot: snapshot) session = semanticWindows.session weekly = semanticWindows.weekly + // Provider-specific by design: Mistral's automatic lane can explicitly select its Monthly Plan window. + let automaticPreference = provider == .mistral + ? self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) + : .automatic rawAutomatic = MenuBarMetricWindowResolver.rateWindow( - preference: .automatic, + preference: automaticPreference, provider: provider, snapshot: snapshot, supportsAverage: self.settings.menuBarMetricSupportsAverage(for: provider), @@ -717,6 +721,7 @@ struct MenuBarLayoutPreview: View { .flatMap { UsagePaceText.weeklyDetail(provider: provider, pace: $0, now: now).rightLabel } let cost = self.store.tokenSnapshotForCurrentProviderConfig(for: provider)?.snapshot let costToday = MenuBarLayoutCostResolver.todayCostUSD(snapshot: cost, now: now) + let automaticRenderWindow = MenuBarLayoutRenderWindow(automatic) return MenuBarLayoutRenderData( provider: provider, iconKey: provider.rawValue, @@ -726,7 +731,11 @@ struct MenuBarLayoutPreview: View { weekly: MenuBarLayoutRenderWindow(weekly), scopedWeekly: MenuBarLayoutRenderWindow(scopedNamed?.window), scopedWeeklyTitle: scopedNamed?.title, - automatic: MenuBarLayoutRenderWindow(automatic), + automatic: automaticRenderWindow, + // Provider-specific by design: Mistral uses spend text when its automatic lane has no percentage window. + automaticText: provider == .mistral && automaticRenderWindow == nil + ? StatusItemController.mistralSpendDisplayText(snapshot: snapshot) + : nil, sessionPace: self.store.menuBarLayoutPaceText( provider: provider, window: session, @@ -782,6 +791,7 @@ struct MenuBarLayoutPreview: View { scopedWeekly: MenuBarLayoutRenderWindow(scopedWeekly), scopedWeeklyTitle: "Fable only", automatic: MenuBarLayoutRenderWindow(session), + automaticText: nil, sessionPace: samplePace(session), weeklyPace: samplePace(weekly), automaticPace: samplePace(session), diff --git a/Sources/CodexBar/MenuBarLayoutRenderer.swift b/Sources/CodexBar/MenuBarLayoutRenderer.swift index 3c9e0159b1..49c7ab144c 100644 --- a/Sources/CodexBar/MenuBarLayoutRenderer.swift +++ b/Sources/CodexBar/MenuBarLayoutRenderer.swift @@ -33,6 +33,8 @@ struct MenuBarLayoutRenderData: Hashable { /// `.scopedWeekly` token with the real model rather than assuming Fable. let scopedWeeklyTitle: String? let automatic: MenuBarLayoutRenderWindow? + /// Provider-specific text used by the automatic percent token when no percentage window exists. + let automaticText: String? /// Signed pace deltas per window, already formatted (`+11%`, `-8%`, `0%`). Pace needs the store's /// historical dataset and work-day setting, so it is resolved upstream like `runsOut` rather than /// derived from the render windows here. @@ -334,8 +336,11 @@ final class MenuBarLayoutRenderer { attributes: style.attributes) case let .percent(window): let rateWindow = Self.window(window, data: data) - let percent = rateWindow.map { options.showUsed ? $0.usedPercent : $0.remainingPercent } - let value = percent.map(UsageFormatter.percentString) ?? Self.missingValue + let resolvedValue = Self.percentValue( + window: window, + rateWindow: rateWindow, + automaticText: data.automaticText, + showUsed: options.showUsed) let prefix: String let accessibilityPrefix: String switch window { @@ -353,10 +358,10 @@ final class MenuBarLayoutRenderer { prefix = "" accessibilityPrefix = L("Usage") } - let display = prefix.isEmpty ? value : "\(prefix) \(value)" - let accessibility = percent == nil - ? L("%@ unavailable", accessibilityPrefix) - : L("%@ %@", accessibilityPrefix, value) + let display = prefix.isEmpty ? resolvedValue.text : "\(prefix) \(resolvedValue.text)" + let accessibility = resolvedValue.isAvailable + ? L("%@ %@", accessibilityPrefix, resolvedValue.text) + : L("%@ unavailable", accessibilityPrefix) return self.textToken(display, accessibilityText: accessibility, attributes: style.attributes) case let .pace(window): let accessibilityPrefix = Self.paceAccessibilityPrefix(window, data: data) @@ -424,6 +429,23 @@ final class MenuBarLayoutRenderer { L("%@ icon", data.providerName ?? L("Provider")) } + private static func percentValue( + window: PercentWindow, + rateWindow: MenuBarLayoutRenderWindow?, + automaticText: String?, + showUsed: Bool) + -> (text: String, isAvailable: Bool) + { + if let rateWindow { + let percent = showUsed ? rateWindow.usedPercent : rateWindow.remainingPercent + return (UsageFormatter.percentString(percent), true) + } + if window == .automatic, let automaticText { + return (automaticText, true) + } + return (Self.missingValue, false) + } + private static func compactRunsOutText(_ text: String) -> String { let nowLabel = L("Runs out now") if text.hasPrefix(nowLabel) { diff --git a/Sources/CodexBar/StatusItemController+MenuBarLayout.swift b/Sources/CodexBar/StatusItemController+MenuBarLayout.swift index 8c9fed7a03..a125f1cc52 100644 --- a/Sources/CodexBar/StatusItemController+MenuBarLayout.swift +++ b/Sources/CodexBar/StatusItemController+MenuBarLayout.swift @@ -75,6 +75,7 @@ extension StatusItemController { let costStrings = self.menuBarLayoutCostStrings(provider: provider, now: now) let providerName = L(self.store.metadata(for: provider).displayName) let accountLabel = self.menuBarLayoutAccountLabel(provider: provider, snapshot: snapshot) + let automatic = MenuBarLayoutRenderWindow(windows.automatic) return MenuBarLayoutRenderData( provider: provider, @@ -85,7 +86,11 @@ extension StatusItemController { weekly: MenuBarLayoutRenderWindow(windows.weekly), scopedWeekly: MenuBarLayoutRenderWindow(scopedNamed?.window), scopedWeeklyTitle: scopedNamed?.title, - automatic: MenuBarLayoutRenderWindow(windows.automatic), + automatic: automatic, + // Provider-specific by design: Mistral uses spend text when its automatic lane has no percentage window. + automaticText: provider == .mistral && automatic == nil + ? Self.mistralSpendDisplayText(snapshot: snapshot) + : nil, sessionPace: self.store.menuBarLayoutPaceText( provider: provider, window: windows.session, @@ -159,8 +164,12 @@ extension StatusItemController { let semanticWindows = MenuBarLayoutSemanticWindowResolver.windows( provider: provider, snapshot: snapshot) + // Provider-specific by design: Mistral's automatic lane can explicitly select its Monthly Plan window. + let automaticPreference = provider == .mistral + ? self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) + : .automatic let automatic = MenuBarMetricWindowResolver.rateWindow( - preference: .automatic, + preference: automaticPreference, provider: provider, snapshot: snapshot, supportsAverage: self.settings.menuBarMetricSupportsAverage(for: provider), diff --git a/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift b/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift index 12a40751b3..5cf81e6a2c 100644 --- a/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift +++ b/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift @@ -177,6 +177,7 @@ struct MenuBarLayoutRendererTests { scopedWeekly: nil, scopedWeeklyTitle: nil, automatic: nil, + automaticText: nil, sessionPace: nil, weeklyPace: nil, automaticPace: nil, @@ -259,6 +260,7 @@ struct MenuBarLayoutRendererTests { scopedWeekly: nil, scopedWeeklyTitle: nil, automatic: nil, + automaticText: nil, // Pace is suppressed below 3% of window elapsed; the percent token must survive that. sessionPace: nil, weeklyPace: nil, @@ -472,6 +474,7 @@ struct MenuBarLayoutRendererTests { scopedWeekly: nil, scopedWeeklyTitle: nil, automatic: textOnlyWindow, + automaticText: nil, sessionPace: nil, weeklyPace: nil, automaticPace: nil, @@ -585,6 +588,7 @@ struct MenuBarLayoutRendererTests { windowMinutes: 300, resetsAt: automaticResetAt ?? self.now.addingTimeInterval(2 * 60 * 60), resetDescription: nil)), + automaticText: nil, sessionPace: "-8%", weeklyPace: "+11%", automaticPace: "0%", diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 2c063c77e1..731c4c8a49 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -2498,7 +2498,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+MenuBarLayout.swift", - line: 146, + line: 151, anchor: "if provider == .codex,", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, diff --git a/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift index 9c44e161ee..cde8cc7b51 100644 --- a/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift +++ b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift @@ -940,3 +940,126 @@ struct StatusItemBalanceDisplayTests { updatedAt: Date()).toUsageSnapshot() } } + +extension StatusItemBalanceDisplayTests { + @Test + func `stored Mistral icon and percent layout preserves selected monthly plan`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-mistral-custom-layout-monthly-plan", + provider: .mistral) + settings.setMenuBarMetricPreference(.monthlyPlan, for: .mistral) + let layout = MenuBarLayout(lines: [[.icon, .percent(window: .automatic)]]) + settings.setMenuBarLayout(layout, for: nil) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = MistralUsageSnapshot( + totalCost: 1.2345, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 10000, + totalOutputTokens: 5000, + totalCachedTokens: 0, + modelCount: 2, + startDate: nil, + endDate: nil, + updatedAt: Date()) + .toUsageSnapshot() + .with(extraRateWindows: [ + NamedRateWindow( + id: "mistral-monthly-plan", + title: "Monthly Plan", + window: RateWindow( + usedPercent: 42, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil)), + ]) + + store._setSnapshotForTesting(snapshot, provider: .mistral) + store._setErrorForTesting(nil, provider: .mistral) + + let statusItemData = controller.menuBarLayoutRenderData( + provider: .mistral, + snapshot: snapshot, + warningFlash: false) + let previewData = MenuBarLayoutPreview( + layout: layout, + provider: .mistral, + settings: settings, + store: store) + .liveData(provider: .mistral, snapshot: snapshot) + + for data in [statusItemData, previewData] { + let rendered = MenuBarLayoutRenderer().render( + layout: layout, + data: data, + icon: NSImage(size: NSSize(width: 16, height: 16)), + options: MenuBarLayoutRenderOptions( + size: .regular, + highContrast: false, + showUsed: true, + appearanceName: "aqua", + isDebugApp: false, + now: Date())) + + #expect(data.automatic?.usedPercent == 42) + #expect(data.automaticText == nil) + #expect(rendered.attributedTitle.string.hasSuffix("42%")) + #expect(rendered.accessibilityLabel.contains("42%")) + } + } + + @Test + func `stored Mistral icon and percent layout uses api spend in status item and preview`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-mistral-custom-layout", + provider: .mistral) + let layout = MenuBarLayout(lines: [[.icon, .percent(window: .automatic)]]) + settings.setMenuBarLayout(layout, for: nil) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = MistralUsageSnapshot( + totalCost: 1.2345, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 10000, + totalOutputTokens: 5000, + totalCachedTokens: 0, + modelCount: 2, + startDate: nil, + endDate: nil, + updatedAt: Date()).toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .mistral) + store._setErrorForTesting(nil, provider: .mistral) + + let statusItemData = controller.menuBarLayoutRenderData( + provider: .mistral, + snapshot: snapshot, + warningFlash: false) + let previewData = MenuBarLayoutPreview( + layout: layout, + provider: .mistral, + settings: settings, + store: store) + .liveData(provider: .mistral, snapshot: snapshot) + + for data in [statusItemData, previewData] { + let rendered = MenuBarLayoutRenderer().render( + layout: layout, + data: data, + icon: NSImage(size: NSSize(width: 16, height: 16)), + options: MenuBarLayoutRenderOptions( + size: .regular, + highContrast: false, + showUsed: true, + appearanceName: "aqua", + isDebugApp: false, + now: Date())) + + #expect(data.automatic == nil) + #expect(rendered.attributedTitle.string.hasSuffix("€1.2345")) + #expect(rendered.accessibilityLabel.contains("€1.2345")) + } + } +}