Files
steipete--codexbar/Sources/CodexBarWidget/CodexBarWidgetViews.swift
T
2026-07-13 12:22:33 +08:00

1174 lines
44 KiB
Swift

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))"
}
}