Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion mac/Sources/CodeBurnMenubar/AppStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ final class AppStore {
var selectedProvider: ProviderFilter = .all
var selectedPeriod: Period = .today
var selectedInsight: InsightMode = .trend
var accentPreset: AccentPreset = ThemeState.shared.preset {
didSet { ThemeState.shared.preset = accentPreset }
}
var showingAccentPicker: Bool = false
var currency: String = "USD"
var isLoading: Bool = false
var lastError: String?
Expand All @@ -44,6 +48,12 @@ final class AppStore {
cache[PayloadCacheKey(period: .today, provider: .all)]?.payload
}

/// All-provider payload for the selected period. Used by the tab strip to show
/// per-provider costs that match the active period, not just today.
var periodAllPayload: MenubarPayload? {
cache[PayloadCacheKey(period: selectedPeriod, provider: .all)]?.payload
}

var hasCachedData: Bool {
cache[currentKey] != nil
}
Expand Down Expand Up @@ -86,6 +96,11 @@ final class AppStore {
lastError = String(describing: error)
NSLog("CodeBurn: fetch failed for \(key.period.rawValue)/\(key.provider.rawValue): \(error)")
}

let allKey = PayloadCacheKey(period: selectedPeriod, provider: .all)
if key != allKey, cache[allKey]?.isFresh != true {
await refreshQuietly(period: selectedPeriod)
}
}

/// Background refresh for a period other than the visible one (e.g. keeping today fresh for the menubar badge).
Expand Down Expand Up @@ -211,21 +226,31 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
case codex = "Codex"
case cursor = "Cursor"
case copilot = "Copilot"
case kiro = "Kiro"
case opencode = "OpenCode"
case pi = "Pi"
case omp = "OMP"

var id: String { rawValue }

/// Maps to the CLI's `--provider` argument values.
var providerKeys: [String] {
switch self {
case .cursor: ["cursor", "cursor agent"]
default: [rawValue.lowercased()]
}
}

var cliArg: String {
switch self {
case .all: "all"
case .claude: "claude"
case .codex: "codex"
case .cursor: "cursor"
case .copilot: "copilot"
case .kiro: "kiro"
case .opencode: "opencode"
case .pi: "pi"
case .omp: "omp"
}
}
}
Expand Down
13 changes: 8 additions & 5 deletions mac/Sources/CodeBurnMenubar/Theme/Theme.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import SwiftUI

