mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-03 21:35:13 +00:00
The computed Binding wrote UserDefaults but nothing invalidated the view, so the picker visually stayed on Auto while the value silently landed. Back it with AppStorage so selection updates render. Refs #647
715 lines
29 KiB
Swift
715 lines
29 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 = ""
|
|
|
|
// AppStorage (not a computed Binding over UsageRefreshCadence.current):
|
|
// a plain UserDefaults write does not invalidate the view, so the picker
|
|
// label would never reflect the selection even though the value landed.
|
|
@AppStorage(UsageRefreshCadence.defaultsKey)
|
|
private var usageRefreshSeconds: Int = UsageRefreshCadence.default.rawValue
|
|
|
|
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("Usage Refresh") {
|
|
Picker("Update every", selection: Binding(
|
|
get: { UsageRefreshCadence(rawValue: usageRefreshSeconds) ?? .default },
|
|
set: { usageRefreshSeconds = $0.rawValue }
|
|
)) {
|
|
ForEach(UsageRefreshCadence.allCases) { cadence in
|
|
Text(cadence.label).tag(cadence)
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
Text("How often the menubar figure re-reads your local session data. Auto refreshes every 30 seconds while you're plugged in and backs off on battery; Manual only refreshes when you open the popover or click Refresh Now.")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|