mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 05:23:19 +00:00
Devin transcripts carry no committed_acu_cost, so the ACU path priced every session at zero and devin.acuUsdRate hid them all until it was set by hand. Cost now comes from each step's tokens and its generation_model through the shared pricing tables, with the cached share carved out of OpenAI-style prompt_tokens; swe-2-high stays unpriced at $0. Task text is the user turn, then the sessions.db title, then prompt_history. The menubar ACU field, its config store and the config key are gone. Separate commit: gpt-5-3-codex ids resolve to the gpt-5.3-codex pricing row instead of the base gpt-5 row.
2331 lines
93 KiB
Swift
2331 lines
93 KiB
Swift
import AppKit
|
||
import SwiftUI
|
||
|
||
/// System Settings–style window: a fixed-width sidebar (search, General/About,
|
||
/// per-provider rows) drives the detail pane. New providers plug in by adding
|
||
/// one entry to `providers`; each pane owns its own Form content and this
|
||
/// top-level view only hosts the shell.
|
||
struct SettingsView: View {
|
||
@Environment(AppStore.self) private var store
|
||
@State private var searchText = ""
|
||
|
||
/// One entry per provider pane. `id` doubles as the deep-link tag, so the
|
||
/// sidebar, the detail switch, and `store.settingsTab` all speak the same
|
||
/// strings.
|
||
struct ProviderPane: Identifiable {
|
||
let id: String
|
||
let name: String
|
||
let icon: String
|
||
let isConnected: Bool
|
||
}
|
||
|
||
private static let mainPaneIDs: Set<String> = ["general", "about"]
|
||
|
||
private var providers: [ProviderPane] {
|
||
// Only surface providers CodeBurn actually has a live quota adapter for;
|
||
// the rest of the catalog would just read "coming soon" and clutter the
|
||
// list, so they stay hidden until their adapter ships.
|
||
CapacityDockPreferences.supportedProviders
|
||
.filter { $0.catalogEntry.hasLiveCodeBurnQuotaAdapter }
|
||
// Grok Bot is an optional desktop app rather than a signed-in
|
||
// account, so its row only means something once it is on the Mac.
|
||
.filter { $0.id != "grokbot" || GrokBotSubscriptionService.isInstalled() }
|
||
.map { provider in
|
||
ProviderPane(
|
||
id: provider.id,
|
||
name: provider.displayName,
|
||
icon: provider.iconName,
|
||
isConnected: providerIsConnected(provider)
|
||
)
|
||
}
|
||
}
|
||
|
||
private func providerIsConnected(_ provider: CapacityDockProvider) -> Bool {
|
||
switch provider.id {
|
||
case CapacityDockProvider.claude.id: store.subscriptionLoadState == .loaded
|
||
case CapacityDockProvider.codex.id: store.codexLoadState == .loaded
|
||
case CapacityDockProvider.kimiCode.id: store.kimiLoadState == .loaded
|
||
case CapacityDockProvider.gemini.id: store.geminiLoadState == .loaded
|
||
case CapacityDockProvider.copilot.id: store.copilotLoadState == .loaded
|
||
case CapacityDockProvider.antigravity.id: store.antigravityLoadState == .loaded
|
||
default: store.capacityDockProviderIsConnected(provider)
|
||
}
|
||
}
|
||
|
||
// Search narrows the provider list only; General/About stay put.
|
||
private var filteredProviders: [ProviderPane] {
|
||
let query = searchText.trimmingCharacters(in: .whitespaces)
|
||
guard !query.isEmpty else { return providers }
|
||
return providers.filter { $0.name.localizedCaseInsensitiveContains(query) }
|
||
}
|
||
|
||
/// Deep links can name a pane that no longer exists; those fall back to
|
||
/// General instead of leaving the sidebar without a selection.
|
||
private var selection: Binding<String> {
|
||
Binding(
|
||
get: {
|
||
let tab = store.settingsTab
|
||
return Self.mainPaneIDs.contains(tab) || providers.contains { $0.id == tab } ? tab : "general"
|
||
},
|
||
set: { store.settingsTab = $0 }
|
||
)
|
||
}
|
||
|
||
private static let windowWidth: CGFloat = 880
|
||
private static let windowHeight: CGFloat = 620
|
||
private static let sidebarWidth: CGFloat = 260
|
||
|
||
var body: some View {
|
||
HStack(spacing: 0) {
|
||
// Fixed-width provider sidebar over an edge-to-edge system material,
|
||
// followed by a hairline divider and the selected detail pane.
|
||
sidebar
|
||
.frame(width: Self.sidebarWidth)
|
||
.background {
|
||
SettingsSidebarMaterial()
|
||
.ignoresSafeArea()
|
||
}
|
||
|
||
Divider()
|
||
.ignoresSafeArea()
|
||
|
||
detailView
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||
}
|
||
.frame(minWidth: Self.windowWidth, maxWidth: .infinity, minHeight: Self.windowHeight, maxHeight: .infinity)
|
||
.background {
|
||
SettingsWindowStyleAccessor(title: currentPaneTitle)
|
||
.allowsHitTesting(false)
|
||
}
|
||
}
|
||
|
||
private var sidebar: some View {
|
||
VStack(spacing: 0) {
|
||
SettingsSidebarSearchField(searchText: $searchText)
|
||
.padding(.horizontal, 8)
|
||
.padding(.top, 16)
|
||
.padding(.bottom, 8)
|
||
|
||
List(selection: selection) {
|
||
Section {
|
||
SettingsSidebarPaneRow(pane: "general", title: L("General"), systemImage: "gearshape.fill", color: .gray)
|
||
SettingsSidebarAboutRow()
|
||
}
|
||
Section {
|
||
ForEach(filteredProviders) { provider in
|
||
SettingsSidebarProviderRow(provider: provider)
|
||
.tag(provider.id)
|
||
}
|
||
} header: {
|
||
HStack(spacing: 4) {
|
||
Text(L("Providers"))
|
||
Spacer()
|
||
Text(L("%lld on", providers.filter(\.isConnected).count))
|
||
.foregroundStyle(.tertiary)
|
||
.monospacedDigit()
|
||
.padding(.trailing, 10)
|
||
}
|
||
}
|
||
}
|
||
.listStyle(.sidebar)
|
||
.scrollContentBackground(.hidden)
|
||
}
|
||
.padding(.horizontal, 8)
|
||
}
|
||
|
||
private var currentPaneTitle: String {
|
||
switch selection.wrappedValue {
|
||
case "general": return L("General")
|
||
case "about": return L("About")
|
||
default:
|
||
return providers.first { $0.id == selection.wrappedValue }?.name ?? L("Settings")
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var detailView: some View {
|
||
switch selection.wrappedValue {
|
||
case "claude": ClaudeSettingsTab()
|
||
case "codex": CodexSettingsTab()
|
||
case "kimi": KimiSettingsTab()
|
||
case "gemini": GeminiSettingsTab()
|
||
case "copilot": CopilotSettingsTab()
|
||
case "antigravity": AntigravitySettingsTab()
|
||
case "about": AboutSettingsTab()
|
||
default:
|
||
if let provider = CapacityDockProvider(rawValue: selection.wrappedValue) {
|
||
GenericProviderSettingsTab(provider: provider)
|
||
} else {
|
||
GeneralSettingsTab()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Sidebar support
|
||
|
||
/// Colored rounded-square symbol used for app panes in the settings sidebar,
|
||
/// mirroring the System Settings sidebar style.
|
||
private struct SettingsIconChip: View {
|
||
static let side: CGFloat = 20
|
||
|
||
let systemImage: String
|
||
let color: Color
|
||
|
||
var body: some View {
|
||
Image(systemName: systemImage)
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.frame(width: Self.side, height: Self.side)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 5, style: .continuous)
|
||
.fill(LinearGradient(
|
||
colors: [color.opacity(0.85), color],
|
||
startPoint: .top,
|
||
endPoint: .bottom)))
|
||
.accessibilityHidden(true)
|
||
}
|
||
}
|
||
|
||
private struct SettingsSidebarPaneRow: View {
|
||
let pane: String
|
||
let title: String
|
||
let systemImage: String
|
||
let color: Color
|
||
|
||
var body: some View {
|
||
HStack(spacing: 8) {
|
||
SettingsIconChip(systemImage: systemImage, color: color)
|
||
Text(title)
|
||
}
|
||
.tag(pane)
|
||
}
|
||
}
|
||
|
||
private struct SettingsSidebarAboutRow: View {
|
||
var body: some View {
|
||
HStack(spacing: 8) {
|
||
// Standard info glyph in a chip, matching the General row's style.
|
||
SettingsIconChip(systemImage: "info.circle.fill", color: .gray)
|
||
Text(L("About"))
|
||
}
|
||
.tag("about")
|
||
}
|
||
}
|
||
|
||
private struct SettingsSidebarProviderRow: View {
|
||
let provider: SettingsView.ProviderPane
|
||
|
||
var body: some View {
|
||
HStack(spacing: 8) {
|
||
SettingsSidebarBrandIcon(icon: provider.icon, isConnected: provider.isConnected)
|
||
|
||
Text(provider.name)
|
||
.foregroundStyle(provider.isConnected ? .primary : .secondary)
|
||
|
||
Spacer(minLength: 4)
|
||
|
||
if provider.isConnected {
|
||
Circle()
|
||
.fill(.green)
|
||
.frame(width: 6, height: 6)
|
||
.accessibilityHidden(true)
|
||
}
|
||
}
|
||
.opacity(provider.isConnected ? 1 : 0.62)
|
||
}
|
||
}
|
||
|
||
private struct SettingsSidebarBrandIcon: View {
|
||
let icon: String
|
||
let isConnected: Bool
|
||
|
||
var body: some View {
|
||
Group {
|
||
if let image = ProviderIconCache.image(named: icon) {
|
||
Image(nsImage: image)
|
||
.resizable()
|
||
.scaledToFit()
|
||
} else {
|
||
Image(systemName: "circle.dotted")
|
||
.resizable()
|
||
.scaledToFit()
|
||
}
|
||
}
|
||
.frame(width: 16, height: 16)
|
||
.foregroundStyle(isConnected ? .primary : .secondary)
|
||
.accessibilityHidden(true)
|
||
}
|
||
}
|
||
|
||
private struct SettingsSidebarSearchField: View {
|
||
@Binding var searchText: String
|
||
|
||
var body: some View {
|
||
HStack(spacing: 5) {
|
||
Image(systemName: "magnifyingglass")
|
||
.foregroundStyle(.secondary)
|
||
.accessibilityHidden(true)
|
||
|
||
TextField(L("Search providers"), text: $searchText)
|
||
.textFieldStyle(.plain)
|
||
|
||
if !searchText.isEmpty {
|
||
Button {
|
||
searchText = ""
|
||
} label: {
|
||
Image(systemName: "xmark.circle.fill")
|
||
.foregroundStyle(.secondary)
|
||
.accessibilityLabel(L("Clear"))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
.font(.callout)
|
||
.padding(.horizontal, 7)
|
||
.padding(.vertical, 4)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||
.fill(Color(nsColor: .textBackgroundColor).opacity(0.6)))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||
.stroke(Color(nsColor: .separatorColor).opacity(0.6), lineWidth: 1))
|
||
}
|
||
}
|
||
|
||
/// Edge-to-edge sidebar material so the sidebar runs up behind the transparent
|
||
/// titlebar, matching System Settings.
|
||
private struct SettingsSidebarMaterial: NSViewRepresentable {
|
||
func makeNSView(context: Context) -> NSVisualEffectView {
|
||
let view = NSVisualEffectView()
|
||
configure(view)
|
||
return view
|
||
}
|
||
|
||
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {
|
||
configure(nsView)
|
||
}
|
||
|
||
private func configure(_ view: NSVisualEffectView) {
|
||
view.material = .sidebar
|
||
view.blendingMode = .behindWindow
|
||
view.state = .followsWindowActiveState
|
||
}
|
||
}
|
||
|
||
/// Applies the System Settings window chrome (transparent, separator-less
|
||
/// titlebar over full-size content) to whichever window hosts this view.
|
||
/// Needed because the SwiftUI Settings scene exposes no styling hooks.
|
||
private struct SettingsWindowStyleAccessor: NSViewRepresentable {
|
||
let title: String
|
||
|
||
func makeNSView(context: Context) -> SettingsWindowStyleView {
|
||
SettingsWindowStyleView()
|
||
}
|
||
|
||
func updateNSView(_ nsView: SettingsWindowStyleView, context: Context) {
|
||
nsView.paneTitle = title
|
||
nsView.applyStyle()
|
||
}
|
||
}
|
||
|
||
private final class SettingsWindowStyleView: NSView {
|
||
var paneTitle = L("Settings")
|
||
|
||
override func viewDidMoveToWindow() {
|
||
super.viewDidMoveToWindow()
|
||
applyStyle()
|
||
}
|
||
|
||
private var didPlaceWindow = false
|
||
|
||
func applyStyle() {
|
||
guard let window else { return }
|
||
// Full-size content lets the sidebar material extend behind the
|
||
// titlebar so the edge-to-edge sidebar reaches the top of the window.
|
||
window.titlebarAppearsTransparent = true
|
||
window.titleVisibility = .visible
|
||
window.titlebarSeparatorStyle = .none
|
||
window.styleMask.insert(.fullSizeContentView)
|
||
window.styleMask.insert(.resizable)
|
||
// Match System Settings: the window is named after the visible pane.
|
||
window.title = paneTitle
|
||
window.collectionBehavior.insert(.fullScreenPrimary)
|
||
// The frameAutosave may restore a position saved when the window was
|
||
// smaller, leaving the grown window hanging off the screen edge —
|
||
// recenter once whenever it does not fit fully on its screen.
|
||
if !didPlaceWindow {
|
||
didPlaceWindow = true
|
||
if let screen = window.screen ?? NSScreen.main,
|
||
!screen.visibleFrame.contains(window.frame) {
|
||
window.center()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 language = LanguagePreference.current()
|
||
@State private var languageChanged = false
|
||
@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
|
||
|
||
// Stored as the raw string so an unrecognised value (older build, manual
|
||
// `defaults write`) parses back to .terminal instead of failing to decode.
|
||
@AppStorage(PreferredTerminal.defaultsKey)
|
||
private var preferredTerminalRaw: String = PreferredTerminal.default.rawValue
|
||
|
||
@AppStorage(UpdateNotificationPreference.defaultsKey)
|
||
private var notifyAboutUpdates: Bool = true
|
||
|
||
@AppStorage(CodexBankedResetNotificationPreference.defaultsKey)
|
||
private var notifyAboutBankedResets: Bool = true
|
||
|
||
@AppStorage(EarlyQuotaResetPreference.defaultsKey)
|
||
private var notifyAboutEarlyResets: Bool = true
|
||
|
||
@AppStorage(QuotaCrossingPreference.defaultsKey)
|
||
private var notifyAboutQuotaCrossings: Bool = true
|
||
|
||
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 L("Enter an amount above, or the alert stays off.") }
|
||
return store.isTokenMetric
|
||
? L("Flame icon turns yellow when today's tokens pass the daily budget.")
|
||
: L("Flame icon turns yellow when today's cost pass the daily budget.")
|
||
}
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section(L("Display")) {
|
||
Picker(L("Currency"), selection: Binding(
|
||
get: { store.currency },
|
||
set: { applyCurrency(code: $0) }
|
||
)) {
|
||
ForEach(SupportedCurrency.allCases) { currency in
|
||
Text(currency.pickerLabel).tag(currency.rawValue)
|
||
}
|
||
}
|
||
Picker(L("Metric"), selection: Binding(
|
||
get: { store.displayMetric },
|
||
set: { store.displayMetric = $0 }
|
||
)) {
|
||
Text(L("Cost ($)")).tag(DisplayMetric.cost)
|
||
Text(L("Tokens (↑↓)")).tag(DisplayMetric.tokens)
|
||
Text(L("Total Tokens")).tag(DisplayMetric.totalTokens)
|
||
Text(L("Credits (Codex)")).tag(DisplayMetric.credits)
|
||
Text(L("Icon Only")).tag(DisplayMetric.iconOnly)
|
||
}
|
||
Picker(L("Period"), selection: Binding(
|
||
get: { store.menubarPeriod },
|
||
set: { store.setMenubarPeriod($0) }
|
||
)) {
|
||
ForEach(Period.menubarMetricCases) { period in
|
||
Text(period.menubarMetricLabel).tag(period)
|
||
}
|
||
}
|
||
.pickerStyle(.menu)
|
||
Picker(L("Scope"), selection: Binding(
|
||
get: { store.menubarScope },
|
||
set: { store.setMenubarScope($0) }
|
||
)) {
|
||
ForEach(MenubarScope.allCases) { scope in
|
||
Text(scope.displayLabel).tag(scope)
|
||
}
|
||
}
|
||
.pickerStyle(.menu)
|
||
// Optional second menu-bar line. Off by default, so the status
|
||
// item keeps its existing single-row figure untouched.
|
||
Toggle(L("Second row"), isOn: Binding(
|
||
get: { store.menubarSecondRowEnabled },
|
||
set: { store.menubarSecondRowEnabled = $0 }
|
||
))
|
||
if store.menubarSecondRowEnabled {
|
||
Picker(L("Second row shows"), selection: Binding(
|
||
get: { store.menubarSecondRowMetric },
|
||
set: { store.menubarSecondRowMetric = $0 }
|
||
)) {
|
||
ForEach(MenubarSecondRowMetric.allCases) { metric in
|
||
Text(metric.settingsLabel).tag(metric)
|
||
}
|
||
}
|
||
.pickerStyle(.menu)
|
||
Text(L("Adds a smaller second line under the menubar figure. Quota remaining tracks whichever connected provider is nearest its limit. The line hides itself while the chosen metric has no data."))
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Picker(L("Accent"), selection: Binding(
|
||
get: { store.accentPreset },
|
||
set: { store.accentPreset = $0 }
|
||
)) {
|
||
ForEach(AccentPreset.allCases) { preset in
|
||
Text(preset.displayLabel).tag(preset)
|
||
}
|
||
}
|
||
}
|
||
|
||
CapacityDockSettingsSection()
|
||
|
||
Section(L("Language")) {
|
||
Picker(L("Language"), selection: $language) {
|
||
ForEach(LanguagePreference.allCases) { choice in
|
||
Text(choice.displayLabel).tag(choice)
|
||
}
|
||
}
|
||
.pickerStyle(.menu)
|
||
.onChange(of: language) { _, choice in
|
||
LanguagePreference.apply(choice)
|
||
languageChanged = true
|
||
}
|
||
if languageChanged {
|
||
// Inline rather than modal: the strings already loaded stay
|
||
// as they are until the process restarts, and nothing is
|
||
// lost by putting that off.
|
||
HStack {
|
||
Text(L("Relaunch to apply."))
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
Button(L("Relaunch")) { relaunch() }
|
||
}
|
||
} else {
|
||
Text(L("Follows System Settings > Language & Region unless you pick one here."))
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
Section(L("Usage Refresh")) {
|
||
Picker(L("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(L("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(L("Notifications")) {
|
||
Toggle(L("Notify me about updates"), isOn: $notifyAboutUpdates)
|
||
Text(L("Posts a notification when a new CodeBurn release is available. Click it to install."))
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
Toggle(L("Notify me when Codex banks a limit reset"), isOn: $notifyAboutBankedResets)
|
||
Text(L("OpenAI sometimes grants Codex accounts a credit that resets a rate-limit window early. CodeBurn reads these from the quota response it already fetches and tells you once per grant. It never spends one."))
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
Toggle(L("Notify me when a quota resets early"), isOn: $notifyAboutEarlyResets)
|
||
Text(L("Posts a notification when a provider resets a usage limit before its scheduled time, so you know the capacity is back."))
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
Toggle(L("Quota crossings (%1$lld%% and %2$lld%%)", 80, 100), isOn: $notifyAboutQuotaCrossings)
|
||
}
|
||
|
||
Section(L("Terminal")) {
|
||
Picker(L("Open commands in"), selection: Binding(
|
||
get: { PreferredTerminal(rawValue: preferredTerminalRaw) ?? .default },
|
||
set: { preferredTerminalRaw = $0.rawValue }
|
||
)) {
|
||
ForEach(PreferredTerminal.allCases) { terminal in
|
||
Text(terminal.isInstalled ? terminal.label : L("%@ (not installed)", terminal.label))
|
||
.tag(terminal)
|
||
}
|
||
}
|
||
.pickerStyle(.menu)
|
||
Text(L("Where Full Report and Optimize open. If the chosen app isn't installed CodeBurn falls back to Terminal; if that's missing too the command runs in the background. Only terminals that can script a command into a live window are listed."))
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
Section(L("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(L("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(L("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(L("Custom…")).tag(-1.0)
|
||
}
|
||
if tokenCustom {
|
||
HStack {
|
||
TextField(L("Amount"), text: $tokenText)
|
||
.multilineTextAlignment(.trailing)
|
||
.onSubmit { applyTokenBudget() }
|
||
.onChange(of: tokenText) { _, _ in applyTokenBudget() }
|
||
Text(L("M tokens")).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
} else {
|
||
Picker(L("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(L("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(L("Custom…")).tag(-1.0)
|
||
}
|
||
if costCustom {
|
||
HStack {
|
||
Text("$").foregroundStyle(.secondary)
|
||
TextField(L("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()
|
||
}
|
||
|
||
/// Restarts through a detached shell so the new process is not a child of
|
||
/// the one being terminated. The delay lets this instance exit before `open`
|
||
/// looks for a running copy.
|
||
private func relaunch() {
|
||
let task = Process()
|
||
task.executableURL = URL(fileURLWithPath: "/bin/sh")
|
||
task.arguments = ["-c", "sleep 0.6; open -n \"\(Bundle.main.bundlePath)\""]
|
||
try? task.run()
|
||
NSApp.terminate(nil)
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
private struct CapacityDockSettingsSection: View {
|
||
@Environment(AppStore.self) private var store
|
||
@State private var snapshot = CapacityDockPreferences.load()
|
||
|
||
private var manageableProviders: [CapacityDockProvider] {
|
||
CapacityDockProviderSelection.manageableProviders(
|
||
selected: snapshot.selectedProviders,
|
||
isConnected: store.capacityDockProviderIsDockEligible
|
||
)
|
||
}
|
||
|
||
private var enabledEligibleProviders: [CapacityDockProvider] {
|
||
manageableProviders.filter {
|
||
snapshot.selectedProviders.contains($0)
|
||
&& store.capacityDockProviderIsDockEligible($0)
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
Section(L("Capacity Dock")) {
|
||
Toggle(L("Show Capacity Dock"), isOn: Binding(
|
||
get: { snapshot.isEnabled },
|
||
set: { CapacityDockPreferences.setEnabled($0) }
|
||
))
|
||
|
||
if !enabledEligibleProviders.isEmpty {
|
||
Picker(L("Resting provider"), selection: Binding(
|
||
get: {
|
||
enabledEligibleProviders.contains(snapshot.preferredProvider)
|
||
? snapshot.preferredProvider
|
||
: enabledEligibleProviders[0]
|
||
},
|
||
set: { CapacityDockPreferences.setPreferredProvider($0) }
|
||
)) {
|
||
ForEach(enabledEligibleProviders) { provider in
|
||
Text(provider.displayName).tag(provider)
|
||
}
|
||
}
|
||
.pickerStyle(.menu)
|
||
}
|
||
|
||
HStack(spacing: 10) {
|
||
Text(L("Size"))
|
||
Slider(
|
||
value: Binding(
|
||
get: { snapshot.scale },
|
||
set: { CapacityDockPreferences.setScale($0) }
|
||
),
|
||
in: CapacityDockPreferences.scaleRange,
|
||
step: 0.05
|
||
)
|
||
.accessibilityLabel(L("Capacity Dock size"))
|
||
Text("\(Int((snapshot.scale * 100).rounded()))%")
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: 38, alignment: .trailing)
|
||
}
|
||
|
||
Picker(L("Appearance"), selection: Binding(
|
||
get: { snapshot.theme },
|
||
set: { CapacityDockPreferences.setTheme($0) }
|
||
)) {
|
||
ForEach(CapacityDockTheme.allCases, id: \.self) { theme in
|
||
Text(theme.displayName).tag(theme)
|
||
}
|
||
}
|
||
.pickerStyle(.menu)
|
||
|
||
Picker(L("Gauge shape"), selection: Binding(
|
||
get: { snapshot.gaugeShape },
|
||
set: { CapacityDockPreferences.setGaugeShape($0) }
|
||
)) {
|
||
ForEach(CapacityDockGaugeShape.allCases, id: \.self) { shape in
|
||
Text(shape.displayName).tag(shape)
|
||
}
|
||
}
|
||
.pickerStyle(.menu)
|
||
|
||
Text(L("Dock providers"))
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundStyle(.secondary)
|
||
.padding(.top, 4)
|
||
|
||
if manageableProviders.isEmpty {
|
||
Text(L("Connect a provider from its sidebar page to make it available here."))
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
ForEach(manageableProviders) { provider in
|
||
Toggle(isOn: Binding(
|
||
get: { snapshot.selectedProviders.contains(provider) },
|
||
set: { setProvider(provider, selected: $0) }
|
||
)) {
|
||
HStack(spacing: 7) {
|
||
if let image = ProviderIconCache.image(named: provider.iconName) {
|
||
Image(nsImage: image)
|
||
.resizable()
|
||
.scaledToFit()
|
||
.frame(width: 15, height: 15)
|
||
.foregroundStyle(.primary)
|
||
}
|
||
Text(provider.displayName)
|
||
if !store.capacityDockProviderIsConnected(provider) {
|
||
Text(L("Needs attention"))
|
||
.font(.system(size: 10))
|
||
.foregroundStyle(.red)
|
||
}
|
||
}
|
||
}
|
||
.toggleStyle(.switch)
|
||
.disabled(!CapacityDockProviderSelection.canDeselect(
|
||
provider,
|
||
selected: snapshot.selectedProviders,
|
||
isConnected: store.capacityDockProviderIsDockEligible
|
||
))
|
||
}
|
||
|
||
Text(L("Connected providers and anything already shown in the dock appear here, so a provider can always be removed even if its connection later fails."))
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
.onAppear(perform: reload)
|
||
.onReceive(NotificationCenter.default.publisher(for: .capacityDockPreferencesDidChange)) { _ in
|
||
reload()
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .capacityDockCredentialPresenceDidChange)) { _ in
|
||
reload()
|
||
}
|
||
}
|
||
|
||
private func setProvider(_ provider: CapacityDockProvider, selected: Bool) {
|
||
var providers = snapshot.selectedProviders
|
||
if selected {
|
||
providers.append(provider)
|
||
} else {
|
||
providers.removeAll { $0 == provider }
|
||
}
|
||
CapacityDockPreferences.setSelectedProviders(providers)
|
||
}
|
||
|
||
private func reload() {
|
||
snapshot = CapacityDockPreferences.load()
|
||
}
|
||
}
|
||
|
||
// MARK: - Claude
|
||
|
||
private struct ClaudeSettingsTab: View {
|
||
@Environment(AppStore.self) private var store
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section(L("Connection")) {
|
||
ClaudeConnectionRow()
|
||
}
|
||
Section {
|
||
ClaudeConfigDirsSection()
|
||
} header: {
|
||
Text(L("Config Directories"))
|
||
} footer: {
|
||
Text(L("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(L("Quota Refresh")) {
|
||
Picker(L("Update every"), selection: Binding(
|
||
get: { SubscriptionRefreshCadence.current },
|
||
set: { SubscriptionRefreshCadence.current = $0 }
|
||
)) {
|
||
ForEach(SubscriptionRefreshCadence.allCases) { cadence in
|
||
Text(cadence.label).tag(cadence)
|
||
}
|
||
}
|
||
.pickerStyle(.menu)
|
||
Text(L("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(L("Refresh Now")) {
|
||
if let delegate = NSApp.delegate as? AppDelegate {
|
||
delegate.refreshSubscriptionNow()
|
||
} else {
|
||
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 L("Connected")
|
||
case let .terminalFailure(reason): return reason ?? L("Reconnect required")
|
||
case .transientFailure: return L("Backing off")
|
||
case .bootstrapping: return L("Connecting…")
|
||
case .loading: return L("Refreshing…")
|
||
case .dormant: return L("Ready")
|
||
case .notBootstrapped, .noCredentials: return L("Not connected")
|
||
case .failed: return L("Couldn't load plan data")
|
||
}
|
||
}
|
||
|
||
private var stateDetail: String {
|
||
switch store.subscriptionLoadState {
|
||
case .loaded:
|
||
if let tier = store.subscription?.tier.displayName {
|
||
return L("Plan: %@", tier)
|
||
}
|
||
return L("Live quota tracked from Anthropic.")
|
||
case .terminalFailure: return L("Open Claude Code in your terminal and type `/login`, then click Reconnect.")
|
||
case .transientFailure: return store.subscriptionError ?? L("Anthropic rate-limited; auto-retrying.")
|
||
case .bootstrapping: return L("macOS may ask permission to read your credentials.")
|
||
case .loading: return L("Background refresh in progress.")
|
||
case .dormant: return L("Tap Load Quota to fetch live usage from Anthropic.")
|
||
case .notBootstrapped, .noCredentials: return L("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(L("Disconnect")) { showDisconnectConfirm = true }
|
||
.confirmationDialog(
|
||
L("Disconnect Claude?"),
|
||
isPresented: $showDisconnectConfirm
|
||
) {
|
||
Button(L("Disconnect"), role: .destructive) {
|
||
store.disconnectSubscription()
|
||
}
|
||
Button(L("Cancel"), role: .cancel) {}
|
||
} message: {
|
||
Text(L("CodeBurn will stop tracking quota and clear its connection state plus any legacy credential cache. Your Claude Code credential is untouched. Claude Code keeps working."))
|
||
}
|
||
case .terminalFailure, .noCredentials, .failed:
|
||
Button(L("Reconnect")) { Task { await store.bootstrapSubscription() } }
|
||
.buttonStyle(.borderedProminent)
|
||
case .dormant:
|
||
Button(L("Load Quota")) { Task { await store.activateClaudeFromDormant() } }
|
||
.buttonStyle(.borderedProminent)
|
||
case .notBootstrapped:
|
||
Button(L("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(L("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(L("Remove"))
|
||
}
|
||
}
|
||
}
|
||
|
||
Button {
|
||
addDirectory()
|
||
} label: {
|
||
Label(L("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 = L("Add")
|
||
panel.message = L("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 {
|
||
CodexConnectionRow()
|
||
}
|
||
Section {
|
||
Text(L("Codex live-quota tracking follows the authoritative `~/.codex/auth.json` session directly and does not create a second Keychain copy. A legacy CodeBurn Keychain item, when present, is read only as a migration fallback. Only ChatGPT-mode auth (Plus / Pro / Team / Business / Edu / Enterprise) is supported. API-key users are billed per request and have a different reporting surface. Credit-metered workspaces report no rate-limit windows, so their monthly credit allowance is shown instead."))
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
} header: {
|
||
Text(L("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 L("Connected")
|
||
case let .terminalFailure(reason): return reason ?? L("Reconnect required")
|
||
case .transientFailure: return L("Backing off")
|
||
case .bootstrapping: return L("Connecting…")
|
||
case .loading: return L("Refreshing…")
|
||
case .dormant: return L("Ready")
|
||
case .notBootstrapped, .noCredentials: return L("Not connected")
|
||
case .failed: return L("Couldn't load Codex quota")
|
||
}
|
||
}
|
||
|
||
private var stateDetail: String {
|
||
switch store.codexLoadState {
|
||
case .loaded:
|
||
if let plan = store.codexUsage?.plan.displayName {
|
||
return L("Plan: %@", plan)
|
||
}
|
||
return L("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 L("Codex is in API-key mode. Run `codex login` and choose a ChatGPT plan to enable quota tracking.")
|
||
}
|
||
return L("Run `codex login` in your terminal to sign in again, then click Reconnect.")
|
||
case .transientFailure: return store.codexError ?? L("ChatGPT rate-limited; auto-retrying.")
|
||
case .bootstrapping: return L("Reading ~/.codex/auth.json.")
|
||
case .loading: return L("Background refresh in progress.")
|
||
case .dormant: return L("Tap Load Quota to fetch live usage from chatgpt.com.")
|
||
case .notBootstrapped, .noCredentials:
|
||
return L("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(L("Disconnect")) { showDisconnectConfirm = true }
|
||
.confirmationDialog(
|
||
L("Disconnect Codex?"),
|
||
isPresented: $showDisconnectConfirm
|
||
) {
|
||
Button(L("Disconnect"), role: .destructive) {
|
||
store.disconnectCodex()
|
||
}
|
||
Button(L("Cancel"), role: .cancel) {}
|
||
} message: {
|
||
Text(L("CodeBurn will stop tracking quota and clear its connection state plus any legacy credential cache. Your ~/.codex/auth.json is untouched. Codex CLI keeps working."))
|
||
}
|
||
case .terminalFailure, .noCredentials, .failed:
|
||
Button(L("Reconnect")) { Task { await store.bootstrapCodex() } }
|
||
.buttonStyle(.borderedProminent)
|
||
case .dormant:
|
||
Button(L("Load Quota")) { Task { await store.activateCodexFromDormant() } }
|
||
.buttonStyle(.borderedProminent)
|
||
case .notBootstrapped:
|
||
Button(L("Connect")) { Task { await store.bootstrapCodex() } }
|
||
.buttonStyle(.borderedProminent)
|
||
case .bootstrapping:
|
||
ProgressView().controlSize(.small)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Kimi Code
|
||
|
||
private struct KimiSettingsTab: View {
|
||
var body: some View {
|
||
Form {
|
||
Section(L("Connection")) {
|
||
KimiConnectionRow()
|
||
}
|
||
Section {
|
||
Text(L("Kimi Code live-quota tracking reads `~/.kimi-code/credentials/kimi-code.json` directly. Nothing is copied or stored. Access tokens are short-lived (~15 minutes) and only the Kimi CLI refreshes them, so if the connection shows as expired, run the Kimi CLI once and click Reconnect."))
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
} header: {
|
||
Text(L("How it works"))
|
||
}
|
||
}
|
||
.formStyle(.grouped)
|
||
.padding()
|
||
}
|
||
}
|
||
|
||
private struct KimiConnectionRow: 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.kimiLoadState {
|
||
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.kimiLoadState {
|
||
case .loaded: return .green
|
||
case .terminalFailure, .failed: return .red
|
||
case .transientFailure: return .orange
|
||
default: return .secondary
|
||
}
|
||
}
|
||
|
||
private var stateTitle: String {
|
||
switch store.kimiLoadState {
|
||
case .loaded: return L("Connected")
|
||
case let .terminalFailure(reason): return reason ?? L("Login refresh required")
|
||
case .transientFailure: return L("Backing off")
|
||
case .bootstrapping: return L("Connecting…")
|
||
case .loading: return L("Refreshing…")
|
||
case .dormant: return L("Ready")
|
||
case .notBootstrapped, .noCredentials: return L("Not connected")
|
||
case .failed: return L("Couldn't load Kimi quota")
|
||
}
|
||
}
|
||
|
||
private var stateDetail: String {
|
||
switch store.kimiLoadState {
|
||
case .loaded:
|
||
return L("Live quota tracked from api.kimi.com.")
|
||
case .terminalFailure:
|
||
return L("Run the Kimi CLI once to refresh your login, then click Reconnect.")
|
||
case .transientFailure: return store.kimiError ?? L("Kimi rate-limited; auto-retrying.")
|
||
case .bootstrapping: return L("Reading ~/.kimi-code credentials.")
|
||
case .loading: return L("Background refresh in progress.")
|
||
case .dormant: return L("Tap Load Quota to fetch live usage from api.kimi.com.")
|
||
case .notBootstrapped, .noCredentials:
|
||
return L("Sign in with the Kimi CLI first, then click Connect.")
|
||
case .failed: return store.kimiError ?? ""
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var actionButton: some View {
|
||
switch store.kimiLoadState {
|
||
case .loaded, .transientFailure, .loading:
|
||
Button(L("Disconnect")) { showDisconnectConfirm = true }
|
||
.confirmationDialog(
|
||
L("Disconnect Kimi Code?"),
|
||
isPresented: $showDisconnectConfirm
|
||
) {
|
||
Button(L("Disconnect"), role: .destructive) {
|
||
store.disconnectKimi()
|
||
}
|
||
Button(L("Cancel"), role: .cancel) {}
|
||
} message: {
|
||
Text(L("CodeBurn will stop tracking Kimi Code quota. Your ~/.kimi-code credentials are untouched. The Kimi CLI keeps working."))
|
||
}
|
||
case .terminalFailure, .noCredentials, .failed:
|
||
Button(L("Reconnect")) { Task { await store.bootstrapKimi() } }
|
||
.buttonStyle(.borderedProminent)
|
||
case .dormant:
|
||
Button(L("Load Quota")) { Task { await store.bootstrapKimi() } }
|
||
.buttonStyle(.borderedProminent)
|
||
case .notBootstrapped:
|
||
Button(L("Connect")) { Task { await store.bootstrapKimi() } }
|
||
.buttonStyle(.borderedProminent)
|
||
case .bootstrapping:
|
||
ProgressView().controlSize(.small)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Gemini
|
||
|
||
private struct GeminiSettingsTab: View {
|
||
var body: some View {
|
||
Form {
|
||
Section(L("Connection")) {
|
||
GeminiConnectionRow()
|
||
}
|
||
Section {
|
||
Text(L("Gemini live-quota tracking reads `~/.gemini/oauth_creds.json` read-only. Nothing is copied or stored, and tokens stay in memory. If the connection shows as expired, run the Gemini CLI once to refresh your login, then click Reconnect."))
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
} header: {
|
||
Text(L("How it works"))
|
||
}
|
||
}
|
||
.formStyle(.grouped)
|
||
.padding()
|
||
}
|
||
}
|
||
|
||
private struct GeminiConnectionRow: 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.geminiLoadState {
|
||
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.geminiLoadState {
|
||
case .loaded: return .green
|
||
case .terminalFailure, .failed: return .red
|
||
case .transientFailure: return .orange
|
||
default: return .secondary
|
||
}
|
||
}
|
||
|
||
private var stateTitle: String {
|
||
switch store.geminiLoadState {
|
||
case .loaded: return L("Connected")
|
||
case let .terminalFailure(reason): return reason ?? L("Login refresh required")
|
||
case .transientFailure: return L("Backing off")
|
||
case .bootstrapping: return L("Connecting…")
|
||
case .loading: return L("Refreshing…")
|
||
case .dormant: return L("Ready")
|
||
case .notBootstrapped, .noCredentials: return L("Not connected")
|
||
case .failed: return L("Couldn't load Gemini quota")
|
||
}
|
||
}
|
||
|
||
private var stateDetail: String {
|
||
switch store.geminiLoadState {
|
||
case .loaded:
|
||
if let plan = store.geminiUsage?.plan {
|
||
return L("Plan: %@", plan)
|
||
}
|
||
return L("Live quota tracked from Google Code Assist.")
|
||
case .terminalFailure:
|
||
return L("Run the Gemini CLI once to refresh your login, then click Reconnect.")
|
||
case .transientFailure: return store.geminiError ?? L("Gemini rate-limited; auto-retrying.")
|
||
case .bootstrapping: return L("Reading ~/.gemini credentials.")
|
||
case .loading: return L("Background refresh in progress.")
|
||
case .dormant: return L("Tap Load Quota to fetch live usage from Google Code Assist.")
|
||
case .notBootstrapped, .noCredentials:
|
||
return L("Sign in with the Gemini CLI first, then click Connect.")
|
||
case .failed: return store.geminiError ?? ""
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var actionButton: some View {
|
||
switch store.geminiLoadState {
|
||
case .loaded, .transientFailure, .loading:
|
||
Button(L("Disconnect")) { showDisconnectConfirm = true }
|
||
.confirmationDialog(
|
||
L("Disconnect Gemini?"),
|
||
isPresented: $showDisconnectConfirm
|
||
) {
|
||
Button(L("Disconnect"), role: .destructive) {
|
||
store.disconnectGemini()
|
||
}
|
||
Button(L("Cancel"), role: .cancel) {}
|
||
} message: {
|
||
Text(L("CodeBurn will stop tracking Gemini quota. Your ~/.gemini credentials are untouched. The Gemini CLI keeps working."))
|
||
}
|
||
case .terminalFailure, .noCredentials, .failed:
|
||
Button(L("Reconnect")) { Task { await store.bootstrapGemini() } }
|
||
.buttonStyle(.borderedProminent)
|
||
case .dormant:
|
||
Button(L("Load Quota")) { Task { await store.bootstrapGemini() } }
|
||
.buttonStyle(.borderedProminent)
|
||
case .notBootstrapped:
|
||
Button(L("Connect")) { Task { await store.bootstrapGemini() } }
|
||
.buttonStyle(.borderedProminent)
|
||
case .bootstrapping:
|
||
ProgressView().controlSize(.small)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Copilot
|
||
|
||
private struct CopilotSettingsTab: View {
|
||
var body: some View {
|
||
Form {
|
||
Section(L("Connection")) {
|
||
CopilotConnectionRow()
|
||
}
|
||
CopilotTokenSection()
|
||
Section {
|
||
Text(L("Copilot live-quota tracking reads a GitHub token that is already on this Mac, read-only. Nothing is copied or stored. CodeBurn looks at the editor plugin files in `~/.config/github-copilot`, the Copilot CLI's `~/.copilot` files, the COPILOT_GITHUB_TOKEN, GH_TOKEN and GITHUB_TOKEN variables, `gh auth token`, and finally a token you paste below. Usage tracking works without any of this; only the live quota bars need a token. Every one of those carries the GitHub host it belongs to — the plugin files are keyed by host, the variables are read with GH_HOST, the `gh` login with the host in gh's own hosts.yml, and a pasted token with the host you type beside it — and a credential for a GitHub Enterprise Cloud host is queried on that tenant's own API (api.<tenant>.ghe.com), never sent to api.github.com."))
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
} header: {
|
||
Text(L("How it works"))
|
||
}
|
||
}
|
||
.formStyle(.grouped)
|
||
.padding()
|
||
}
|
||
}
|
||
|
||
/// Last rung of the credential chain: a token the user pastes here. Stored in
|
||
/// CodeBurn's own provider-scoped Keychain item, the same one ClinePass uses.
|
||
private struct CopilotTokenSection: View {
|
||
@Environment(AppStore.self) private var store
|
||
@State private var token = ""
|
||
/// The GitHub host the pasted token belongs to. An enterprise PAT is not a
|
||
/// dotcom token, so this rung has to carry a host like the credential
|
||
/// files do (#1306).
|
||
@State private var host = CopilotHostEndpoint.defaultHost
|
||
@State private var isSaving = false
|
||
@State private var errorText: String?
|
||
|
||
var body: some View {
|
||
Section {
|
||
SecureField(L("GitHub token"), text: $token)
|
||
TextField(L("GitHub host"), text: $host, prompt: Text(CopilotHostEndpoint.defaultHost))
|
||
.disableAutocorrection(true)
|
||
HStack {
|
||
Button(L("Save & Connect")) { save(token) }
|
||
.buttonStyle(.borderedProminent)
|
||
.disabled(isSaving || token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||
Button(L("Clear Token")) { save("") }
|
||
.disabled(isSaving)
|
||
if isSaving {
|
||
ProgressView().controlSize(.small)
|
||
}
|
||
}
|
||
if let errorText {
|
||
Text(errorText)
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.red)
|
||
}
|
||
} header: {
|
||
Text(L("Paste a token"))
|
||
} footer: {
|
||
Text(L("Optional, and only needed when nothing else on this Mac is signed in. A fine-grained personal access token with the \"Plan: Read-only\" permission is enough. Leave the host at github.com unless the token was minted on a GitHub Enterprise Cloud tenant, in which case enter that tenant's host (<tenant>.ghe.com) so the token is never sent to api.github.com. Both are saved in CodeBurn's own Keychain item and are used only to read your Copilot quota."))
|
||
.font(.system(size: 11))
|
||
}
|
||
.task { await loadSavedHost() }
|
||
}
|
||
|
||
/// Shows the host already on file so a saved tenant is not silently
|
||
/// replaced with github.com on the next save. Gated on the non-secret
|
||
/// presence index, so a Mac with nothing pasted never touches Keychain.
|
||
private func loadSavedHost() async {
|
||
guard CapacityDockProviderCredentialPresence.contains(CopilotSubscriptionService.providerID) else { return }
|
||
guard let saved = try? await CapacityDockProviderCredentialStore.loadAsync(
|
||
for: CopilotSubscriptionService.providerID
|
||
).sanitizedOverride.host else { return }
|
||
host = saved
|
||
}
|
||
|
||
private func save(_ raw: String) {
|
||
let trimmedToken = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
// Clearing drops the host with the token: a host on its own addresses
|
||
// nothing, and leaving it behind would pair it with the next token.
|
||
if trimmedToken.isEmpty {
|
||
persist(CapacityDockProviderCredential())
|
||
return
|
||
}
|
||
// Refuse a host no endpoint can be derived from at the field the user
|
||
// typed, instead of saving it and reporting a terminal fetch failure.
|
||
if let rejection = CopilotQuotaPresentation.pastedHostRejection(host) {
|
||
errorText = rejection
|
||
return
|
||
}
|
||
let resolvedHost = CopilotHostEndpoint.normalize(host) ?? CopilotHostEndpoint.defaultHost
|
||
persist(CapacityDockProviderCredential(apiKey: trimmedToken, host: resolvedHost))
|
||
}
|
||
|
||
private func persist(_ credential: CapacityDockProviderCredential) {
|
||
isSaving = true
|
||
errorText = nil
|
||
Task {
|
||
defer { isSaving = false }
|
||
do {
|
||
try await CapacityDockProviderCredentialStore.saveAsync(
|
||
credential,
|
||
for: CopilotSubscriptionService.providerID
|
||
)
|
||
token = ""
|
||
if credential.isEmpty { host = CopilotHostEndpoint.defaultHost }
|
||
CopilotSubscriptionService.resetProbeCache()
|
||
await store.connectCopilot()
|
||
} catch {
|
||
errorText = error.localizedDescription
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct CopilotConnectionRow: 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.copilotLoadState {
|
||
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.copilotLoadState {
|
||
case .loaded: return .green
|
||
case .terminalFailure, .failed: return .red
|
||
case .transientFailure: return .orange
|
||
default: return .secondary
|
||
}
|
||
}
|
||
|
||
private var stateTitle: String {
|
||
switch store.copilotLoadState {
|
||
case .loaded: return L("Connected")
|
||
case let .terminalFailure(reason): return reason ?? L("Login refresh required")
|
||
case .transientFailure: return L("Backing off")
|
||
case .bootstrapping: return L("Connecting…")
|
||
case .loading: return L("Refreshing…")
|
||
case .dormant: return L("Ready")
|
||
case .notBootstrapped, .noCredentials: return L("Not connected")
|
||
case .failed: return L("Couldn't load Copilot quota")
|
||
}
|
||
}
|
||
|
||
private var stateDetail: String {
|
||
switch store.copilotLoadState {
|
||
case .loaded:
|
||
return CopilotQuotaPresentation.connectedSettingsDetail(
|
||
plan: store.copilotUsage?.plan,
|
||
apiHost: store.copilotUsage?.apiHost ?? CopilotHostEndpoint.defaultAPIHost
|
||
)
|
||
case .terminalFailure:
|
||
return L("Sign in again with the Copilot CLI, an editor's Copilot plugin, or gh auth login, then click Reconnect.")
|
||
case .transientFailure: return store.copilotError ?? L("GitHub rate-limited; auto-retrying.")
|
||
case .bootstrapping: return L("Looking for a GitHub token on this Mac.")
|
||
case .loading: return L("Background refresh in progress.")
|
||
case .dormant:
|
||
return CopilotQuotaPresentation.dormantSettingsDetail(apiHost: store.copilotUsage?.apiHost)
|
||
case .notBootstrapped:
|
||
return CopilotQuotaPresentation.settingsNotConnectedDetail(
|
||
explicitlyDisconnected: CopilotExplicitDisconnect.isSet(defaults: store.copilotQuotaRuntime.defaults)
|
||
)
|
||
case .noCredentials:
|
||
return CopilotQuotaPresentation.noCredentialsSettingsDetail
|
||
case .failed: return store.copilotError ?? ""
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var actionButton: some View {
|
||
switch store.copilotLoadState {
|
||
case .loaded, .transientFailure, .loading:
|
||
Button(L("Disconnect")) { showDisconnectConfirm = true }
|
||
.confirmationDialog(
|
||
L("Disconnect Copilot?"),
|
||
isPresented: $showDisconnectConfirm
|
||
) {
|
||
Button(L("Disconnect"), role: .destructive) {
|
||
store.disconnectCopilot()
|
||
}
|
||
Button(L("Cancel"), role: .cancel) {}
|
||
} message: {
|
||
Text(L("CodeBurn will stop tracking Copilot quota. Every credential it read stays untouched, and your Copilot clients keep working."))
|
||
}
|
||
case .terminalFailure, .noCredentials, .failed:
|
||
Button(L("Reconnect")) { Task { await store.connectCopilot() } }
|
||
.buttonStyle(.borderedProminent)
|
||
case .dormant:
|
||
Button(L("Load Quota")) { Task { await store.connectCopilot() } }
|
||
.buttonStyle(.borderedProminent)
|
||
case .notBootstrapped:
|
||
Button(L("Connect")) { Task { await store.connectCopilot() } }
|
||
.buttonStyle(.borderedProminent)
|
||
case .bootstrapping:
|
||
ProgressView().controlSize(.small)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Antigravity
|
||
|
||
private struct AntigravitySettingsTab: View {
|
||
var body: some View {
|
||
Form {
|
||
Section(L("Connection")) {
|
||
AntigravityConnectionRow()
|
||
}
|
||
Section {
|
||
Text(L("Antigravity live-quota tracking talks to the Antigravity app's local language server on 127.0.0.1 only. Nothing leaves the machine and no credential files are read. If it shows as disconnected, start the Antigravity app, then click Reconnect."))
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
} header: {
|
||
Text(L("How it works"))
|
||
}
|
||
}
|
||
.formStyle(.grouped)
|
||
.padding()
|
||
}
|
||
}
|
||
|
||
private struct AntigravityConnectionRow: 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.antigravityLoadState {
|
||
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.antigravityLoadState {
|
||
case .loaded: return .green
|
||
case .terminalFailure, .failed: return .red
|
||
case .transientFailure: return .orange
|
||
default: return .secondary
|
||
}
|
||
}
|
||
|
||
private var stateTitle: String {
|
||
switch store.antigravityLoadState {
|
||
case .loaded: return L("Connected")
|
||
case let .terminalFailure(reason): return reason ?? L("Reconnect required")
|
||
case .transientFailure: return L("Backing off")
|
||
case .bootstrapping: return L("Connecting…")
|
||
case .loading: return L("Refreshing…")
|
||
case .dormant: return L("Ready")
|
||
case .notBootstrapped, .noCredentials: return L("Not connected")
|
||
case .failed: return L("Couldn't load Antigravity quota")
|
||
}
|
||
}
|
||
|
||
private var stateDetail: String {
|
||
switch store.antigravityLoadState {
|
||
case .loaded:
|
||
if let plan = store.antigravityUsage?.plan {
|
||
return L("Plan: %@", plan)
|
||
}
|
||
return L("Live quota tracked from the local Antigravity server.")
|
||
case .terminalFailure:
|
||
return L("Start the Antigravity app, then click Reconnect.")
|
||
case .transientFailure: return store.antigravityError ?? L("Local probe failed; auto-retrying.")
|
||
case .bootstrapping: return L("Probing the local Antigravity language server.")
|
||
case .loading: return L("Background refresh in progress.")
|
||
case .dormant: return L("Tap Load Quota to probe the local Antigravity server.")
|
||
case .notBootstrapped:
|
||
return L("Start the Antigravity app first, then click Connect.")
|
||
case .noCredentials:
|
||
return L("No local Antigravity server found. Start the Antigravity app, then click Reconnect.")
|
||
case .failed: return store.antigravityError ?? ""
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var actionButton: some View {
|
||
switch store.antigravityLoadState {
|
||
case .loaded, .transientFailure, .loading:
|
||
Button(L("Disconnect")) { showDisconnectConfirm = true }
|
||
.confirmationDialog(
|
||
L("Disconnect Antigravity?"),
|
||
isPresented: $showDisconnectConfirm
|
||
) {
|
||
Button(L("Disconnect"), role: .destructive) {
|
||
store.disconnectAntigravity()
|
||
}
|
||
Button(L("Cancel"), role: .cancel) {}
|
||
} message: {
|
||
Text(L("CodeBurn will stop tracking Antigravity quota. Nothing is read from or written to disk. The Antigravity app keeps working."))
|
||
}
|
||
case .terminalFailure, .noCredentials, .failed:
|
||
Button(L("Reconnect")) { Task { await store.bootstrapAntigravity() } }
|
||
.buttonStyle(.borderedProminent)
|
||
case .dormant:
|
||
Button(L("Load Quota")) { Task { await store.bootstrapAntigravity() } }
|
||
.buttonStyle(.borderedProminent)
|
||
case .notBootstrapped:
|
||
Button(L("Connect")) { Task { await store.bootstrapAntigravity() } }
|
||
.buttonStyle(.borderedProminent)
|
||
case .bootstrapping:
|
||
ProgressView().controlSize(.small)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Catalog providers
|
||
|
||
/// Draft connection-override fields shown in Settings. Persistence stays
|
||
/// account-keyed; this value is only the in-memory editor and must be
|
||
/// rebuilt whenever the selected provider identity changes.
|
||
struct ProviderSettingsEditorState: Equatable {
|
||
var providerID: String
|
||
var credential: CapacityDockProviderCredential
|
||
var savedCredential: CapacityDockProviderCredential
|
||
var localError: String?
|
||
|
||
static func load(
|
||
providerID: String,
|
||
stored: CapacityDockProviderCredential
|
||
) -> ProviderSettingsEditorState {
|
||
ProviderSettingsEditorState(
|
||
providerID: providerID,
|
||
credential: stored,
|
||
savedCredential: stored,
|
||
localError: nil
|
||
)
|
||
}
|
||
|
||
mutating func applyProviderChange(
|
||
to providerID: String,
|
||
stored: CapacityDockProviderCredential
|
||
) {
|
||
guard self.providerID != providerID else { return }
|
||
self = .load(providerID: providerID, stored: stored)
|
||
}
|
||
|
||
mutating func beginLoading(providerID: String) {
|
||
guard self.providerID != providerID else { return }
|
||
self = .load(providerID: providerID, stored: CapacityDockProviderCredential())
|
||
}
|
||
|
||
/// A Keychain read can finish after the user has selected another row.
|
||
/// Ignore that stale result so one provider's secret can never appear in
|
||
/// another provider's editor, even for a single rendered frame.
|
||
mutating func applyLoadedCredential(
|
||
_ stored: CapacityDockProviderCredential,
|
||
for providerID: String
|
||
) {
|
||
guard self.providerID == providerID else { return }
|
||
self = .load(providerID: providerID, stored: stored)
|
||
}
|
||
}
|
||
|
||
private struct GenericProviderSettingsTab: View {
|
||
let provider: CapacityDockProvider
|
||
|
||
var body: some View {
|
||
Form {
|
||
GenericProviderConnectionSections(provider: provider)
|
||
.id(provider.id)
|
||
}
|
||
.formStyle(.grouped)
|
||
.padding()
|
||
}
|
||
}
|
||
|
||
private struct GenericProviderConnectionSections: View {
|
||
@Environment(AppStore.self) private var store
|
||
|
||
let provider: CapacityDockProvider
|
||
@State private var editor = ProviderSettingsEditorState.load(
|
||
providerID: "",
|
||
stored: CapacityDockProviderCredential()
|
||
)
|
||
@State private var credentialIsLoading = false
|
||
|
||
private var summary: QuotaSummary? {
|
||
store.capacityDockQuotaSummary(for: provider)
|
||
}
|
||
|
||
private var isLoading: Bool {
|
||
store.capacityDockProvidersLoading.contains(provider.id)
|
||
}
|
||
|
||
private var isConnected: Bool {
|
||
guard let connection = summary?.connection else { return false }
|
||
return connection == .connected || connection == .stale
|
||
}
|
||
|
||
private var hasLiveAdapter: Bool {
|
||
provider.catalogEntry.hasLiveCodeBurnQuotaAdapter
|
||
}
|
||
|
||
private var sourceModes: [ProviderReferenceSourceMode] {
|
||
ProviderReferenceSourceMode.allCases.filter(provider.catalogEntry.sourceModes.contains)
|
||
}
|
||
|
||
private var authMethods: [ProviderAuthMethod] {
|
||
ProviderAuthMethod.allCases.filter(provider.catalogEntry.authMethods.contains)
|
||
}
|
||
|
||
private var supportsAPIKey: Bool {
|
||
provider.catalogEntry.authMethods.contains(.apiTokenOrCloudCredentials)
|
||
}
|
||
|
||
var body: some View {
|
||
Group {
|
||
Section {
|
||
HStack(alignment: .top, spacing: 12) {
|
||
Image(systemName: connectionIcon)
|
||
.font(.system(size: 18))
|
||
.foregroundStyle(connectionTint)
|
||
.frame(width: 22)
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text(connectionTitle)
|
||
.font(.system(size: 12, weight: .semibold))
|
||
Text(connectionDetail)
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
Spacer()
|
||
if !hasLiveAdapter {
|
||
Text(L("Not yet supported"))
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundStyle(.secondary)
|
||
} else if isLoading {
|
||
ProgressView().controlSize(.small)
|
||
} else if isConnected {
|
||
Button(L("Disconnect"), role: .destructive) {
|
||
Task {
|
||
do {
|
||
try await store.disconnectCapacityDockProvider(provider)
|
||
editor = .load(
|
||
providerID: provider.id,
|
||
stored: CapacityDockProviderCredential()
|
||
)
|
||
} catch {
|
||
editor.localError = error.localizedDescription
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
Button(primaryConnectionButtonTitle, action: connect)
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
}
|
||
.padding(.vertical, 4)
|
||
} header: {
|
||
Text(L("Connection"))
|
||
} footer: {
|
||
Text(hasLiveAdapter
|
||
? L("Automatic connection uses the provider's existing app, CLI, OAuth, browser session, or environment credentials first. CodeBurn does not copy those source credentials into its Keychain.")
|
||
: L("Authentication methods are listed for reference. A native CodeBurn quota adapter is required before this provider can connect to Capacity Dock."))
|
||
.font(.system(size: 11))
|
||
}
|
||
|
||
Section(L("Authentication methods")) {
|
||
ForEach(authMethods, id: \.self) { method in
|
||
Label(method.title, systemImage: authIcon(method))
|
||
.font(.system(size: 11.5))
|
||
}
|
||
}
|
||
|
||
if hasLiveAdapter {
|
||
Section {
|
||
Picker(L("Source"), selection: $editor.credential.sourceMode) {
|
||
ForEach(sourceModes, id: \.self) { source in
|
||
Text(sourceTitle(source)).tag(source.rawValue)
|
||
}
|
||
}
|
||
.pickerStyle(.menu)
|
||
|
||
if supportsAPIKey {
|
||
SecureField(L("API key or token"), text: $editor.credential.apiKey)
|
||
}
|
||
|
||
HStack {
|
||
Button(L("Save & Connect")) { saveAndConnect() }
|
||
.buttonStyle(.borderedProminent)
|
||
Button(L("Clear Override")) {
|
||
Task {
|
||
do {
|
||
try await store.disconnectCapacityDockProvider(provider)
|
||
editor = .load(
|
||
providerID: provider.id,
|
||
stored: CapacityDockProviderCredential()
|
||
)
|
||
} catch {
|
||
editor.localError = error.localizedDescription
|
||
}
|
||
}
|
||
}
|
||
.disabled(editor.savedCredential.isEmpty || credentialIsLoading)
|
||
|
||
if credentialIsLoading {
|
||
ProgressView()
|
||
.controlSize(.small)
|
||
.accessibilityLabel(L("Loading saved provider credential"))
|
||
}
|
||
}
|
||
|
||
if let localError = editor.localError {
|
||
Text(localError)
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.red)
|
||
}
|
||
} header: {
|
||
Text(L("Connection override"))
|
||
} footer: {
|
||
Text(L("Overrides are optional and are saved only when you press Save & Connect. Secret values use one CodeBurn-owned Keychain item for this provider; background reads suppress authentication UI."))
|
||
.font(.system(size: 11))
|
||
}
|
||
.disabled(credentialIsLoading)
|
||
} else if CapacityDockProviderCredentialPresence.contains(provider.id) {
|
||
Section {
|
||
Button(L("Remove saved override"), role: .destructive) {
|
||
Task {
|
||
do {
|
||
try await store.disconnectCapacityDockProvider(provider)
|
||
editor = .load(
|
||
providerID: provider.id,
|
||
stored: CapacityDockProviderCredential()
|
||
)
|
||
} catch {
|
||
editor.localError = error.localizedDescription
|
||
}
|
||
}
|
||
}
|
||
if let localError = editor.localError {
|
||
Text(localError)
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.red)
|
||
}
|
||
} header: {
|
||
Text(L("Saved data"))
|
||
} footer: {
|
||
Text(L("This credential predates a live CodeBurn quota adapter and is not treated as a connection."))
|
||
.font(.system(size: 11))
|
||
}
|
||
}
|
||
}
|
||
.task(id: provider.id) {
|
||
await reloadEditor()
|
||
}
|
||
}
|
||
|
||
private var connectionTitle: String {
|
||
guard hasLiveAdapter else { return L("Quota adapter not available") }
|
||
if isLoading { return L("Connecting…") }
|
||
guard let summary else { return L("Not connected") }
|
||
switch summary.connection {
|
||
case .connected: return L("Connected")
|
||
case .loading: return L("Connecting…")
|
||
case .stale: return store.quotaRefreshIsInFlight(for: provider) ? L("Refreshing…") : L("Connected")
|
||
case .transientFailure: return L("Retrying")
|
||
case .terminalFailure: return L("Reconnect required")
|
||
case .disconnected: return L("Not connected")
|
||
}
|
||
}
|
||
|
||
private var connectionDetail: String {
|
||
guard hasLiveAdapter else {
|
||
return L("%@ is catalogued, but CodeBurn cannot fetch its live quota yet.", provider.displayName)
|
||
}
|
||
if let error = store.capacityDockProviderErrors[provider.id], !error.isEmpty {
|
||
return "\(error) \(ProviderConnectionGuidance.instruction(for: provider))"
|
||
}
|
||
guard let summary else {
|
||
return ProviderConnectionGuidance.instruction(for: provider)
|
||
}
|
||
if case let .terminalFailure(reason) = summary.connection {
|
||
let instruction = ProviderConnectionGuidance.instruction(for: provider)
|
||
guard let reason, !reason.isEmpty else { return instruction }
|
||
return "\(reason) \(instruction)"
|
||
}
|
||
if let source = summary.footerLines.first(where: { $0.hasPrefix("Source:") }) {
|
||
return source
|
||
}
|
||
return isConnected ? L("Live quota is available to Capacity Dock.") : L("Waiting for quota data.")
|
||
}
|
||
|
||
private var connectionIcon: String {
|
||
if isLoading { return "ellipsis.circle" }
|
||
return isConnected ? "checkmark.circle.fill" : "link.circle"
|
||
}
|
||
|
||
private var connectionTint: Color {
|
||
if isConnected { return .green }
|
||
if case .terminalFailure = summary?.connection { return .red }
|
||
return .secondary
|
||
}
|
||
|
||
private var requiresExplicitCredential: Bool {
|
||
// A provider whose only authentication path is an API credential cannot
|
||
// be connected from an empty "Automatic" draft: its live adapter still
|
||
// needs this provider-scoped key. Mixed-auth providers may legitimately
|
||
// discover an existing app, CLI, OAuth, or browser session instead.
|
||
provider.catalogEntry.authMethods == [.apiTokenOrCloudCredentials]
|
||
}
|
||
|
||
private var submissionAction: ProviderConnectionSubmissionPolicy.Action {
|
||
ProviderConnectionSubmissionPolicy.resolve(
|
||
credential: editor.credential,
|
||
savedCredential: editor.savedCredential,
|
||
requiresExplicitCredential: requiresExplicitCredential
|
||
)
|
||
}
|
||
|
||
private var primaryConnectionButtonTitle: String {
|
||
switch submissionAction {
|
||
case .saveAndConnect: return L("Save & Connect")
|
||
case .connect, .requiresCredential: return summary == nil ? L("Connect") : L("Retry")
|
||
}
|
||
}
|
||
|
||
private func saveAndConnect() {
|
||
if requiresExplicitCredential,
|
||
editor.credential.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
editor.localError = ProviderConnectionGuidance.instruction(for: provider)
|
||
return
|
||
}
|
||
let credential = editor.credential
|
||
Task {
|
||
do {
|
||
try await store.saveCapacityDockCredential(credential, for: provider)
|
||
editor.savedCredential = credential
|
||
editor.localError = nil
|
||
await store.connectCapacityDockProvider(provider)
|
||
} catch {
|
||
editor.localError = error.localizedDescription
|
||
}
|
||
}
|
||
}
|
||
|
||
private func authIcon(_ method: ProviderAuthMethod) -> String {
|
||
switch method {
|
||
case .localAppOrCLI: "terminal"
|
||
case .oauth: "person.badge.key"
|
||
case .apiTokenOrCloudCredentials: "key"
|
||
case .cookieOrWebSession: "globe"
|
||
case .localhost: "network"
|
||
case .none: "checkmark.seal"
|
||
}
|
||
}
|
||
|
||
private func sourceTitle(_ source: ProviderReferenceSourceMode) -> String {
|
||
switch source {
|
||
case .automatic: L("Automatic")
|
||
case .web: L("Browser session")
|
||
case .cli: L("CLI")
|
||
case .oauth: L("OAuth")
|
||
case .api: L("API")
|
||
}
|
||
}
|
||
|
||
private func connect() {
|
||
switch submissionAction {
|
||
case .requiresCredential:
|
||
editor.localError = ProviderConnectionGuidance.instruction(for: provider)
|
||
case .saveAndConnect:
|
||
saveAndConnect()
|
||
case .connect:
|
||
editor.localError = nil
|
||
Task { await store.connectCapacityDockProvider(provider) }
|
||
}
|
||
}
|
||
|
||
private func reloadEditor() async {
|
||
let providerID = provider.id
|
||
editor.beginLoading(providerID: providerID)
|
||
credentialIsLoading = true
|
||
defer {
|
||
if editor.providerID == providerID {
|
||
credentialIsLoading = false
|
||
}
|
||
}
|
||
|
||
do {
|
||
let stored = try await CapacityDockProviderCredentialStore.loadAsync(for: providerID)
|
||
guard !Task.isCancelled else { return }
|
||
editor.applyLoadedCredential(stored, for: providerID)
|
||
} catch {
|
||
guard !Task.isCancelled, editor.providerID == providerID else { return }
|
||
editor.localError = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
// MARK: - About
|
||
|
||
private struct AboutSettingsTab: View {
|
||
@Environment(UpdateChecker.self) private var updateChecker
|
||
|
||
private var versionString: String {
|
||
let version = AppVersion.normalizedBundleShortVersion
|
||
let build = AppVersion.normalizedBundleBuildVersion
|
||
return build == version ? version : "\(version) (\(build))"
|
||
}
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
hero
|
||
.frame(maxWidth: .infinity)
|
||
.listRowBackground(Color.clear)
|
||
}
|
||
|
||
Section {
|
||
LabeledContent(L("Version %@", versionString)) {
|
||
Button(L("Check for Updates")) {
|
||
Task { await updateChecker.check() }
|
||
}
|
||
}
|
||
if let error = updateChecker.updateError {
|
||
Text(error)
|
||
.font(.footnote)
|
||
.foregroundStyle(.secondary)
|
||
} else if updateChecker.updateAvailable, let latest = updateChecker.latestVersion {
|
||
Text(L("%@ is available. Choose Check for Updates in the CodeBurn menu to install it.", AppVersion.display(latest)))
|
||
.font(.footnote)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
} header: {
|
||
Text(L("Updates"))
|
||
}
|
||
|
||
Section {
|
||
AboutLinkRow(
|
||
icon: "chevron.left.slash.chevron.right",
|
||
title: L("GitHub"),
|
||
url: "https://github.com/getagentseal/codeburn")
|
||
AboutLinkRow(
|
||
icon: "globe",
|
||
title: L("Website"),
|
||
url: "https://codeburn.app")
|
||
AboutLinkRow(
|
||
icon: "exclamationmark.bubble",
|
||
title: L("Issues"),
|
||
url: "https://github.com/getagentseal/codeburn/issues")
|
||
} header: {
|
||
Text(L("Links"))
|
||
} footer: {
|
||
Text(L("© 2026 Resham Joshi (iamtoruk) · AgentSeal. MIT License."))
|
||
.frame(maxWidth: .infinity)
|
||
.multilineTextAlignment(.center)
|
||
}
|
||
}
|
||
.formStyle(.grouped)
|
||
.scrollContentBackground(.hidden)
|
||
}
|
||
|
||
private var hero: some View {
|
||
VStack(spacing: 10) {
|
||
if let flame = AboutFlameImage.load() {
|
||
Image(nsImage: flame)
|
||
.resizable()
|
||
.scaledToFit()
|
||
.frame(width: 72, height: 72)
|
||
} else if let icon = NSApplication.shared.applicationIconImage {
|
||
Image(nsImage: icon)
|
||
.resizable()
|
||
.frame(width: 64, height: 64)
|
||
.cornerRadius(12)
|
||
}
|
||
|
||
VStack(spacing: 2) {
|
||
Text("CodeBurn")
|
||
.font(.title3).fontWeight(.semibold)
|
||
Text(L("Version %@", versionString))
|
||
.foregroundStyle(.secondary)
|
||
Text(L("Your AI Bill, Itemized"))
|
||
.font(.footnote)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.padding(.vertical, 6)
|
||
}
|
||
}
|
||
|
||
private struct AboutLinkRow: View {
|
||
let icon: String
|
||
let title: String
|
||
let url: String
|
||
@State private var hovering = false
|
||
|
||
var body: some View {
|
||
Button {
|
||
if let url = URL(string: self.url) { NSWorkspace.shared.open(url) }
|
||
} label: {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: icon)
|
||
.frame(width: 18)
|
||
.foregroundStyle(.secondary)
|
||
Text(title)
|
||
.foregroundStyle(.primary)
|
||
Spacer()
|
||
Image(systemName: "arrow.up.right")
|
||
.font(.caption)
|
||
.foregroundStyle(hovering ? Color.accentColor : Color.secondary)
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.onHover { hovering = $0 }
|
||
}
|
||
}
|
||
|
||
/// The full-color binary-flame brand mark shown in the About hero. Loaded
|
||
/// directly (not via ProviderIconCache) because it must keep its colors —
|
||
/// the cache marks everything as a template image.
|
||
@MainActor
|
||
enum AboutFlameImage {
|
||
private static var cached: NSImage?
|
||
|
||
static func load() -> NSImage? {
|
||
if let cached { return cached }
|
||
for subdirectory in ["Resources/ProviderIcons", "ProviderIcons", nil] {
|
||
if let url = Bundle.module.url(forResource: "about-flame", withExtension: "png", subdirectory: subdirectory),
|
||
let image = NSImage(contentsOf: url) {
|
||
cached = image
|
||
return image
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
}
|