codeburn/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift
2026-05-11 11:21:39 -07:00

365 lines
14 KiB
Swift

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 {
GeneralSettingsTab()
.tabItem { Label("General", systemImage: "gearshape") }
ClaudeSettingsTab()
.tabItem { Label("Claude", systemImage: "brain") }
CodexSettingsTab()
.tabItem { Label("Codex", systemImage: "chevron.left.forwardslash.chevron.right") }
AboutSettingsTab()
.tabItem { Label("About", systemImage: "info.circle") }
}
.frame(width: 520, height: 400)
}
}
// MARK: - General
private struct GeneralSettingsTab: View {
@Environment(AppStore.self) private var store
var body: some View {
Form {
Section("Display") {
Picker("Currency", selection: Binding(
get: { store.currency },
set: { applyCurrency(code: $0) }
)) {
ForEach(["USD", "EUR", "GBP", "INR", "JPY", "AUD", "CAD"], id: \.self) { code in
Text(code).tag(code)
}
}
Picker("Accent", selection: Binding(
get: { store.accentPreset },
set: { store.accentPreset = $0 }
)) {
ForEach(AccentPreset.allCases) { preset in
Text(preset.rawValue).tag(preset)
}
}
}
}
.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("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, .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 .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 .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 .notBootstrapped:
Button("Connect") { Task { await store.bootstrapSubscription() } }
.buttonStyle(.borderedProminent)
case .bootstrapping:
ProgressView().controlSize(.small)
}
}
}
// 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, .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 .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 .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 .notBootstrapped:
Button("Connect") { Task { await store.bootstrapCodex() } }
.buttonStyle(.borderedProminent)
case .bootstrapping:
ProgressView().controlSize(.small)
}
}
}
// 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)
HStack(spacing: 10) {
Link("GitHub", destination: URL(string: "https://github.com/getagentseal/codeburn")!)
Link("Issues", destination: URL(string: "https://github.com/getagentseal/codeburn/issues")!)
}
.font(.system(size: 12))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
}