/// Design tokens. Warm terracotta-ember palette, not generic orange.
/// Design tokens. Accent colors are driven by ThemeState so the user can switch palettes.
@MainActor
enum Theme {
static let brandAccent = Color(red: 0xC9/255.0, green: 0x52/255.0, blue: 0x1D/255.0)
static let brandAccentDark = Color(red: 0xE8/255.0, green: 0x77/255.0, blue: 0x4A/255.0)
static let brandEmberDeep = Color(red: 0x8B/255.0, green: 0x3E/255.0, blue: 0x13/255.0)
static let brandEmberGlow = Color(red: 0xF0/255.0, green: 0xA0/255.0, blue: 0x70/255.0)
static let brandEmber = Color(red: 0xC9/255.0, green: 0x52/255.0, blue: 0x1D/255.0)

static var brandAccent: Color { ThemeState.shared.preset.base }
static var brandAccentLight: Color { ThemeState.shared.preset.light }
static var brandAccentDeep: Color { ThemeState.shared.preset.deep }
static var brandAccentGlow: Color { ThemeState.shared.preset.glow }

static let warmSurface = Color(red: 0xFA/255.0, green: 0xF7/255.0, blue: 0xF3/255.0)
static let warmSurfaceDark = Color(red: 0x1C/255.0, green: 0x18/255.0, blue: 0x16/255.0)
Expand Down
86 changes: 86 additions & 0 deletions mac/Sources/CodeBurnMenubar/Theme/ThemeState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import SwiftUI

enum AccentPreset: String, CaseIterable, Identifiable {
case ember = "Ember"
case blue = "Blue"
case purple = "Purple"
case pink = "Pink"
case red = "Red"
case orange = "Orange"
case yellow = "Yellow"
case green = "Green"
case graphite = "Graphite"

var id: String { rawValue }

/// Apple macOS dark-mode system accent colors (NSColor.system*).
var base: Color {
switch self {
case .ember: Color(red: 0xC9/255, green: 0x52/255, blue: 0x1D/255)
case .blue: Color(red: 0x0A/255, green: 0x84/255, blue: 0xFF/255)
case .purple: Color(red: 0xBF/255, green: 0x5A/255, blue: 0xF2/255)
case .pink: Color(red: 0xFF/255, green: 0x37/255, blue: 0x5F/255)
case .red: Color(red: 0xFF/255, green: 0x45/255, blue: 0x3A/255)
case .orange: Color(red: 0xFF/255, green: 0x9F/255, blue: 0x0A/255)
case .yellow: Color(red: 0xFF/255, green: 0xD6/255, blue: 0x0A/255)
case .green: Color(red: 0x30/255, green: 0xD1/255, blue: 0x58/255)
case .graphite: Color(red: 0x98/255, green: 0x98/255, blue: 0x9D/255)
}
}

var light: Color {
switch self {
case .ember: Color(red: 0xE8/255, green: 0x77/255, blue: 0x4A/255)
case .blue: Color(red: 0x40/255, green: 0x9C/255, blue: 0xFF/255)
case .purple: Color(red: 0xDA/255, green: 0x8F/255, blue: 0xF7/255)
case .pink: Color(red: 0xFF/255, green: 0x6E/255, blue: 0x8C/255)
case .red: Color(red: 0xFF/255, green: 0x6E/255, blue: 0x63/255)
case .orange: Color(red: 0xFF/255, green: 0xBD/255, blue: 0x4A/255)
case .yellow: Color(red: 0xFF/255, green: 0xE0/255, blue: 0x4A/255)
case .green: Color(red: 0x5A/255, green: 0xE0/255, blue: 0x78/255)
case .graphite: Color(red: 0xAE/255, green: 0xAE/255, blue: 0xB2/255)
}
}

var deep: Color {
switch self {
case .ember: Color(red: 0x8B/255, green: 0x3E/255, blue: 0x13/255)
case .blue: Color(red: 0x06/255, green: 0x52/255, blue: 0xB3/255)
case .purple: Color(red: 0x7C/255, green: 0x38/255, blue: 0xA8/255)
case .pink: Color(red: 0xB3/255, green: 0x26/255, blue: 0x42/255)
case .red: Color(red: 0xB3/255, green: 0x30/255, blue: 0x28/255)
case .orange: Color(red: 0xB3/255, green: 0x6F/255, blue: 0x06/255)
case .yellow: Color(red: 0xB3/255, green: 0x96/255, blue: 0x06/255)
case .green: Color(red: 0x20/255, green: 0x92/255, blue: 0x3D/255)
case .graphite: Color(red: 0x5E/255, green: 0x5E/255, blue: 0x62/255)
}
}

var glow: Color {
switch self {
case .ember: Color(red: 0xF0/255, green: 0xA0/255, blue: 0x70/255)
case .blue: Color(red: 0x80/255, green: 0xC0/255, blue: 0xFF/255)
case .purple: Color(red: 0xE0/255, green: 0xB8/255, blue: 0xFA/255)
case .pink: Color(red: 0xFF/255, green: 0x99/255, blue: 0xB0/255)
case .red: Color(red: 0xFF/255, green: 0x99/255, blue: 0x90/255)
case .orange: Color(red: 0xFF/255, green: 0xD0/255, blue: 0x80/255)
case .yellow: Color(red: 0xFF/255, green: 0xEA/255, blue: 0x80/255)
case .green: Color(red: 0x80/255, green: 0xF0/255, blue: 0x98/255)
case .graphite: Color(red: 0xC8/255, green: 0xC8/255, blue: 0xCC/255)
}
}
}

@MainActor
final class ThemeState {
static let shared = ThemeState()

var preset: AccentPreset {
didSet { UserDefaults.standard.set(preset.rawValue, forKey: "CodeBurnAccentPreset") }
}

private init() {
let saved = UserDefaults.standard.string(forKey: "CodeBurnAccentPreset") ?? ""
self.preset = AccentPreset(rawValue: saved) ?? .ember
}
}
35 changes: 18 additions & 17 deletions mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,34 +25,33 @@ struct AgentTabStrip: View {
}
}

