import CodexBarCore import SwiftUI import WidgetKit extension EnvironmentValues { @Entry fileprivate var widgetUsageShowsUsed: Bool = false } struct CodexBarUsageWidgetView: View { @Environment(\.widgetFamily) private var family let entry: CodexBarWidgetEntry var body: some View { let providerEntry = self.entry.snapshot.entries.first { $0.provider == self.entry.provider } ZStack { Color.black.opacity(0.02) if let providerEntry { self.content(providerEntry: providerEntry) } else { self.emptyState } } .containerBackground(.fill.tertiary, for: .widget) .environment(\.widgetUsageShowsUsed, self.entry.snapshot.usageBarsShowUsed) } @ViewBuilder private func content(providerEntry: WidgetSnapshot.ProviderEntry) -> some View { switch self.family { case .systemSmall: SmallUsageView(entry: providerEntry) case .systemMedium: MediumUsageView(entry: providerEntry) default: LargeUsageView(entry: providerEntry) } } private var emptyState: some View { VStack(alignment: .leading, spacing: 6) { Text("Open CodexBar") .font(.body) .fontWeight(.semibold) Text("Usage data will appear once the app refreshes.") .font(.caption) .foregroundStyle(.secondary) } .padding(12) } } struct CodexBarHistoryWidgetView: View { @Environment(\.widgetFamily) private var family let entry: CodexBarWidgetEntry var body: some View { let providerEntry = self.entry.snapshot.entries.first { $0.provider == self.entry.provider } ZStack { Color.black.opacity(0.02) if let providerEntry { HistoryView(entry: providerEntry, isLarge: self.family == .systemLarge) } else { self.emptyState } } .containerBackground(.fill.tertiary, for: .widget) } private var emptyState: some View { VStack(alignment: .leading, spacing: 6) { Text("Open CodexBar") .font(.body) .fontWeight(.semibold) Text("Usage history will appear after a refresh.") .font(.caption) .foregroundStyle(.secondary) } .padding(12) } } struct CodexBarCompactWidgetView: View { let entry: CodexBarCompactEntry var body: some View { let providerEntry = self.entry.snapshot.entries.first { $0.provider == self.entry.provider } ZStack { Color.black.opacity(0.02) if let providerEntry { CompactMetricView(entry: providerEntry, metric: self.entry.metric) } else { self.emptyState } } .containerBackground(.fill.tertiary, for: .widget) } private var emptyState: some View { VStack(alignment: .leading, spacing: 6) { Text("Open CodexBar") .font(.body) .fontWeight(.semibold) Text("Usage data will appear once the app refreshes.") .font(.caption) .foregroundStyle(.secondary) } .padding(12) } } struct CodexBarSwitcherWidgetView: View { @Environment(\.widgetFamily) private var family let entry: CodexBarSwitcherEntry var body: some View { let providerEntry = self.entry.snapshot.entries.first { $0.provider == self.entry.provider } ZStack { Color.black.opacity(0.02) VStack(alignment: .leading, spacing: 10) { ProviderSwitcherRow( providers: self.entry.availableProviders, selected: self.entry.provider, updatedAt: providerEntry?.updatedAt ?? Date(), compact: self.family == .systemSmall, showsTimestamp: self.family != .systemSmall) if let providerEntry { self.content(providerEntry: providerEntry) } else { self.emptyState } } .padding(12) } .containerBackground(.fill.tertiary, for: .widget) .environment(\.widgetUsageShowsUsed, self.entry.snapshot.usageBarsShowUsed) } @ViewBuilder private func content(providerEntry: WidgetSnapshot.ProviderEntry) -> some View { switch self.family { case .systemSmall: SwitcherSmallUsageView(entry: providerEntry) case .systemMedium: SwitcherMediumUsageView(entry: providerEntry) default: SwitcherLargeUsageView(entry: providerEntry) } } private var emptyState: some View { VStack(alignment: .leading, spacing: 6) { Text("Open CodexBar") .font(.caption) .foregroundStyle(.secondary) Text("Usage data appears after a refresh.") .font(.caption2) .foregroundStyle(.secondary) } } } private struct CompactMetricView: View { let entry: WidgetSnapshot.ProviderEntry let metric: CompactMetric var body: some View { let display = CompactMetricFormatter.display(for: self.entry, metric: self.metric) VStack(alignment: .leading, spacing: 8) { HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) VStack(alignment: .leading, spacing: 2) { Text(display.value) .font(.title2) .fontWeight(.semibold) Text(display.label) .font(.caption2) .foregroundStyle(.secondary) if let detail = display.detail { Text(detail) .font(.caption2) .foregroundStyle(.secondary) } } } .padding(12) } } struct CompactMetricDisplay: Equatable { let value: String let label: String let detail: String? } enum CompactMetricFormatter { static func display(for entry: WidgetSnapshot.ProviderEntry, metric: CompactMetric) -> CompactMetricDisplay { switch metric { case .credits: if let cost = WidgetBalanceFormatter.extraUsageCost(for: entry) { return CompactMetricDisplay( value: WidgetFormat.currency(cost.used, code: cost.currencyCode), label: "Extra usage balance", detail: nil) } let value = entry.creditsRemaining.map(WidgetFormat.credits) ?? "—" return CompactMetricDisplay(value: value, label: "Credits left", detail: nil) case .todayCost: let value = entry.tokenUsage.map { token in token.sessionCostUSD.map { WidgetFormat.currency($0, code: token.currencyCode) } ?? "—" } ?? "—" let detail = entry.tokenUsage?.sessionTokens.map(WidgetFormat.tokenCount) let label = entry.tokenUsage.map { WidgetFormat.tokenRowTitle("\($0.sessionLabel) cost", summary: $0, entryUpdatedAt: entry.updatedAt) } ?? "Today cost" return CompactMetricDisplay(value: value, label: label, detail: detail) case .last30DaysCost: let value = entry.tokenUsage.map { token in token.last30DaysCostUSD.map { WidgetFormat.currency($0, code: token.currencyCode) } ?? "—" } ?? "—" let detail = entry.tokenUsage?.last30DaysTokens.map(WidgetFormat.tokenCount) let label = entry.tokenUsage.map { WidgetFormat.tokenRowTitle("\($0.last30DaysLabel) cost", summary: $0, entryUpdatedAt: entry.updatedAt) } ?? "30d cost" return CompactMetricDisplay(value: value, label: label, detail: detail) } } } private struct ProviderSwitcherRow: View { let providers: [UsageProvider] let selected: UsageProvider let updatedAt: Date let compact: Bool let showsTimestamp: Bool var body: some View { HStack(spacing: self.compact ? 4 : 6) { ForEach(self.providers, id: \.self) { provider in ProviderSwitchChip( provider: provider, selected: provider == self.selected, compact: self.compact) } if self.showsTimestamp { Spacer(minLength: 6) Text(WidgetFormat.relativeDate(self.updatedAt)) .font(.caption2) .foregroundStyle(.secondary) } } } } private struct ProviderSwitchChip: View { let provider: UsageProvider let selected: Bool let compact: Bool var body: some View { let label = self.compact ? self.shortLabel : self.longLabel let background = self.selected ? WidgetColors.color(for: self.provider).opacity(0.2) : Color.primary.opacity(0.08) if let choice = ProviderChoice(provider: self.provider) { Button(intent: SwitchWidgetProviderIntent(provider: choice)) { Text(label) .font(self.compact ? .caption2.weight(.semibold) : .caption.weight(.semibold)) .foregroundStyle(self.selected ? Color.primary : Color.secondary) .padding(.horizontal, self.compact ? 6 : 8) .padding(.vertical, self.compact ? 3 : 4) .background(Capsule().fill(background)) } .buttonStyle(.plain) } else { Text(label) .font(self.compact ? .caption2.weight(.semibold) : .caption.weight(.semibold)) .foregroundStyle(self.selected ? Color.primary : Color.secondary) .padding(.horizontal, self.compact ? 6 : 8) .padding(.vertical, self.compact ? 3 : 4) .background(Capsule().fill(background)) } } private var longLabel: String { ProviderDefaults.metadata[self.provider]?.displayName ?? self.provider.rawValue.capitalized } private var shortLabel: String { switch self.provider { case .codex: "Codex" case .openai: "OpenAI" case .azureopenai: "Azure OpenAI" case .claude: "Claude" case .gemini: "Gemini" case .antigravity: "Anti" case .cursor: "Cursor" case .opencode: "OpenCode" case .opencodego: "OpenCode Go" case .alibaba: "Alibaba" case .alibabatokenplan: "Token Plan" case .zai: "z.ai" case .factory: "Droid" case .copilot: "Copilot" case .devin: "Devin" case .minimax: "MiniMax" case .manus: "Manus" case .vertexai: "Vertex" case .kilo: "Kilo" case .kiro: "Kiro" case .augment: "Augment" case .jetbrains: "JetBrains" case .kimi: "Kimi" case .kimik2: "Kimi K2" case .moonshot: "Moonshot" case .amp: "Amp" case .t3chat: "T3 Chat" case .ollama: "Ollama" case .synthetic: "Synthetic" case .openrouter: "OpenRouter" case .crossmodel: "CrossModel" case .clawrouter: "ClawRouter" case .sub2api: "sub2api" case .wayfinder: "Wayfinder" case .elevenlabs: "ElevenLabs" case .warp: "Warp" case .windsurf: "Windsurf" case .perplexity: "Pplx" case .mimo: "MiMo" case .doubao: "Doubao" case .sakana: "Sakana" case .abacus: "Abacus" case .mistral: "Mistral" case .deepseek: "DeepSeek" case .codebuff: "Codebuff" case .crof: "Crof" case .venice: "Venice" case .commandcode: "Command Code" case .qoder: "Qoder" case .stepfun: "StepFun" case .bedrock: "Bedrock" case .grok: "Grok" case .groq: "Groq" case .llmproxy: "LLM Proxy" case .litellm: "LiteLLM" case .deepgram: "Deepgram" case .poe: "Poe" case .chutes: "Chutes" case .zed: "Zed" } } } private struct SwitcherSmallUsageView: View { let entry: WidgetSnapshot.ProviderEntry var body: some View { VStack(alignment: .leading, spacing: 8) { ForEach(WidgetUsageRow.rows( for: self.entry, limit: WidgetUsageRow.smallWidgetRowLimit(for: self.entry))) { row in UsageBarRow( title: row.title, percentLeft: row.percentLeft, color: WidgetColors.color(for: self.entry.provider)) } if let codeReview = entry.codeReviewRemainingPercent { UsageBarRow( title: "Code review", percentLeft: codeReview, color: WidgetColors.color(for: self.entry.provider)) } if let token = WidgetUsageRow.compactTokenUsage(for: self.entry) { ValueLine( title: WidgetFormat.tokenRowTitle( token.sessionLabel, summary: token, entryUpdatedAt: self.entry.updatedAt), value: WidgetFormat.costAndTokens( cost: token.sessionCostUSD, tokens: token.sessionTokens, currencyCode: token.currencyCode)) } if let balance = extraUsageBalanceLine(for: entry) { balance } } } } private struct SwitcherMediumUsageView: View { let entry: WidgetSnapshot.ProviderEntry var body: some View { VStack(alignment: .leading, spacing: 10) { ForEach(WidgetUsageRow.rows( for: self.entry, limit: WidgetUsageRow.mediumWidgetRowLimit(for: self.entry))) { row in UsageBarRow( title: row.title, percentLeft: row.percentLeft, color: WidgetColors.color(for: self.entry.provider)) } if let credits = entry.creditsRemaining { ValueLine(title: "Credits", value: WidgetFormat.credits(credits)) } if let token = entry.tokenUsage { ValueLine( title: WidgetFormat.tokenRowTitle( token.sessionLabel, summary: token, entryUpdatedAt: self.entry.updatedAt), value: WidgetFormat.costAndTokens( cost: token.sessionCostUSD, tokens: token.sessionTokens, currencyCode: token.currencyCode)) } if let balance = extraUsageBalanceLine(for: entry) { balance } } } } private struct SwitcherLargeUsageView: View { let entry: WidgetSnapshot.ProviderEntry var body: some View { VStack(alignment: .leading, spacing: 12) { ForEach(WidgetUsageRow.rows(for: self.entry)) { row in UsageBarRow( title: row.title, percentLeft: row.percentLeft, color: WidgetColors.color(for: self.entry.provider)) } if let codeReview = entry.codeReviewRemainingPercent { UsageBarRow( title: "Code review", percentLeft: codeReview, color: WidgetColors.color(for: self.entry.provider)) } if let credits = entry.creditsRemaining { ValueLine(title: "Credits", value: WidgetFormat.credits(credits)) } if let token = entry.tokenUsage { VStack(alignment: .leading, spacing: 4) { ValueLine( title: WidgetFormat.tokenRowTitle( token.sessionLabel, summary: token, entryUpdatedAt: self.entry.updatedAt), value: WidgetFormat.costAndTokens( cost: token.sessionCostUSD, tokens: token.sessionTokens, currencyCode: token.currencyCode)) ValueLine( title: WidgetFormat.tokenRowTitle( token.last30DaysLabel, summary: token, entryUpdatedAt: self.entry.updatedAt), value: WidgetFormat.costAndTokens( cost: token.last30DaysCostUSD, tokens: token.last30DaysTokens, currencyCode: token.currencyCode)) } } if let balance = extraUsageBalanceLine(for: entry) { balance } UsageHistoryChart( points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider), currencyCode: self.entry.tokenUsage?.currencyCode) .frame(height: 50) } } } private struct SmallUsageView: View { let entry: WidgetSnapshot.ProviderEntry var body: some View { VStack(alignment: .leading, spacing: 8) { HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) ForEach(WidgetUsageRow.rows( for: self.entry, limit: WidgetUsageRow.smallWidgetRowLimit(for: self.entry))) { row in UsageBarRow( title: row.title, percentLeft: row.percentLeft, color: WidgetColors.color(for: self.entry.provider)) } if let codeReview = entry.codeReviewRemainingPercent { UsageBarRow( title: "Code review", percentLeft: codeReview, color: WidgetColors.color(for: self.entry.provider)) } if let token = WidgetUsageRow.compactTokenUsage(for: self.entry) { ValueLine( title: WidgetFormat.tokenRowTitle( token.sessionLabel, summary: token, entryUpdatedAt: self.entry.updatedAt), value: WidgetFormat.costAndTokens( cost: token.sessionCostUSD, tokens: token.sessionTokens, currencyCode: token.currencyCode)) } if let balance = extraUsageBalanceLine(for: entry) { balance } } .padding(12) } } private struct MediumUsageView: View { let entry: WidgetSnapshot.ProviderEntry var body: some View { VStack(alignment: .leading, spacing: 10) { HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) ForEach(WidgetUsageRow.rows( for: self.entry, limit: WidgetUsageRow.mediumWidgetRowLimit(for: self.entry))) { row in UsageBarRow( title: row.title, percentLeft: row.percentLeft, color: WidgetColors.color(for: self.entry.provider)) } if let credits = entry.creditsRemaining { ValueLine(title: "Credits", value: WidgetFormat.credits(credits)) } if let token = entry.tokenUsage { ValueLine( title: WidgetFormat.tokenRowTitle( token.sessionLabel, summary: token, entryUpdatedAt: self.entry.updatedAt), value: WidgetFormat.costAndTokens( cost: token.sessionCostUSD, tokens: token.sessionTokens, currencyCode: token.currencyCode)) } if let balance = extraUsageBalanceLine(for: entry) { balance } } .padding(12) } } private struct LargeUsageView: View { let entry: WidgetSnapshot.ProviderEntry var body: some View { VStack(alignment: .leading, spacing: 12) { HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) ForEach(WidgetUsageRow.rows(for: self.entry)) { row in UsageBarRow( title: row.title, percentLeft: row.percentLeft, color: WidgetColors.color(for: self.entry.provider)) } if let codeReview = entry.codeReviewRemainingPercent { UsageBarRow( title: "Code review", percentLeft: codeReview, color: WidgetColors.color(for: self.entry.provider)) } if let credits = entry.creditsRemaining { ValueLine(title: "Credits", value: WidgetFormat.credits(credits)) } if let token = entry.tokenUsage { VStack(alignment: .leading, spacing: 4) { ValueLine( title: WidgetFormat.tokenRowTitle( token.sessionLabel, summary: token, entryUpdatedAt: self.entry.updatedAt), value: WidgetFormat.costAndTokens( cost: token.sessionCostUSD, tokens: token.sessionTokens, currencyCode: token.currencyCode)) ValueLine( title: WidgetFormat.tokenRowTitle( token.last30DaysLabel, summary: token, entryUpdatedAt: self.entry.updatedAt), value: WidgetFormat.costAndTokens( cost: token.last30DaysCostUSD, tokens: token.last30DaysTokens, currencyCode: token.currencyCode)) } } if let balance = extraUsageBalanceLine(for: entry) { balance } UsageHistoryChart( points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider), currencyCode: self.entry.tokenUsage?.currencyCode) .frame(height: 50) } .padding(12) } } struct WidgetUsageRow: Identifiable, Equatable { let id: String let title: String let percentLeft: Double? private enum AntigravityQuotaFamily { case gemini case claudeGPT } static func smallWidgetRowLimit(for entry: WidgetSnapshot.ProviderEntry) -> Int? { if entry.provider == .kimi { return 3 } return self.antigravityQuotaSummaryRowLimit(for: entry, limit: 2) } static func mediumWidgetRowLimit(for entry: WidgetSnapshot.ProviderEntry) -> Int? { if entry.provider == .kimi { return 3 } return self.antigravityQuotaSummaryRowLimit(for: entry, limit: 3) } private static func antigravityQuotaSummaryRowLimit( for entry: WidgetSnapshot.ProviderEntry, limit: Int) -> Int? { guard entry.provider == .antigravity, entry.usageRows?.contains(where: { $0.id.hasPrefix("antigravity-quota-summary-") }) == true else { return nil } return limit } static func rows( for entry: WidgetSnapshot.ProviderEntry, limit: Int? = nil, now: Date = Date()) -> [WidgetUsageRow] { let rows: [WidgetUsageRow] if let usageRows = entry.usageRows { let resolvedSnapshots = usageRows.map { row in guard row.window == nil, let window = self.legacyCodexRateWindow(for: row.id, entry: entry) else { return row } return WidgetSnapshot.WidgetUsageRowSnapshot( id: row.id, title: row.title, percentLeft: row.percentLeft, window: window) } let sourceRows = resolvedSnapshots.map { row in WidgetUsageRow( id: row.id, title: row.title, percentLeft: row.window?.remainingPercent ?? row.percentLeft) } rows = self.applyingCodexWeeklyCap( sourceRows, snapshots: resolvedSnapshots, provider: entry.provider, now: now) } else { let metadata = ProviderDefaults.metadata[entry.provider] var defaultRows = [ WidgetUsageRow( id: "primary", title: metadata?.sessionLabel ?? "Session", percentLeft: entry.primary?.remainingPercent), WidgetUsageRow( id: "secondary", title: metadata?.weeklyLabel ?? "Weekly", percentLeft: entry.secondary?.remainingPercent), ] if metadata?.supportsOpus == true { defaultRows.append(WidgetUsageRow( id: "tertiary", title: metadata?.opusLabel ?? "Opus", percentLeft: entry.tertiary?.remainingPercent)) } rows = defaultRows.filter { $0.percentLeft != nil } } guard let limit else { return rows } if entry.provider == .antigravity, limit >= 2, rows.contains(where: { $0.id.hasPrefix("antigravity-quota-summary-") }) { var selected = [AntigravityQuotaFamily.gemini, .claudeGPT].compactMap { family in rows .filter { self.antigravityQuotaFamily(for: $0) == family } .min(by: self.isMoreConstrained) } let selectedIDs = Set(selected.map(\.id)) let fallbackRows = rows.enumerated() .filter { !selectedIDs.contains($0.element.id) } .sorted { lhs, rhs in switch (lhs.element.percentLeft, rhs.element.percentLeft) { case let (.some(left), .some(right)): left == right ? lhs.offset < rhs.offset : left < right case (.some, .none): true case (.none, .some): false case (.none, .none): lhs.offset < rhs.offset } } .map(\.element) selected.append(contentsOf: fallbackRows.prefix(max(0, limit - selected.count))) return selected } return Array(rows.prefix(max(0, limit))) } private static func applyingCodexWeeklyCap( _ rows: [WidgetUsageRow], snapshots: [WidgetSnapshot.WidgetUsageRowSnapshot], provider: UsageProvider, now: Date) -> [WidgetUsageRow] { guard provider == .codex, let weekly = snapshots.first(where: { $0.id == "weekly" })?.window, weekly.remainingPercent <= 0, weekly.resetsAt.map({ $0 > now }) ?? true else { return rows } return rows.map { row in guard row.id == "session" else { return row } return WidgetUsageRow(id: row.id, title: row.title, percentLeft: 0) } } private static func legacyCodexRateWindow( for rowID: String, entry: WidgetSnapshot.ProviderEntry) -> RateWindow? { guard entry.provider == .codex else { return nil } let candidates = [(entry.primary, "session"), (entry.secondary, "weekly")] for (window, fallbackID) in candidates { guard let window else { continue } let classifiedID = switch window.windowMinutes { case 300: "session" case 10080: "weekly" default: fallbackID } if classifiedID == rowID { return window } } return nil } static func compactTokenUsage( for entry: WidgetSnapshot.ProviderEntry) -> WidgetSnapshot.TokenUsageSummary? { guard self.rows(for: entry).isEmpty, entry.codeReviewRemainingPercent == nil else { return nil } return entry.tokenUsage } private static func antigravityQuotaFamily(for row: WidgetUsageRow) -> AntigravityQuotaFamily? { guard row.id.hasPrefix("antigravity-quota-summary-") else { return nil } let id = row.id.lowercased() if id.contains("gemini") { return .gemini } if id.contains("3p") || id.contains("third-party") { return .claudeGPT } let title = row.title.lowercased() if title.contains("gemini") { return .gemini } if title.contains("claude") || title.contains("gpt") { return .claudeGPT } return nil } private static func isMoreConstrained(_ lhs: WidgetUsageRow, than rhs: WidgetUsageRow) -> Bool { switch (lhs.percentLeft, rhs.percentLeft) { case let (.some(left), .some(right)): left < right case (.some, .none): true case (.none, .some): false case (.none, .none): false } } } enum WidgetUsageDisplay { static func percent(fromRemaining remaining: Double?, showUsed: Bool) -> Double? { guard let remaining else { return nil } let clamped = max(0, min(100, remaining)) return showUsed ? 100 - clamped : clamped } } private struct HistoryView: View { let entry: WidgetSnapshot.ProviderEntry let isLarge: Bool var body: some View { VStack(alignment: .leading, spacing: 12) { HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) UsageHistoryChart( points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider), currencyCode: self.entry.tokenUsage?.currencyCode) .frame(height: self.isLarge ? 90 : 60) if let token = entry.tokenUsage { ValueLine( title: WidgetFormat.tokenRowTitle( token.sessionLabel, summary: token, entryUpdatedAt: self.entry.updatedAt), value: WidgetFormat.costAndTokens( cost: token.sessionCostUSD, tokens: token.sessionTokens, currencyCode: token.currencyCode)) ValueLine( title: WidgetFormat.tokenRowTitle( token.last30DaysLabel, summary: token, entryUpdatedAt: self.entry.updatedAt), value: WidgetFormat.costAndTokens( cost: token.last30DaysCostUSD, tokens: token.last30DaysTokens, currencyCode: token.currencyCode)) } } .padding(12) } } private struct HeaderView: View { let provider: UsageProvider let updatedAt: Date var body: some View { HStack(alignment: .firstTextBaseline) { Text(ProviderDefaults.metadata[self.provider]?.displayName ?? self.provider.rawValue.capitalized) .font(.body) .fontWeight(.semibold) Spacer() Text(WidgetFormat.relativeDate(self.updatedAt)) .font(.caption2) .foregroundStyle(.secondary) } } } private struct UsageBarRow: View { @Environment(\.widgetUsageShowsUsed) private var showUsed let title: String let percentLeft: Double? let color: Color var body: some View { let percent = WidgetUsageDisplay.percent(fromRemaining: self.percentLeft, showUsed: self.showUsed) VStack(alignment: .leading, spacing: 4) { HStack { Text(self.title) .font(.caption) Spacer() Text(WidgetFormat.percent(percent)) .font(.caption) .foregroundStyle(.secondary) } GeometryReader { proxy in let width = max(0, min(1, (percent ?? 0) / 100)) * proxy.size.width ZStack(alignment: .leading) { Capsule().fill(Color.primary.opacity(0.08)) Capsule().fill(self.color).frame(width: width) } } .frame(height: 6) } } } private struct ValueLine: View { let title: String let value: String var body: some View { HStack(spacing: 6) { Text(self.title) .font(.caption) .foregroundStyle(.secondary) Text(self.value) .font(.caption) } } } private struct UsageHistoryChart: View { let points: [WidgetSnapshot.DailyUsagePoint] let color: Color let currencyCode: String? var body: some View { let isCostMode = UsageHistoryChartMode.isCostMode(self.points) let values = self.points.map { point -> Double in if isCostMode { return point.costUSD ?? 0 } return Double(point.totalTokens ?? 0) } let scale = UsageChartScale(values: values) VStack(alignment: .trailing, spacing: 2) { if isCostMode, let currencyCode = self.currencyCode, scale.maximum > 0 { Text(UsageFormatter.compactCurrencyString(scale.maximum, currencyCode: currencyCode)) .font(.caption2) .foregroundStyle(.secondary) .monospacedDigit() .lineLimit(1) .allowsTightening(true) } GeometryReader { geometry in HStack(alignment: .bottom, spacing: 2) { ForEach(values.indices, id: \.self) { index in let fraction = scale.fraction(for: values[index]) RoundedRectangle(cornerRadius: 2) .fill(self.color.opacity(0.85)) .frame(maxWidth: .infinity) .frame(height: max(fraction > 0 ? 2 : 0, CGFloat(fraction) * geometry.size.height)) .animation(.easeOut(duration: 0.2), value: fraction) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) } } } } enum UsageHistoryChartMode { static func isCostMode(_ points: [WidgetSnapshot.DailyUsagePoint]) -> Bool { !points.isEmpty && points.allSatisfy { $0.costUSD != nil } } } enum WidgetColors { // swiftlint:disable:next cyclomatic_complexity static func color(for provider: UsageProvider) -> Color { switch provider { case .codex: Color(red: 73 / 255, green: 163 / 255, blue: 176 / 255) case .openai: Color(red: 15 / 255, green: 130 / 255, blue: 110 / 255) case .azureopenai: Color(red: 0, green: 120 / 255, blue: 212 / 255) case .claude: Color(red: 204 / 255, green: 124 / 255, blue: 94 / 255) case .gemini: Color(red: 171 / 255, green: 135 / 255, blue: 234 / 255) case .antigravity: Color(red: 96 / 255, green: 186 / 255, blue: 126 / 255) case .cursor: Color(red: 0 / 255, green: 191 / 255, blue: 165 / 255) // #00BFA5 - Cursor teal case .opencode: Color(red: 59 / 255, green: 130 / 255, blue: 246 / 255) case .opencodego: Color(red: 59 / 255, green: 130 / 255, blue: 246 / 255) case .alibaba, .alibabatokenplan: Color(red: 1.0, green: 106 / 255, blue: 0) case .zai: Color(red: 232 / 255, green: 90 / 255, blue: 106 / 255) case .factory: Color(red: 255 / 255, green: 107 / 255, blue: 53 / 255) // Factory orange case .copilot: Color(red: 168 / 255, green: 85 / 255, blue: 247 / 255) // Purple case .devin: Color(red: 70 / 255, green: 180 / 255, blue: 130 / 255) case .minimax: Color(red: 254 / 255, green: 96 / 255, blue: 60 / 255) case .manus: Color(red: 24 / 255, green: 24 / 255, blue: 24 / 255) case .vertexai: Color(red: 66 / 255, green: 133 / 255, blue: 244 / 255) // Google Blue case .kilo: Color(red: 242 / 255, green: 112 / 255, blue: 39 / 255) // Kilo orange case .kiro: Color(red: 255 / 255, green: 153 / 255, blue: 0 / 255) // AWS orange case .augment: Color(red: 99 / 255, green: 102 / 255, blue: 241 / 255) // Augment purple case .jetbrains: Color(red: 255 / 255, green: 51 / 255, blue: 153 / 255) // JetBrains pink case .kimi: Color(red: 254 / 255, green: 96 / 255, blue: 60 / 255) // Kimi orange case .kimik2: Color(red: 76 / 255, green: 0 / 255, blue: 255 / 255) // Kimi K2 purple case .moonshot: Color(red: 32 / 255, green: 93 / 255, blue: 235 / 255) case .amp: Color(red: 220 / 255, green: 38 / 255, blue: 38 / 255) // Amp red case .t3chat: Color(red: 245 / 255, green: 102 / 255, blue: 71 / 255) case .ollama: Color(red: 32 / 255, green: 32 / 255, blue: 32 / 255) // Ollama charcoal case .synthetic: Color(red: 20 / 255, green: 20 / 255, blue: 20 / 255) // Synthetic charcoal case .openrouter: Color(red: 111 / 255, green: 66 / 255, blue: 193 / 255) // OpenRouter purple case .crossmodel: Color(red: 124 / 255, green: 58 / 255, blue: 237 / 255) // CrossModel purple case .clawrouter: Color(red: 89 / 255, green: 110 / 255, blue: 246 / 255) case .sub2api: Color(red: 45 / 255, green: 198 / 255, blue: 216 / 255) case .wayfinder: Color(red: 16 / 255, green: 163 / 255, blue: 127 / 255) case .elevenlabs: Color(red: 235 / 255, green: 235 / 255, blue: 230 / 255) case .warp: Color(red: 147 / 255, green: 139 / 255, blue: 180 / 255) case .windsurf: Color(red: 52 / 255, green: 232 / 255, blue: 187 / 255) // Windsurf #34e8bb case .perplexity: Color(red: 32 / 255, green: 178 / 255, blue: 170 / 255) // Perplexity teal case .mimo: Color(red: 1.0, green: 105 / 255, blue: 0) case .doubao: Color(red: 45 / 255, green: 136 / 255, blue: 255 / 255) // Doubao blue case .sakana: Color(red: 41 / 255, green: 117 / 255, blue: 219 / 255) case .abacus: Color(red: 56 / 255, green: 189 / 255, blue: 248 / 255) case .mistral: Color(red: 255 / 255, green: 80 / 255, blue: 15 / 255) // Mistral orange case .deepseek: Color(red: 82 / 255, green: 125 / 255, blue: 240 / 255) case .codebuff: Color(red: 68 / 255, green: 255 / 255, blue: 0 / 255) // Codebuff lime case .crof: Color(red: 46 / 255, green: 171 / 255, blue: 148 / 255) case .venice: Color(red: 51 / 255, green: 153 / 255, blue: 1.0) case .commandcode: Color(red: 0, green: 0, blue: 0) case .qoder: Color(red: 16 / 255, green: 185 / 255, blue: 129 / 255) case .stepfun: Color(red: 255 / 255, green: 140 / 255, blue: 0 / 255) // StepFun orange case .bedrock: Color(red: 255 / 255, green: 153 / 255, blue: 0 / 255) // AWS orange case .grok: Color(red: 16 / 255, green: 163 / 255, blue: 127 / 255) // Grok teal case .groq: Color(red: 245 / 255, green: 104 / 255, blue: 68 / 255) case .llmproxy: Color(red: 36 / 255, green: 180 / 255, blue: 126 / 255) case .litellm: Color(red: 76 / 255, green: 137 / 255, blue: 240 / 255) case .deepgram: Color(red: 10 / 255, green: 18 / 255, blue: 27 / 255) case .poe: Color(red: 93 / 255, green: 92 / 255, blue: 222 / 255) // Poe purple case .chutes: Color(red: 24 / 255, green: 160 / 255, blue: 88 / 255) case .zed: Color(red: 64 / 255, green: 156 / 255, blue: 255 / 255) } } } struct WidgetBalanceLine: Equatable { let title: String let value: String } enum WidgetBalanceFormatter { static func extraUsageCost(for entry: WidgetSnapshot.ProviderEntry) -> ProviderCostSnapshot? { guard entry.provider == .devin, let cost = entry.providerCost, cost.period == "Extra usage balance" else { return nil } return cost } static func extraUsageBalance(for entry: WidgetSnapshot.ProviderEntry) -> WidgetBalanceLine? { guard let cost = self.extraUsageCost(for: entry) else { return nil } return WidgetBalanceLine( title: "Extra usage", value: "Balance: \(WidgetFormat.currency(cost.used, code: cost.currencyCode))") } } private func extraUsageBalanceLine(for entry: WidgetSnapshot.ProviderEntry) -> ValueLine? { guard let line = WidgetBalanceFormatter.extraUsageBalance(for: entry) else { return nil } return ValueLine(title: line.title, value: line.value) } enum WidgetFormat { static func percent(_ value: Double?) -> String { guard let value else { return "—" } return String(format: "%.0f%%", value) } static func credits(_ value: Double) -> String { let formatter = NumberFormatter() formatter.numberStyle = .decimal formatter.maximumFractionDigits = 2 formatter.minimumFractionDigits = 0 return formatter.string(from: NSNumber(value: value)) ?? String(format: "%.2f", value) } static func costAndTokens(cost: Double?, tokens: Int?, currencyCode: String = "USD") -> String { let costText = cost.map { self.currency($0, code: currencyCode) } ?? "—" if let tokens { return "\(costText) · \(self.tokenCount(tokens))" } return costText } static func currency(_ value: Double, code: String) -> String { let formatter = NumberFormatter() formatter.numberStyle = .currency formatter.currencyCode = code formatter.maximumFractionDigits = 2 formatter.minimumFractionDigits = 2 return formatter.string(from: NSNumber(value: value)) ?? "\(code) \(String(format: "%.2f", value))" } static func tokenCount(_ value: Int) -> String { let formatter = NumberFormatter() formatter.numberStyle = .decimal formatter.maximumFractionDigits = 0 let raw = formatter.string(from: NSNumber(value: value)) ?? "\(value)" return "\(raw) tokens" } static func relativeDate(_ date: Date) -> String { let formatter = RelativeDateTimeFormatter() formatter.locale = Locale(identifier: "en_US") formatter.unitsStyle = .short return formatter.localizedString(for: date, relativeTo: Date()) } /// Suffixes the title with the token snapshot's own age once it lags the entry's /// freshness signal past `TokenUsageSummary.staleLagThreshold`. static func tokenRowTitle( _ base: String, summary: WidgetSnapshot.TokenUsageSummary, entryUpdatedAt: Date) -> String { guard summary.isStale(comparedTo: entryUpdatedAt), let updatedAt = summary.updatedAt else { return base } return "\(base) · \(self.relativeDate(updatedAt))" } }