import AppKit import SwiftUI /// Shared state read by the NSEvent local monitor closure. The closure /// snapshots its captured environment at install time, so SwiftUI @State /// can't be used directly — a reference-type holder keeps the latest hover /// status visible to the monitor across SwiftUI updates. @MainActor final class AgentTabStripScrollState { static let shared = AgentTabStripScrollState() var isStripHovered: Bool = false } struct AgentTabStrip: View { @Environment(AppStore.self) private var store @State private var stripViewportWidth: CGFloat = 0 @State private var stripContentWidth: CGFloat = 0 @State private var scrollWheelMonitor: Any? var body: some View { GeometryReader { viewportGeo in ScrollViewReader { proxy in HStack(spacing: 4) { if isOverflowing { Button { selectAdjacentProvider(direction: -1, proxy: proxy) } label: { Image(systemName: "chevron.left") .font(.system(size: 10, weight: .semibold)) .frame(width: 18, height: 18) } .buttonStyle(.plain) .foregroundStyle(canMoveBackward ? Color.primary : Color.secondary.opacity(0.35)) .disabled(!canMoveBackward) .help("Show previous providers") } ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 5) { ForEach(visibleFilters) { filter in AgentTab( filter: filter, cost: cost(for: filter), isActive: store.selectedProvider == filter, quota: store.quotaSummary(for: filter) ) { store.switchTo(provider: filter) withAnimation(.easeInOut(duration: 0.18)) { proxy.scrollTo(filter.id, anchor: .center) } } .id(filter.id) } } .background( GeometryReader { contentGeo in Color.clear .onAppear { stripContentWidth = contentGeo.size.width } .onChange(of: contentGeo.size.width) { _, newWidth in stripContentWidth = newWidth } } ) } .padding(.horizontal, 12) .padding(.top, 8) .padding(.bottom, 4) .onHover { hovering in AgentTabStripScrollState.shared.isStripHovered = hovering } if isOverflowing { Button { selectAdjacentProvider(direction: 1, proxy: proxy) } label: { Image(systemName: "chevron.right") .font(.system(size: 10, weight: .semibold)) .frame(width: 18, height: 18) } .buttonStyle(.plain) .foregroundStyle(canMoveForward ? Color.primary : Color.secondary.opacity(0.35)) .disabled(!canMoveForward) .help("Show next providers") } } .onAppear { stripViewportWidth = viewportGeo.size.width installScrollWheelMonitorIfNeeded() withAnimation(.easeInOut(duration: 0.18)) { proxy.scrollTo(store.selectedProvider.id, anchor: .center) } } .onChange(of: viewportGeo.size.width) { _, newWidth in stripViewportWidth = newWidth } .onChange(of: store.selectedProvider) { _, newProvider in withAnimation(.easeInOut(duration: 0.18)) { proxy.scrollTo(newProvider.id, anchor: .center) } } .onDisappear { removeScrollWheelMonitorIfNeeded() } } } .frame(height: 38) } private var periodAll: MenubarPayload { store.periodAllPayload ?? store.payload } private var visibleFilters: [ProviderFilter] { // Tabs reflect the SELECTED range: every provider with usage (cost > 0) // in the period, ordered by usage. Providers with no usage in the range // are omitted. `.all` always leads. cost(for:) reads periodAll, so this // updates as the user switches periods. let costs = Dictionary(uniqueKeysWithValues: ProviderFilter.allCases.map { ($0, cost(for: $0) ?? 0) }) let detected = ProviderFilter.allCases.filter { filter in filter == .all || (costs[filter] ?? 0) > 0 } return detected.sorted { a, b in if a == .all { return true } if b == .all { return false } let ca = costs[a, default: 0] let cb = costs[b, default: 0] if ca != cb { return ca > cb } return a.rawValue < b.rawValue } } private func cost(for filter: ProviderFilter) -> Double? { let data = periodAll if filter == .all { return data.current.cost } let providers = Dictionary( data.current.providers.map { ($0.key.lowercased(), $0.value) }, uniquingKeysWith: + ) return filter.providerKeys.reduce(0.0) { sum, key in sum + (providers[key] ?? 0) } } private var currentFilterIndex: Int { visibleFilters.firstIndex(of: store.selectedProvider) ?? 0 } private var canMoveBackward: Bool { currentFilterIndex > 0 } private var canMoveForward: Bool { currentFilterIndex < visibleFilters.count - 1 } private var isOverflowing: Bool { stripContentWidth > (stripViewportWidth - 30) } private func selectAdjacentProvider(direction: Int, proxy: ScrollViewProxy) { guard !visibleFilters.isEmpty else { return } let targetIndex = min(max(currentFilterIndex + direction, 0), visibleFilters.count - 1) let target = visibleFilters[targetIndex] store.switchTo(provider: target) withAnimation(.easeInOut(duration: 0.18)) { proxy.scrollTo(target.id, anchor: .center) } } /// Standard mouse wheels emit vertical-only scroll deltas, which a horizontal /// `ScrollView` ignores. While the cursor is over the strip we transpose /// vertical-axis scroll fields onto the horizontal axis so the underlying /// NSScrollView receives a real horizontal delta. Trackpad events (precise /// deltas, with native horizontal component) are passed through untouched /// so vertical scrolling elsewhere in the popover is unaffected. private func installScrollWheelMonitorIfNeeded() { guard scrollWheelMonitor == nil else { return } scrollWheelMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in guard AgentTabStripScrollState.shared.isStripHovered, !event.hasPreciseScrollingDeltas, abs(event.scrollingDeltaX) < 0.001, abs(event.scrollingDeltaY) > 0, let cg = event.cgEvent?.copy() else { return event } let lineDeltaY = cg.getIntegerValueField(.scrollWheelEventDeltaAxis1) let pointDeltaY = cg.getDoubleValueField(.scrollWheelEventPointDeltaAxis1) let fixedDeltaY = cg.getDoubleValueField(.scrollWheelEventFixedPtDeltaAxis1) cg.setIntegerValueField(.scrollWheelEventDeltaAxis1, value: 0) cg.setDoubleValueField(.scrollWheelEventPointDeltaAxis1, value: 0) cg.setDoubleValueField(.scrollWheelEventFixedPtDeltaAxis1, value: 0) cg.setIntegerValueField(.scrollWheelEventDeltaAxis2, value: lineDeltaY) cg.setDoubleValueField(.scrollWheelEventPointDeltaAxis2, value: pointDeltaY) cg.setDoubleValueField(.scrollWheelEventFixedPtDeltaAxis2, value: fixedDeltaY) return NSEvent(cgEvent: cg) ?? event } } private func removeScrollWheelMonitorIfNeeded() { if let monitor = scrollWheelMonitor { NSEvent.removeMonitor(monitor) scrollWheelMonitor = nil } } } private struct AgentTab: View { let filter: ProviderFilter let cost: Double? let isActive: Bool let quota: QuotaSummary? let onTap: () -> Void @State private var hoverPopoverShown = false @State private var hoverEnterTask: DispatchWorkItem? @State private var hoverExitTask: DispatchWorkItem? @State private var clickDismissed = false /// Providers whose AgentTab chip reserves a 3pt bar slot underneath the /// label, even when not yet connected. Driven by which providers we /// actually implement live-quota fetching for in AppStore.quotaSummary. static func providerSupportsQuota(_ filter: ProviderFilter) -> Bool { switch filter { case .claude, .codex: return true default: return false } } var body: some View { VStack(spacing: 3) { HStack(spacing: 5) { Text(filter.rawValue) .font(.system(size: 11.5, weight: .medium)) .tracking(-0.05) if let cost, cost > 0 { Text(cost.asCompactCurrency()) .font(.codeMono(size: 10.5, weight: .medium)) .foregroundStyle(isActive ? AnyShapeStyle(.white.opacity(0.8)) : AnyShapeStyle(.secondary)) .tracking(-0.2) } } if quota != nil { AgentTabQuotaBar(quota: quota, isActive: isActive) .frame(height: 3) } } .padding(.horizontal, 10) .padding(.vertical, 4) .background( RoundedRectangle(cornerRadius: 6) .fill(isActive ? AnyShapeStyle(Theme.brandAccent) : AnyShapeStyle(Color.secondary.opacity(0.08))) ) .foregroundStyle(isActive ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary)) .contentShape(Rectangle()) .onTapGesture { hoverPopoverShown = false hoverEnterTask?.cancel() clickDismissed = true onTap() } .onHover { hovering in hoverEnterTask?.cancel() hoverExitTask?.cancel() if !hovering { clickDismissed = false let task = DispatchWorkItem { hoverPopoverShown = false } hoverExitTask = task DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: task) } else if !clickDismissed, quota != nil { let task = DispatchWorkItem { hoverPopoverShown = true } hoverEnterTask = task DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: task) } } .popover(isPresented: $hoverPopoverShown) { if let quota { QuotaDetailPopover(quota: quota) } } } } /// Thin progress bar drawn inside an AgentTab chip when that provider has a live quota /// source. Width matches the chip; color shifts green → amber → red at 70% / 90%. private struct AgentTabQuotaBar: View { let quota: QuotaSummary? let isActive: Bool var body: some View { GeometryReader { geo in ZStack(alignment: .leading) { Capsule() .fill(trackColor) if let percent = filledFraction { Capsule() .fill(barColor) .frame(width: max(2, geo.size.width * CGFloat(percent))) .animation(.easeOut(duration: 0.25), value: percent) } if case .terminalFailure = quota?.connection { // Hatched/red strip to telegraph "broken; reconnect needed". Capsule() .fill(Color.red.opacity(0.7)) } } } } private var filledFraction: Double? { guard let pct = quota?.primary?.percent else { return nil } return min(max(pct, 0), 1) } private var barColor: Color { guard let pct = quota?.primary?.percent else { return .clear } switch QuotaSummary.severity(for: pct) { case .normal: return isActive ? Color.white : Color.green.opacity(0.85) case .warning: return Color.yellow case .critical: return Color.orange case .danger: return Color.red } } private var trackColor: Color { isActive ? Color.white.opacity(0.20) : Color.secondary.opacity(0.18) } } private struct QuotaDetailPopover: View { let quota: QuotaSummary var body: some View { VStack(alignment: .leading, spacing: 8) { switch quota.connection { case .terminalFailure(let reason): terminalFailureCard(reason: reason) case .disconnected: Text(disconnectedMessage) .font(.system(size: 11)) .foregroundStyle(.secondary) case .loading where quota.details.isEmpty: Text("Loading…") .font(.system(size: 11)) .foregroundStyle(.secondary) default: rowsCard } } .padding(12) .frame(width: 260) } private var disconnectedMessage: String { switch quota.providerFilter { case .codex: return "Sign in with `codex` (ChatGPT mode) to track quota." case .claude: return "Sign in to Claude Code to track quota." default: return "Sign in to track quota." } } private var rowsCard: some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 6) { Text("\(quota.providerFilter.rawValue) usage") .font(.system(size: 11, weight: .semibold)) if case .stale = quota.connection { Text("stale") .font(.system(size: 9.5)) .foregroundStyle(.secondary) } else if case .transientFailure = quota.connection { Text("retrying") .font(.system(size: 9.5)) .foregroundStyle(.orange) } Spacer() if let plan = quota.planLabel, !plan.isEmpty { Text(plan) .font(.system(size: 9.5, weight: .medium)) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) .padding(.horizontal, 6) .padding(.vertical, 2) .background( RoundedRectangle(cornerRadius: 4) .fill(Color.secondary.opacity(0.12)) ) // Size to content. Plan names are bounded short strings // ("Max 20x", "Pro Lite", "Free Workspace"); a forced // maxWidth was making short labels look stretched. .fixedSize(horizontal: true, vertical: false) } } ForEach(Array(quota.details.enumerated()), id: \.offset) { _, w in QuotaDetailRow(window: w) } if !quota.footerLines.isEmpty { Divider() .padding(.top, 2) ForEach(Array(quota.footerLines.enumerated()), id: \.offset) { _, line in Text(line) .font(.system(size: 10.5)) .foregroundStyle(.secondary) } } } } private func terminalFailureCard(reason: String?) -> some View { VStack(alignment: .leading, spacing: 6) { Text(reconnectTitle) .font(.system(size: 11.5, weight: .semibold)) .foregroundStyle(.red) Text(reason ?? defaultReconnectReason) .font(.system(size: 11)) .foregroundStyle(.secondary) .lineLimit(2) Text(reconnectInstruction) .font(.system(size: 10.5)) .foregroundStyle(.secondary) } } private var reconnectTitle: String { switch quota.providerFilter { case .codex: return "Reconnect Codex" default: return "Reconnect Claude" } } private var defaultReconnectReason: String { switch quota.providerFilter { case .codex: return "Refresh token rejected by OpenAI." default: return "Refresh token rejected by Anthropic." } } private var reconnectInstruction: String { switch quota.providerFilter { case .codex: return "Run `codex login` in your terminal, then click Reconnect." default: return "Open Claude Code in your terminal and type `/login`, then click Reconnect." } } } private struct QuotaDetailRow: View { let window: QuotaSummary.Window var body: some View { HStack(spacing: 8) { Text(window.label) .font(.system(size: 10.5)) .frame(width: 92, alignment: .leading) GeometryReader { geo in ZStack(alignment: .leading) { Capsule().fill(Color.secondary.opacity(0.18)) Capsule() .fill(barColor) .frame(width: max(2, geo.size.width * CGFloat(min(max(window.percent, 0), 1)))) } } .frame(height: 4) Text(window.percentLabel) .font(.codeMono(size: 10.5, weight: .medium)) .frame(width: 36, alignment: .trailing) if !window.resetsInLabel.isEmpty { Text(window.resetsInLabel) .font(.codeMono(size: 10)) .foregroundStyle(.secondary) .frame(width: 50, alignment: .trailing) } } } private var barColor: Color { switch QuotaSummary.severity(for: window.percent) { case .normal: return Color.green.opacity(0.85) case .warning: return Color.yellow case .critical: return Color.orange case .danger: return Color.red } } } extension ProviderFilter { @MainActor var color: Color { switch self { case .all: return Theme.brandAccent case .claude: return Theme.categoricalClaude case .cline: return Color(red: 0x23/255.0, green: 0x8A/255.0, blue: 0x7E/255.0) case .codewhale: return Color(red: 0x38/255.0, green: 0xBD/255.0, blue: 0xF8/255.0) case .codex: return Theme.categoricalCodex case .cursor: return Theme.categoricalCursor case .cursorAgent: return Color(red: 0x4E/255.0, green: 0xC9/255.0, blue: 0xB0/255.0) case .copilot: return Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/255.0) case .devin: return Color(red: 0x25/255.0, green: 0xA0/255.0, blue: 0x8D/255.0) case .droid: return Color(red: 0x7C/255.0, green: 0x3A/255.0, blue: 0xED/255.0) case .gemini: return Color(red: 0x44/255.0, green: 0x85/255.0, blue: 0xF4/255.0) case .ibmBob: return Color(red: 0x0F/255.0, green: 0x62/255.0, blue: 0xFE/255.0) case .kiloCode: return Color(red: 0x00/255.0, green: 0x96/255.0, blue: 0x88/255.0) case .kiro: return Color(red: 0x4A/255.0, green: 0x9E/255.0, blue: 0xC4/255.0) case .kimi: return Color(red: 0xA4/255.0, green: 0xC6/255.0, blue: 0x39/255.0) case .kimiCode: return Color(red: 0xA3/255.0, green: 0xE6/255.0, blue: 0x35/255.0) case .lingtaiTui: return Color(red: 0x22/255.0, green: 0xA7/255.0, blue: 0xA0/255.0) case .openclaw: return Color(red: 0xDA/255.0, green: 0x70/255.0, blue: 0x56/255.0) case .opencode: return Color(red: 0x5B/255.0, green: 0x83/255.0, blue: 0x5B/255.0) case .pi: return Color(red: 0xB2/255.0, green: 0x6B/255.0, blue: 0x3D/255.0) case .qwen: return Color(red: 0x61/255.0, green: 0x5E/255.0, blue: 0xEB/255.0) case .omp: return Color(red: 0x8B/255.0, green: 0x5C/255.0, blue: 0xB0/255.0) case .rooCode: return Color(red: 0x4C/255.0, green: 0xAF/255.0, blue: 0x50/255.0) case .crush: return Color(red: 0xE0/255.0, green: 0x6C/255.0, blue: 0x9F/255.0) case .antigravity: return Color(red: 0xFF/255.0, green: 0x7A/255.0, blue: 0x45/255.0) case .goose: return Color(red: 0xB7/255.0, green: 0x8D/255.0, blue: 0x52/255.0) case .grok: return Color(red: 0x8E/255.0, green: 0x8E/255.0, blue: 0x93/255.0) case .hermes: return Color(red: 0xC7/255.0, green: 0x52/255.0, blue: 0x3E/255.0) case .zcode: return Color(red: 0x52/255.0, green: 0x6E/255.0, blue: 0xD6/255.0) } } }