codeburn/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift
ozymandiashh 805d2ffa49
Some checks are pending
CI / semgrep (push) Waiting to run
feat(menubar): add Local/Combined usage toggle (#566) (#568)
Mirror the combined multi-device dashboard in the macOS menubar via a
Local/Combined scope toggle (Settings -> Display). Builds on the menubar-json
`--scope combined` contract.

- MenubarScope enum (.local default) persisted in UserDefaults; PayloadCacheKey
  gains a scope dimension so local and combined payloads cache separately.
- DataClient.fetch(scope:): a pure statusSubcommand builder appends
  `--scope combined` only for combined, forces `--provider all` (the CLI rejects
  combined with a provider/project/exclude filter), and coerces multi-day
  selections to local (the CLI rejects combined + --days).
- MenubarPayload decodes the optional `combined` block (per-device + totals);
  nil when absent (back-compat).
- The badge always stays on the local payload; the combined network pull lives
  on a separate cache key and falls back to local with a "Combined unavailable"
  indicator, so a slow/offline peer never blocks the badge.
- Hero shows combined totals + a per-device breakdown in Combined scope; the
  token metric stays input+output (cache excluded) to match local; the
  per-device daily-budget warning is suppressed for a multi-device total;
  selecting Combined resets the provider tab to All.

Tests run via `swift test` (CommandLineTools Testing.framework on the rpath, no
Xcode): scope persistence, argv (local/combined/provider-forcing/multi-day),
cache-key partition, badge-survives-combined-failure, combined decode, token
consistency, provider reset, budget suppression.
2026-06-28 22:01:01 +02:00

694 lines
28 KiB
Swift

import AppKit
import SwiftUI
/// macOS-standard tabbed Settings window. New per-provider sections (Codex,
/// Cursor, Copilot, etc.) plug in as additional tabs. Each tab owns its own
/// concerns; this top-level view only hosts the TabView shell.
struct SettingsView: View {
@Environment(AppStore.self) private var store
var body: some View {
TabView(selection: Binding(get: { store.settingsTab }, set: { store.settingsTab = $0 })) {
GeneralSettingsTab()
.tabItem { Label("General", systemImage: "gearshape") }
.tag("general")
ClaudeSettingsTab()
.tabItem { Label("Claude", systemImage: "brain") }
.tag("claude")
CodexSettingsTab()
.tabItem { Label("Codex", systemImage: "chevron.left.forwardslash.chevron.right") }
.tag("codex")
DevinSettingsTab()
.tabItem { Label("Devin", systemImage: "flame.fill") }
.tag("devin")
AboutSettingsTab()
.tabItem { Label("About", systemImage: "info.circle") }
.tag("about")
}
.frame(width: 520, height: 430)
}
}
// MARK: - General
private struct GeneralSettingsTab: View {
@Environment(AppStore.self) private var store
// "Custom" budget entry state, one per metric (cost in dollars, tokens in
// millions). When custom is active the picker shows "Custom" and a field
// appears for an exact amount.
@State private var costCustom = false
@State private var tokenCustom = false
@State private var costText = ""
@State private var tokenText = ""
private let costPresets: Set<Double> = [25, 50, 100, 200, 500]
private let tokenPresets: Set<Double> = [1_000_000, 5_000_000, 10_000_000, 25_000_000, 50_000_000, 100_000_000]
private func applyCostBudget() {
store.dailyBudget = max(0, Double(costText.trimmingCharacters(in: .whitespaces)) ?? 0)
}
private func applyTokenBudget() {
let millions = Double(tokenText.trimmingCharacters(in: .whitespaces)) ?? 0
store.dailyTokenBudget = max(0, millions * 1_000_000)
}
private func trimNumber(_ v: Double) -> String {
v == v.rounded() ? String(Int(v)) : String(v)
}
// Help text under the budget picker. When "Custom" is selected but no amount
// has been entered, the budget is effectively 0 (off); call that out so the
// alert does not look armed when it isn't.
private var alertHelpText: String {
let customEmpty = store.isTokenMetric
? (tokenCustom && store.dailyTokenBudget == 0)
: (costCustom && store.dailyBudget == 0)
if customEmpty { return "Enter an amount above, or the alert stays off." }
return "Flame icon turns yellow when today's \(store.isTokenMetric ? "tokens" : "cost") pass the daily budget."
}
var body: some View {
Form {
Section("Display") {
Picker("Currency", selection: Binding(
get: { store.currency },
set: { applyCurrency(code: $0) }
)) {
ForEach(SupportedCurrency.allCases) { currency in
Text("\(currency.rawValue)\(currency.displayName)").tag(currency.rawValue)
}
}
Picker("Metric", selection: Binding(
get: { store.displayMetric },
set: { store.displayMetric = $0 }
)) {
Text("Cost ($)").tag(DisplayMetric.cost)
Text("Tokens (↑↓)").tag(DisplayMetric.tokens)
Text("Total Tokens").tag(DisplayMetric.totalTokens)
Text("Credits (Codex)").tag(DisplayMetric.credits)
Text("Icon Only").tag(DisplayMetric.iconOnly)
}
Picker("Period", selection: Binding(
get: { store.menubarPeriod },
set: { store.setMenubarPeriod($0) }
)) {
ForEach(Period.menubarMetricCases) { period in
Text(period.menubarMetricLabel).tag(period)
}
}
.pickerStyle(.menu)
Picker("Scope", selection: Binding(
get: { store.menubarScope },
set: { store.setMenubarScope($0) }
)) {
ForEach(MenubarScope.allCases) { scope in
Text(scope.rawValue).tag(scope)
}
}
.pickerStyle(.menu)
Picker("Accent", selection: Binding(
get: { store.accentPreset },
set: { store.accentPreset = $0 }
)) {
ForEach(AccentPreset.allCases) { preset in
Text(preset.rawValue).tag(preset)
}
}
}
Section("Alerts") {
// The budget tracks whatever the menubar metric shows: dollars for
// the Cost metric, tokens for the Tokens / Total Tokens metrics.
// "Custom" reveals a field for an exact amount.
if store.isTokenMetric {
Picker("Daily budget", selection: Binding(
get: { tokenCustom ? -1.0 : store.dailyTokenBudget },
set: { sel in
if sel < 0 {
tokenCustom = true
tokenText = store.dailyTokenBudget > 0 ? trimNumber(store.dailyTokenBudget / 1_000_000) : ""
} else {
tokenCustom = false
store.dailyTokenBudget = sel
}
}
)) {
Text("Off").tag(0.0)
Text("1M").tag(1_000_000.0)
Text("5M").tag(5_000_000.0)
Text("10M").tag(10_000_000.0)
Text("25M").tag(25_000_000.0)
Text("50M").tag(50_000_000.0)
Text("100M").tag(100_000_000.0)
Text("Custom…").tag(-1.0)
}
if tokenCustom {
HStack {
TextField("Amount", text: $tokenText)
.multilineTextAlignment(.trailing)
.onSubmit { applyTokenBudget() }
.onChange(of: tokenText) { _, _ in applyTokenBudget() }
Text("M tokens").foregroundStyle(.secondary)
}
}
} else {
Picker("Daily budget", selection: Binding(
get: { costCustom ? -1.0 : store.dailyBudget },
set: { sel in
if sel < 0 {
costCustom = true
costText = store.dailyBudget > 0 ? trimNumber(store.dailyBudget) : ""
} else {
costCustom = false
store.dailyBudget = sel
}
}
)) {
Text("Off").tag(0.0)
Text("$25").tag(25.0)
Text("$50").tag(50.0)
Text("$100").tag(100.0)
Text("$200").tag(200.0)
Text("$500").tag(500.0)
Text("Custom…").tag(-1.0)
}
if costCustom {
HStack {
Text("$").foregroundStyle(.secondary)
TextField("Amount", text: $costText)
.multilineTextAlignment(.trailing)
.onSubmit { applyCostBudget() }
.onChange(of: costText) { _, _ in applyCostBudget() }
}
}
}
Text(alertHelpText)
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
.onAppear {
costCustom = store.dailyBudget > 0 && !costPresets.contains(store.dailyBudget)
if costCustom { costText = trimNumber(store.dailyBudget) }
tokenCustom = store.dailyTokenBudget > 0 && !tokenPresets.contains(store.dailyTokenBudget)
if tokenCustom { tokenText = trimNumber(store.dailyTokenBudget / 1_000_000) }
}
}
.formStyle(.grouped)
.padding()
}
private func applyCurrency(code: String) {
let symbol = CurrencyState.symbolForCode(code)
Task {
let cached = await FXRateCache.shared.cachedRate(for: code)
if let cached {
store.currency = code
CurrencyState.shared.apply(code: code, rate: cached, symbol: symbol)
}
let fresh = await FXRateCache.shared.rate(for: code)
store.currency = code
CurrencyState.shared.apply(code: code, rate: fresh ?? cached, symbol: symbol)
}
CLICurrencyConfig.persist(code: code)
}
}
// MARK: - Claude
private struct ClaudeSettingsTab: View {
@Environment(AppStore.self) private var store
var body: some View {
Form {
Section("Connection") {
ClaudeConnectionRow()
}
Section {
ClaudeConfigDirsSection()
} header: {
Text("Config Directories")
} footer: {
Text("Aggregate usage across multiple Claude config directories (e.g. work and personal accounts). Leave empty to track just the default `~/.claude`. The `CLAUDE_CONFIG_DIRS` environment variable, if set, overrides this list.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
Section("Quota Refresh") {
Picker("Update every", selection: Binding(
get: { SubscriptionRefreshCadence.current },
set: { SubscriptionRefreshCadence.current = $0 }
)) {
ForEach(SubscriptionRefreshCadence.allCases) { cadence in
Text(cadence.label).tag(cadence)
}
}
.pickerStyle(.menu)
Text("Anthropic rate-limits this endpoint per account. 2 minutes is plenty for the 5-hour and weekly windows; pick Manual if you only want updates on demand.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
Button("Refresh Now") {
Task { await store.refreshSubscription() }
}
}
}
.formStyle(.grouped)
.padding()
}
}
private struct ClaudeConnectionRow: View {
@Environment(AppStore.self) private var store
@State private var showDisconnectConfirm = false
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: stateIcon)
.font(.system(size: 18))
.foregroundStyle(stateTint)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text(stateTitle)
.font(.system(size: 12, weight: .semibold))
Text(stateDetail)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer()
actionButton
}
.padding(.vertical, 4)
}
private var stateIcon: String {
switch store.subscriptionLoadState {
case .loaded: return "checkmark.circle.fill"
case .terminalFailure: return "exclamationmark.triangle.fill"
case .transientFailure: return "clock.arrow.circlepath"
case .bootstrapping, .loading: return "ellipsis.circle"
case .notBootstrapped, .dormant, .noCredentials: return "link.circle"
case .failed: return "xmark.circle"
}
}
private var stateTint: Color {
switch store.subscriptionLoadState {
case .loaded: return .green
case .terminalFailure, .failed: return .red
case .transientFailure: return .orange
default: return .secondary
}
}
private var stateTitle: String {
switch store.subscriptionLoadState {
case .loaded: return "Connected"
case let .terminalFailure(reason): return reason ?? "Reconnect required"
case .transientFailure: return "Backing off"
case .bootstrapping: return "Connecting…"
case .loading: return "Refreshing…"
case .dormant: return "Ready"
case .notBootstrapped, .noCredentials: return "Not connected"
case .failed: return "Couldn't load plan data"
}
}
private var stateDetail: String {
switch store.subscriptionLoadState {
case .loaded:
if let tier = store.subscription?.tier.displayName {
return "Plan: \(tier)"
}
return "Live quota tracked from Anthropic."
case .terminalFailure: return "Open Claude Code in your terminal and type `/login`, then click Reconnect."
case .transientFailure: return store.subscriptionError ?? "Anthropic rate-limited; auto-retrying."
case .bootstrapping: return "macOS may ask permission to read your credentials."
case .loading: return "Background refresh in progress."
case .dormant: return "Tap Load Quota to fetch live usage from Anthropic."
case .notBootstrapped, .noCredentials: return "Click Connect to read your Claude Code credentials and start tracking quota."
case .failed: return store.subscriptionError ?? ""
}
}
@ViewBuilder
private var actionButton: some View {
switch store.subscriptionLoadState {
case .loaded, .transientFailure, .loading:
Button("Disconnect") { showDisconnectConfirm = true }
.confirmationDialog(
"Disconnect Claude?",
isPresented: $showDisconnectConfirm
) {
Button("Disconnect", role: .destructive) {
store.disconnectSubscription()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("CodeBurn will stop tracking quota and delete its local copy of your Claude credentials. Your Claude Code keychain entry is untouched — Claude Code keeps working.")
}
case .terminalFailure, .noCredentials, .failed:
Button("Reconnect") { Task { await store.bootstrapSubscription() } }
.buttonStyle(.borderedProminent)
case .dormant:
Button("Load Quota") { Task { await store.activateClaudeFromDormant() } }
.buttonStyle(.borderedProminent)
case .notBootstrapped:
Button("Connect") { Task { await store.bootstrapSubscription() } }
.buttonStyle(.borderedProminent)
case .bootstrapping:
ProgressView().controlSize(.small)
}
}
}
// MARK: - Claude config directories
private struct ClaudeConfigDirsSection: View {
@Environment(AppStore.self) private var store
@State private var dirs: [String] = CLIClaudeConfig.load()
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if dirs.isEmpty {
Text("No extra directories — tracking the default `~/.claude`.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
} else {
ForEach(Array(dirs.enumerated()), id: \.offset) { index, dir in
HStack(spacing: 8) {
Image(systemName: "folder")
.font(.system(size: 12))
.foregroundStyle(.secondary)
Text(dir)
.font(.system(size: 12))
.truncationMode(.middle)
.lineLimit(1)
.help(dir)
Spacer()
Button {
remove(at: index)
} label: {
Image(systemName: "minus.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help("Remove")
}
}
}
Button {
addDirectory()
} label: {
Label("Add Directory…", systemImage: "plus")
}
.controlSize(.small)
}
.padding(.vertical, 2)
}
private func addDirectory() {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = true
panel.prompt = "Add"
panel.message = "Choose one or more Claude config directories (each containing a `projects` folder)."
guard panel.runModal() == .OK else { return }
let added = panel.urls.map { $0.path }
var next = dirs
for path in added where !next.contains(path) {
next.append(path)
}
apply(next)
}
private func remove(at index: Int) {
guard dirs.indices.contains(index) else { return }
var next = dirs
next.remove(at: index)
apply(next)
}
/// Persists the new list and kicks a forced refresh so the dashboard
/// reflects the changed aggregation immediately.
private func apply(_ next: [String]) {
dirs = next
CLIClaudeConfig.persist(dirs: next)
Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) }
}
}
// MARK: - Codex
private struct CodexSettingsTab: View {
@Environment(AppStore.self) private var store
var body: some View {
Form {
Section("Connection") {
CodexConnectionRow()
}
Section {
Text("Codex live-quota tracking reads `~/.codex/auth.json` once on Connect, then keeps a local copy under Application Support so subsequent quota fetches don't re-read the original. Only ChatGPT-mode auth (Plus / Pro / Team / Business) is supported — API-key users are billed per request and have a different reporting surface.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
} header: {
Text("How it works")
}
}
.formStyle(.grouped)
.padding()
}
}
private struct CodexConnectionRow: View {
@Environment(AppStore.self) private var store
@State private var showDisconnectConfirm = false
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: stateIcon)
.font(.system(size: 18))
.foregroundStyle(stateTint)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text(stateTitle)
.font(.system(size: 12, weight: .semibold))
Text(stateDetail)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer()
actionButton
}
.padding(.vertical, 4)
}
private var stateIcon: String {
switch store.codexLoadState {
case .loaded: return "checkmark.circle.fill"
case .terminalFailure: return "exclamationmark.triangle.fill"
case .transientFailure: return "clock.arrow.circlepath"
case .bootstrapping, .loading: return "ellipsis.circle"
case .notBootstrapped, .dormant, .noCredentials: return "link.circle"
case .failed: return "xmark.circle"
}
}
private var stateTint: Color {
switch store.codexLoadState {
case .loaded: return .green
case .terminalFailure, .failed: return .red
case .transientFailure: return .orange
default: return .secondary
}
}
private var stateTitle: String {
switch store.codexLoadState {
case .loaded: return "Connected"
case let .terminalFailure(reason): return reason ?? "Reconnect required"
case .transientFailure: return "Backing off"
case .bootstrapping: return "Connecting…"
case .loading: return "Refreshing…"
case .dormant: return "Ready"
case .notBootstrapped, .noCredentials: return "Not connected"
case .failed: return "Couldn't load Codex quota"
}
}
private var stateDetail: String {
switch store.codexLoadState {
case .loaded:
if let plan = store.codexUsage?.plan.displayName {
return "Plan: \(plan)"
}
return "Live quota tracked from chatgpt.com."
case .terminalFailure:
// Be specific about the cause: the message we already surface in
// codexError will say "API-key mode" if that's the situation, so
// the generic "run codex login" hint covers both cases.
if let err = store.codexError, err.lowercased().contains("api-key") {
return "Codex is in API-key mode. Run `codex login` and choose a ChatGPT plan to enable quota tracking."
}
return "Run `codex login` in your terminal to sign in again, then click Reconnect."
case .transientFailure: return store.codexError ?? "ChatGPT rate-limited; auto-retrying."
case .bootstrapping: return "Reading ~/.codex/auth.json."
case .loading: return "Background refresh in progress."
case .dormant: return "Tap Load Quota to fetch live usage from chatgpt.com."
case .notBootstrapped, .noCredentials:
return "Click Connect to read your Codex CLI credentials. If Connect fails, run `codex login` in your terminal first to create ~/.codex/auth.json."
case .failed: return store.codexError ?? ""
}
}
@ViewBuilder
private var actionButton: some View {
switch store.codexLoadState {
case .loaded, .transientFailure, .loading:
Button("Disconnect") { showDisconnectConfirm = true }
.confirmationDialog(
"Disconnect Codex?",
isPresented: $showDisconnectConfirm
) {
Button("Disconnect", role: .destructive) {
store.disconnectCodex()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("CodeBurn will stop tracking quota and delete its local copy of your Codex credentials. Your ~/.codex/auth.json is untouched — Codex CLI keeps working.")
}
case .terminalFailure, .noCredentials, .failed:
Button("Reconnect") { Task { await store.bootstrapCodex() } }
.buttonStyle(.borderedProminent)
case .dormant:
Button("Load Quota") { Task { await store.activateCodexFromDormant() } }
.buttonStyle(.borderedProminent)
case .notBootstrapped:
Button("Connect") { Task { await store.bootstrapCodex() } }
.buttonStyle(.borderedProminent)
case .bootstrapping:
ProgressView().controlSize(.small)
}
}
}
// MARK: - Devin
private struct DevinSettingsTab: View {
@State private var rateText: String = ""
@State private var statusText: String = ""
private var parsedRate: Double? {
let trimmed = rateText.trimmingCharacters(in: .whitespacesAndNewlines)
guard let value = Double(trimmed), value.isFinite, value > 0 else { return nil }
return value
}
var body: some View {
Form {
Section("ACU Conversion") {
HStack(alignment: .center, spacing: 10) {
Text("USD per ACU")
Spacer()
TextField("", text: $rateText)
.textFieldStyle(.roundedBorder)
.multilineTextAlignment(.trailing)
.frame(width: 96)
.accessibilityLabel("USD per ACU")
Text("USD")
.foregroundStyle(.secondary)
.frame(width: 36, alignment: .leading)
}
Button("Save") {
saveRate()
}
.buttonStyle(.borderedProminent)
.disabled(parsedRate == nil)
if !statusText.isEmpty {
Text(statusText)
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
}
Section {
Text("CodeBurn reads Devin ACU usage from local transcripts only after this rate is configured, then multiplies each step by the rate before reporting cost.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
} header: {
Text("How it works")
}
}
.formStyle(.grouped)
.padding()
.onAppear {
if let rate = CLIDevinConfig.loadAcuUsdRate() {
rateText = Self.format(rate)
}
}
}
private func saveRate() {
guard let rate = parsedRate else { return }
CLIDevinConfig.persistAcuUsdRate(rate)
rateText = Self.format(rate)
statusText = "Saved. Refresh CodeBurn to recalculate Devin cost."
}
private static func format(_ value: Double) -> String {
let formatter = NumberFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 6
return formatter.string(from: NSNumber(value: value)) ?? String(value)
}
}
// MARK: - About
private struct AboutSettingsTab: View {
private let appVersion: String = AppVersion.normalizedBundleShortVersion
private let buildVersion: String = AppVersion.normalizedBundleBuildVersion
var body: some View {
VStack(spacing: 14) {
Image(systemName: "flame.fill")
.font(.system(size: 40))
.foregroundStyle(Theme.brandAccent)
Text("CodeBurn")
.font(.system(size: 18, weight: .semibold))
Text("AI Coding Cost Tracker")
.font(.system(size: 12))
.foregroundStyle(.secondary)
Text("Version \(appVersion) (\(buildVersion))")
.font(.codeMono(size: 11))
.foregroundStyle(.secondary)
Link(destination: URL(string: "https://github.com/getagentseal/codeburn")!) {
Label("Star on GitHub", systemImage: "star.fill")
.font(.system(size: 12, weight: .medium))
}
.buttonStyle(.borderedProminent)
.tint(Theme.brandAccent)
HStack(spacing: 10) {
Link("GitHub", destination: URL(string: "https://github.com/getagentseal/codeburn")!)
Link("Issues", destination: URL(string: "https://github.com/getagentseal/codeburn/issues")!)
Link("Sponsor", destination: URL(string: "https://github.com/sponsors/iamtoruk")!)
}
.font(.system(size: 12))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
}