diff --git a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift index 8c2e01656..e51c52aef 100644 --- a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift +++ b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift @@ -272,6 +272,14 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { } var cursor = cursor + + if cursor > 0, !menu.items[cursor - 1].isSeparatorItem { + let separator = NSMenuItem.separator() + separator.tag = self.tag + menu.insertItem(separator, at: cursor) + cursor += 1 + } + let headerItem = NSMenuItem() headerItem.tag = self.tag headerItem.isEnabled = false @@ -292,6 +300,28 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { return cursor } + if let selectedProvider = self.selectedUsageProviderId, + let primary = rows.first(where: { $0.providerId.lowercased() == selectedProvider }), + rows.count > 1 + { + let others = rows.filter { $0.providerId.lowercased() != selectedProvider } + + let item = NSMenuItem() + item.tag = self.tag + item.isEnabled = true + if !others.isEmpty { + item.submenu = self.buildUsageOverflowMenu(rows: others, width: width) + } + item.view = self.makeHostedView( + rootView: AnyView(UsageMenuLabelView(row: primary, width: width, showsChevron: !others.isEmpty)), + width: width, + highlighted: true) + menu.insertItem(item, at: cursor) + cursor += 1 + + return cursor + } + for row in rows { let item = NSMenuItem() item.tag = self.tag @@ -307,11 +337,34 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { return cursor } + private var selectedUsageProviderId: String? { + guard let model = self.cachedSnapshot?.defaults.model.nonEmpty else { return nil } + let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) + guard let slash = trimmed.firstIndex(of: "/") else { return nil } + let provider = trimmed[.. NSMenu { + let menu = NSMenu() + for row in rows { + let item = NSMenuItem() + item.tag = self.tag + item.isEnabled = false + item.view = self.makeHostedView( + rootView: AnyView(UsageMenuLabelView(row: row, width: width)), + width: width, + highlighted: false) + menu.addItem(item) + } + return menu + } + private var isControlChannelConnected: Bool { #if DEBUG if let override = self.testControlChannelConnected { return override } @@ -938,6 +991,12 @@ extension MenuSessionsInjector { self.cacheUpdatedAt = Date() } + func setTestingUsageSummary(_ summary: GatewayUsageSummary?, errorText: String? = nil) { + self.cachedUsageSummary = summary + self.cachedUsageErrorText = errorText + self.usageCacheUpdatedAt = Date() + } + func injectForTesting(into menu: NSMenu) { self.inject(into: menu) } diff --git a/apps/macos/Sources/Clawdbot/UsageData.swift b/apps/macos/Sources/Clawdbot/UsageData.swift index 2318d98e8..4a181418e 100644 --- a/apps/macos/Sources/Clawdbot/UsageData.swift +++ b/apps/macos/Sources/Clawdbot/UsageData.swift @@ -21,6 +21,7 @@ struct GatewayUsageSummary: Codable { struct UsageRow: Identifiable { let id: String + let providerId: String let displayName: String let plan: String? let windowLabel: String? @@ -73,6 +74,7 @@ extension GatewayUsageSummary { if let error = provider.error, provider.windows.isEmpty { return UsageRow( id: provider.provider, + providerId: provider.provider, displayName: provider.displayName, plan: provider.plan, windowLabel: nil, @@ -87,6 +89,7 @@ extension GatewayUsageSummary { return UsageRow( id: "\(provider.provider)-\(window.label)", + providerId: provider.provider, displayName: provider.displayName, plan: provider.plan, windowLabel: window.label, diff --git a/apps/macos/Sources/Clawdbot/UsageMenuLabelView.swift b/apps/macos/Sources/Clawdbot/UsageMenuLabelView.swift index 4b1193e2f..5d32bf26d 100644 --- a/apps/macos/Sources/Clawdbot/UsageMenuLabelView.swift +++ b/apps/macos/Sources/Clawdbot/UsageMenuLabelView.swift @@ -3,12 +3,19 @@ import SwiftUI struct UsageMenuLabelView: View { let row: UsageRow let width: CGFloat + var showsChevron: Bool = false + @Environment(\.menuItemHighlighted) private var isHighlighted private let paddingLeading: CGFloat = 22 private let paddingTrailing: CGFloat = 14 private let barHeight: CGFloat = 6 - private var primaryTextColor: Color { .primary } - private var secondaryTextColor: Color { .secondary } + private var primaryTextColor: Color { + self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary + } + + private var secondaryTextColor: Color { + self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary + } var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -36,6 +43,13 @@ struct UsageMenuLabelView: View { .lineLimit(1) .fixedSize(horizontal: true, vertical: false) .layoutPriority(2) + + if self.showsChevron { + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(self.secondaryTextColor) + .padding(.leading, 2) + } } } .padding(.vertical, 10) diff --git a/apps/macos/Tests/ClawdbotIPCTests/MenuSessionsInjectorTests.swift b/apps/macos/Tests/ClawdbotIPCTests/MenuSessionsInjectorTests.swift index cae8b7be4..a43915991 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/MenuSessionsInjectorTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/MenuSessionsInjectorTests.swift @@ -23,7 +23,7 @@ struct MenuSessionsInjectorTests { let injector = MenuSessionsInjector() injector.setTestingControlChannelConnected(true) - let defaults = SessionDefaults(model: "claude-opus-4-5", contextTokens: 200_000) + let defaults = SessionDefaults(model: "anthropic/claude-opus-4-5", contextTokens: 200_000) let rows = [ SessionRow( id: "main", @@ -66,6 +66,24 @@ struct MenuSessionsInjectorTests { rows: rows) injector.setTestingSnapshot(snapshot, errorText: nil) + let usage = GatewayUsageSummary( + updatedAt: Date().timeIntervalSince1970 * 1000, + providers: [ + GatewayUsageProvider( + provider: "anthropic", + displayName: "Claude", + windows: [GatewayUsageWindow(label: "5h", usedPercent: 12, resetAt: nil)], + plan: "Pro", + error: nil), + GatewayUsageProvider( + provider: "openai-codex", + displayName: "Codex", + windows: [GatewayUsageWindow(label: "day", usedPercent: 3, resetAt: nil)], + plan: nil, + error: nil), + ]) + injector.setTestingUsageSummary(usage, errorText: nil) + let menu = NSMenu() menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: "")) menu.addItem(.separator()) @@ -73,5 +91,6 @@ struct MenuSessionsInjectorTests { injector.injectForTesting(into: menu) #expect(menu.items.contains { $0.tag == 9_415_557 }) + #expect(menu.items.contains { $0.tag == 9_415_557 && $0.isSeparatorItem }) } }