/// Drive tab visibility and per-tab cost labels from the *all-provider* payload (today),
/// not the currently selected provider's payload. Without this, switching to Codex (which
/// has no data) would hide every other tab including Claude.
private var allProvidersToday: MenubarPayload {
private var todayAll: MenubarPayload {
store.todayPayload ?? store.payload
}

private var periodAll: MenubarPayload {
store.periodAllPayload ?? store.payload
}

private var visibleFilters: [ProviderFilter] {
// Show a tab for every provider detected on this machine. The CLI decides what
// to include in the providers map based on session dirs / credential files it
// finds, so zero-cost-today is still "installed" and the user expects to see
// it. Only providers that aren't installed at all are absent from the map.
let detectedKeys = Set(
allProvidersToday.current.providers.keys.map { $0.lowercased() }
todayAll.current.providers.keys.map { $0.lowercased() }
)
return ProviderFilter.allCases.filter { filter in
if filter == .all { return true }
return detectedKeys.contains(filter.rawValue.lowercased())
return filter.providerKeys.contains(where: detectedKeys.contains)
}
}

private func cost(for filter: ProviderFilter) -> Double? {
switch filter {
case .all:
return allProvidersToday.current.cost
default:
let key = filter.rawValue.lowercased()
return allProvidersToday.current.providers[key]
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)
}
}
}
Expand Down Expand Up @@ -86,15 +85,17 @@ private struct AgentTab: View {
}

extension ProviderFilter {
var color: Color {
@MainActor var color: Color {
switch self {
case .all: return Theme.brandAccent
case .claude: return Theme.categoricalClaude
case .codex: return Theme.categoricalCodex
case .cursor: return Theme.categoricalCursor
case .copilot: return Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/255.0)
case .kiro: return Color(red: 0x4A/255.0, green: 0x9E/255.0, blue: 0xC4/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 .omp: return Color(red: 0x8B/255.0, green: 0x5C/255.0, blue: 0xB0/255.0)
}
}
}
11 changes: 4 additions & 7 deletions mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import SwiftUI

private let winColor = Theme.brandAccent
private let riskColor = Theme.brandAccent
private let improveColor = Theme.brandAccent

/// Three-category insights panel: wins, improvements, risks.
/// Wins/risks are derived from current + history; improvements come from the optimize findings.
Expand Down Expand Up @@ -133,7 +130,7 @@ private struct TipItem: Identifiable {
let trailing: String?
}

private func computeTipGroups(payload: MenubarPayload) -> [TipGroup] {
@MainActor private func computeTipGroups(payload: MenubarPayload) -> [TipGroup] {
let stats = computeHistoryStats(history: payload.history.daily)

// What's working
Expand Down Expand Up @@ -201,9 +198,9 @@ private func computeTipGroups(payload: MenubarPayload) -> [TipGroup] {
}

return [
TipGroup(label: "What's working", icon: "checkmark.circle.fill", color: winColor, items: wins),
TipGroup(label: "What to improve", icon: "arrow.up.right.circle.fill", color: improveColor, items: improvements),
TipGroup(label: "Risks", icon: "exclamationmark.triangle.fill", color: riskColor, items: risks),
TipGroup(label: "What's working", icon: "checkmark.circle.fill", color: Theme.brandAccent, items: wins),
TipGroup(label: "What to improve", icon: "arrow.up.right.circle.fill", color: Theme.brandAccent, items: improvements),
TipGroup(label: "Risks", icon: "exclamationmark.triangle.fill", color: Theme.brandAccent, items: risks),
]
}

Expand Down
2 changes: 1 addition & 1 deletion mac/Sources/CodeBurnMenubar/Views/HeroSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ struct HeroSection: View {
.tracking(-1)
.foregroundStyle(
LinearGradient(
colors: [Theme.brandAccent, Theme.brandEmberDeep],
colors: [Theme.brandAccent, Theme.brandAccentDeep],
startPoint: .top,
endPoint: .bottom
)
Expand Down
Loading
Loading