codeburn/app/renderer/sections/Settings.tsx
Resham Joshi ae1d1c026b
app: Download button direct-downloads the update asset (#777)
The update Download button opened the GitHub release page; it now
downloads the right artifact for the running platform directly: arm64
or x64 dmg on macOS (preload newly exposes process.arch), the Setup exe
on Windows. Linux keeps the release page since it ships three formats
and the user picks. Unknown platforms and preloads without arch fall
back to the page. URL mapping is unit-tested per platform.

Co-authored-by: reviewer <review@local>
2026-07-20 10:02:43 -07:00

492 lines
37 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import { Hint } from '../components/Hint'
import { CliErrorText, cliErrorDisplay } from '../components/CliErrorPanel'
import { ConnectAffordance } from '../components/ConnectAffordance'
import { Dropdown } from '../components/Dropdown'
import { Panel } from '../components/Panel'
import { ProviderLogo } from '../components/ProviderLogo'
import type { Section } from '../components/Sidebar'
import { usePolled } from '../hooks/usePolled'
import { updateDownloadUrl, useUpdateStatus } from '../hooks/useUpdateStatus'
import { version as appVersion } from '../../package.json'
import { readDailyBudget } from '../lib/budget'
import { formatConverted, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import { motionClass } from '../lib/motion'
import { REFRESH_OPTIONS, useRefreshCadence } from '../lib/refreshCadence'
import { showToast } from '../lib/toast'
import { ToastHost } from '../components/ToastHost'
import { rateLimitedNote } from './Plans'
import type { ActionResult, AliasRow, ClaudeConfigSelector, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, PriceOverrideList, PriceOverrideRow, PriceRates, QuotaProvider, ShareStatus, StatusJson, TelemetryStatus } from '../lib/types'
export type SettingsPane = 'general' | 'providers' | 'aliases' | 'pricing' | 'plans' | 'devices' | 'export' | 'privacy'
type Pane = SettingsPane
type Theme = 'system' | 'light' | 'dark'
type PlanPreset = { id: Exclude<PlanId, 'custom' | 'none'>; label: string; provider: Exclude<PlanProvider, 'all' | 'codex'> }
const PLAN_PRESETS: PlanPreset[] = [
{ id: 'claude-pro', label: 'Claude Pro', provider: 'claude' },
{ id: 'claude-max', label: 'Claude Max 20x', provider: 'claude' },
{ id: 'claude-max-5x', label: 'Claude Max 5x', provider: 'claude' },
{ id: 'cursor-pro', label: 'Cursor Pro', provider: 'cursor' },
{ id: 'supergrok', label: 'SuperGrok', provider: 'grok' },
{ id: 'supergrok-heavy', label: 'SuperGrok Heavy', provider: 'grok' },
]
// Claude and Codex subscriptions are detected from the CLI login (see the
// detected-subscriptions list), so only non-OAuth providers get a manual preset.
const MANUAL_PLAN_PRESETS = PLAN_PRESETS.filter(preset => preset.provider !== 'claude')
const CURRENCIES = [
'USD', 'EUR', 'GBP', 'JPY', 'CNY', 'CAD', 'AUD', 'CHF', 'HKD', 'SGD', 'INR', 'NZD', 'SEK', 'NOK', 'DKK',
'KRW', 'BRL', 'MXN', 'ZAR', 'AED', 'SAR', 'TRY', 'PLN', 'THB', 'IDR', 'MYR', 'PHP', 'RUB', 'ILS', 'CZK',
]
function readSetting(key: string): string | null {
try { return globalThis.localStorage?.getItem(key) ?? null } catch { return null }
}
function writeSetting(key: string, value: string): void {
try { globalThis.localStorage?.setItem(key, value) } catch { /* storage can be unavailable in hardened contexts */ }
}
const RAIL_ITEMS: Array<{ id: Pane; label: string; icon: React.ReactNode }> = [
{ id: 'general', label: 'General', icon: <><line x1="4" y1="8" x2="20" y2="8" /><circle cx="9" cy="8" r="2.2" /><line x1="4" y1="16" x2="20" y2="16" /><circle cx="15" cy="16" r="2.2" /></> },
{ id: 'providers', label: 'Providers', icon: <><rect x="3" y="3" width="7" height="7" rx="1.5" /><rect x="14" y="3" width="7" height="7" rx="1.5" /><rect x="3" y="14" width="7" height="7" rx="1.5" /><rect x="14" y="14" width="7" height="7" rx="1.5" /></> },
{ id: 'aliases', label: 'Model aliases', icon: <><path d="M20 12l-8 8-9-9V3h8z" /><circle cx="7.5" cy="7.5" r="1.4" /></> },
{ id: 'pricing', label: 'Pricing', icon: <><circle cx="12" cy="12" r="9" /><path d="M14.5 9a2.5 2.5 0 0 0-2.5-1.6c-1.5 0-2.5.8-2.5 2s1 1.6 2.5 2 2.5.9 2.5 2-1 2-2.5 2A2.5 2.5 0 0 1 9.5 15" /><line x1="12" y1="6" x2="12" y2="18" /></> },
{ id: 'plans', label: 'Plans', icon: <><rect x="2" y="5" width="20" height="14" rx="2" /><line x1="2" y1="10" x2="22" y2="10" /></> },
{ id: 'devices', label: 'Devices', icon: <><rect x="3" y="4" width="18" height="12" rx="1.5" /><line x1="8" y1="20" x2="16" y2="20" /><line x1="12" y1="16" x2="12" y2="20" /></> },
{ id: 'export', label: 'Export', icon: <><path d="M12 3v12" /><path d="M7 11l5 5 5-5" /><path d="M4 21h16" /></> },
{ id: 'privacy', label: 'Privacy & data', icon: <path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z" /> },
]
function periodLabel(period: Period): string {
if (period === 'today') return 'today'
if (period === 'week') return 'last 7 days'
if (period === 'month') return 'this month'
if (period === '30days') return 'last 30 days'
return 'all time'
}
function shortFingerprint(fingerprint: string): string {
const parts = fingerprint.split(':').filter(Boolean)
if (parts.length < 3) return fingerprint
return `${parts[0]}:${parts[1]}:…:${parts[parts.length - 1]}`
}
/** Inline destructive confirm: the button swaps to a prompt + Confirm/Cancel in
* place (no OS dialog). Auto-cancels on Escape or when focus leaves the group. */
function ConfirmButton({ label, prompt, onConfirm }: { label: string; prompt: string; onConfirm: () => void }) {
const [confirming, setConfirming] = useState(false)
if (!confirming) {
return <button className="btnp" onClick={() => setConfirming(true)}>{label}</button>
}
return (
<span
className="set-confirm"
onBlur={event => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setConfirming(false) }}
onKeyDown={event => { if (event.key === 'Escape') setConfirming(false) }}
>
<span className="set-confirm-q">{prompt}</span>
<button className="set-text-button" autoFocus onClick={() => { setConfirming(false); onConfirm() }}>Confirm</button>
<button className="set-text-button" onClick={() => setConfirming(false)}>Cancel</button>
</span>
)
}
export function Settings({ period, refreshToken = 0, onNavigate, initialPane, claudeConfigs, claudeConfigSource = null, onConfigMutated }: { period: Period; refreshToken?: number; onNavigate?: (section: Section) => void; initialPane?: SettingsPane; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource?: string | null; onConfigMutated?: () => void }) {
const [pane, setPane] = useState<Pane>(initialPane ?? 'general')
return (
<>
<div className="bar"><div className="t">Settings</div></div>
<ToastHost />
<div className={motionClass('body set-body', 'section-fade')}>
<nav className="set-rail" aria-label="Settings sections">
{RAIL_ITEMS.map(item => (
<button key={item.id} className={pane === item.id ? 'set-rail-item on' : 'set-rail-item'} aria-current={pane === item.id ? 'page' : undefined} onClick={() => setPane(item.id)}>
<svg viewBox="0 0 24 24" aria-hidden="true">{item.icon}</svg>{item.label}
</button>
))}
</nav>
<main className="set-pane">
{pane === 'general' && <GeneralPane period={period} refreshToken={refreshToken} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} />}
{pane === 'providers' && <ProvidersPane period={period} refreshToken={refreshToken} />}
{pane === 'aliases' && <AliasesPane refreshToken={refreshToken} onConfigMutated={onConfigMutated} />}
{pane === 'pricing' && <PricingPane refreshToken={refreshToken} onConfigMutated={onConfigMutated} />}
{pane === 'plans' && <PlansPane period={period} refreshToken={refreshToken} onNavigate={onNavigate} onConfigMutated={onConfigMutated} />}
{pane === 'devices' && <DevicesPane period={period} refreshToken={refreshToken} />}
{pane === 'export' && <ExportPane period={period} refreshToken={refreshToken} />}
{pane === 'privacy' && <PrivacyPane />}
</main>
</div>
<Hint items={[{ k: '⌘1-7', label: 'Navigate' }, { k: '⌘R', label: 'Refresh' }]} right="pairing uses mutual TLS · approve-style, no PIN" />
</>
)
}
function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource, onConfigMutated }: { period: Period; refreshToken: number; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource: string | null; onConfigMutated?: () => void }) {
const [currencyNonce, setCurrencyNonce] = useState(0)
const plans = usePolled<StatusJson>(() => codeburn.getPlans(period), [period, refreshToken, currencyNonce])
const [theme, setTheme] = useState<Theme>(() => {
const saved = readSetting('codeburn.theme')
return saved === 'light' || saved === 'dark' ? saved : 'system'
})
const [defaultPeriod, setDefaultPeriod] = useState(() => readSetting('codeburn.defaultPeriod') ?? 'today')
const cadence = useRefreshCadence()
const [budgetKind, setBudgetKind] = useState<'off' | 'usd' | 'tokens'>(() => readDailyBudget()?.kind ?? 'off')
const [budgetInput, setBudgetInput] = useState(() => { const budget = readDailyBudget(); return budget ? String(budget.value) : '' })
const [budgetError, setBudgetError] = useState('')
const update = useUpdateStatus()
const version = update?.currentVersion || appVersion
const updateNote = update?.updateAvailable && update.latestVersion
? `Update available: ${update.latestVersion}`
: update?.latestVersion
? 'Up to date'
: ''
useEffect(() => {
if (theme === 'system') document.documentElement.removeAttribute('data-theme')
else document.documentElement.setAttribute('data-theme', theme)
}, [theme])
// Store on change; a positive finite amount persists, anything else clears the
// cap (so the banner turns off) and, when non-empty, flags a validation error.
const persistBudget = (kind: 'off' | 'usd' | 'tokens', input: string) => {
const trimmed = input.trim()
if (kind === 'off' || trimmed === '') { setBudgetError(''); writeSetting('codeburn.dailyBudget', ''); return }
const value = Number(trimmed)
if (!Number.isFinite(value) || value <= 0) { setBudgetError('Enter a positive number.'); return }
setBudgetError('')
writeSetting('codeburn.dailyBudget', JSON.stringify({ kind, value }))
}
const chooseTheme = (next: Theme) => {
setTheme(next)
writeSetting('codeburn.theme', next)
}
const finishCurrency = (result: ActionResult) => {
showToast(result.ok ? 'Updated' : result.stderr || 'Unable to update currency', result.ok ? 'ok' : 'error')
if (result.ok) { setCurrencyNonce(value => value + 1); onConfigMutated?.() }
}
const currencies = [...CURRENCIES]
if (plans.data?.currency && !currencies.includes(plans.data.currency)) currencies.push(plans.data.currency)
const hasConfigs = Boolean(claudeConfigs && claudeConfigs.options.length > 0)
const activeConfigLabel = claudeConfigSource
? claudeConfigs?.options.find(option => option.id === claudeConfigSource)?.label ?? claudeConfigSource
: 'All Claude configs'
return (
<section className="set-p on">
<div><h3 className="set-h">General</h3><p className="set-sub">Display and appearance for the whole app.</p></div>
<div className="card">
<div className="about-sec">
<div className="about-sec-h">Appearance</div>
<div className="about-row"><span className="tx">Theme<small>Match your system or force a mode</small></span><span className="r"><span className="seg">
{(['system', 'light', 'dark'] as Theme[]).map(value => <button key={value} className={theme === value ? 'on' : undefined} aria-pressed={theme === value} onClick={() => chooseTheme(value)}>{value[0]!.toUpperCase() + value.slice(1)}</button>)}
</span></span></div>
</div>
{hasConfigs && (
<div className="about-sec">
<div className="about-sec-h">Claude config</div>
<div className="about-row"><span className="tx">Active config<small>Applies to the overview data. Manage config folders with the codeburn CLI.</small></span><span className="r"><span className="set-cap">{activeConfigLabel}</span></span></div>
</div>
)}
<div className="about-sec">
<div className="about-sec-h">Display</div>
<div className="about-row"><label className="tx" htmlFor="settings-currency">Currency</label><span className="r">
{plans.data ? <Dropdown id="settings-currency" ariaLabel="Currency" value={plans.data.currency} options={currencies.map(code => ({ value: code, label: code }))} onChange={value => void codeburn.setCurrency(value).then(finishCurrency)} width={92} /> : plans.error ? <SettingsErrorText error={plans.error} /> : <span className="set-cap">Loading</span>}
<button className="set-text-button" onClick={() => void codeburn.resetCurrency().then(finishCurrency)}>Reset to USD</button>
</span></div>
<div className="about-row"><label className="tx" htmlFor="settings-period">Default period<small>Applied on next launch.</small></label><span className="r"><Dropdown id="settings-period" ariaLabel="Default period" value={defaultPeriod} options={[{ value: 'today', label: 'Today' }, { value: 'week', label: '7d' }, { value: '30days', label: '30d' }, { value: 'month', label: 'Month' }, { value: 'all', label: 'All' }]} onChange={value => { setDefaultPeriod(value); writeSetting('codeburn.defaultPeriod', value) }} width={92} /></span></div>
<div className="about-row"><label className="tx" htmlFor="settings-refresh">Refresh every<small>How often data auto-refreshes. Manual updates only on R.</small></label><span className="r"><Dropdown id="settings-refresh" ariaLabel="Refresh every" value={cadence.value} options={REFRESH_OPTIONS.map(option => ({ value: option.value, label: option.label }))} onChange={cadence.setValue} width={124} /></span></div>
<div className="about-row"><label className="tx" htmlFor="settings-budget">Daily budget<small>Warns at 80%, alerts at 100%.</small></label><span className="r"><Dropdown id="settings-budget" ariaLabel="Daily budget" value={budgetKind} options={[{ value: 'off', label: 'Off' }, { value: 'usd', label: 'USD amount' }, { value: 'tokens', label: 'Tokens' }]} onChange={value => { const kind = value as 'off' | 'usd' | 'tokens'; setBudgetKind(kind); persistBudget(kind, budgetInput) }} width={120} />{budgetKind !== 'off' && <input className="set-input" type="text" inputMode="decimal" aria-label="Daily budget amount" placeholder={budgetKind === 'usd' ? 'USD' : 'tokens'} value={budgetInput} onChange={event => { setBudgetInput(event.target.value); persistBudget(budgetKind, event.target.value) }} style={{ width: 90 }} />}</span></div>
{budgetError && <p className="set-action-msg error">{budgetError}</p>}
</div>
<div className="about-sec set-last-sec">
<div className="about-sec-h">About</div>
<div className="about-row"><span className="tx">Version {version}{updateNote && <small>{updateNote}</small>}</span><span className="r">{update?.updateAvailable && update.tag ? <button className="set-text-button" onClick={() => { void codeburn.openExternal(updateDownloadUrl(update.tag!)) }}>Download</button> : null}</span></div>
</div>
</div>
</section>
)
}
function ProvidersPane({ period, refreshToken }: { period: Period; refreshToken: number }) {
const overview = usePolled<MenubarPayload>(() => codeburn.getOverview(period, 'all'), [period, refreshToken])
const details = overview.data?.current.providerDetails
// Prefer providerDetails (internal id + display label) so ProviderLogo keys on
// the internal id. Fall back to the providers map keys (lowercased display
// names) for older CLIs that omit providerDetails.
const providers = details
? details.filter(entry => entry.cost > 0).map(entry => ({ id: entry.id, label: entry.label, cost: entry.cost }))
: Object.entries(overview.data?.current.providers ?? {}).map(([id, cost]) => ({ id, label: id.charAt(0).toUpperCase() + id.slice(1), cost }))
return <section className="set-p on">
<div><h3 className="set-h">Providers</h3><p className="set-sub">codeburn auto-detects coding tools from local session files. No setup needed.</p></div>
{overview.error ? <SettingsErrorText error={overview.error} /> : !overview.data ? <p className="set-cap">Loading detected providers</p> : providers.length === 0 ? <p className="set-cap">No providers detected.</p> : providers.map(entry => <div className="card" key={entry.id}><div className="set-prov-head"><ProviderLogo provider={entry.id} /><span className="set-prov-name">{entry.label}</span><span className="set-status"><span className="set-dot ok" />Detected · {formatUsd(entry.cost)}</span></div></div>)}
</section>
}
function AliasesPane({ refreshToken, onConfigMutated }: { refreshToken: number; onConfigMutated?: () => void }) {
const [actionNonce, setActionNonce] = useState(0)
const aliases = usePolled<AliasRow[]>(() => codeburn.getAliases(), [refreshToken, actionNonce])
const [from, setFrom] = useState('')
const [to, setTo] = useState('')
const [error, setError] = useState('')
const complete = (result: ActionResult, added = false) => {
if (!result.ok) { setError(result.stderr || 'Alias action failed'); return }
setError('')
if (added) { setFrom(''); setTo('') }
setActionNonce(value => value + 1)
onConfigMutated?.()
}
return <section className="set-p on">
<div><h3 className="set-h">Model aliases</h3><p className="set-sub">Map an unrecognized model name to a priced model so its cost shows up.</p></div>
<div className="card"><div className="about-sec set-last-sec">
{aliases.error ? <SettingsErrorText error={aliases.error} /> : !aliases.data ? <p className="set-cap">Loading aliases</p> : aliases.data.length === 0 ? <p className="set-cap set-alias-empty">No aliases configured. Unknown models are priced at $0 until aliased.</p> : aliases.data.map(alias => <div className="set-alias" key={alias.from}><span className="set-mono">{alias.from}</span><span className="set-alias-ar"></span><span className="set-mono set-alias-to">{alias.to}</span><button className="btnp" onClick={() => void codeburn.removeAlias(alias.from).then(result => complete(result))}>Remove</button></div>)}
<div className="set-alias"><input aria-label="Unrecognized model" className="set-input set-mono" placeholder="unrecognized model" value={from} onChange={event => setFrom(event.target.value)} /><span className="set-alias-ar"></span><input aria-label="Priced model" className="set-input set-mono" placeholder="priced model" value={to} onChange={event => setTo(event.target.value)} /><button className="btnp btnp-primary" disabled={!from.trim() || !to.trim()} onClick={() => void codeburn.addAlias(from.trim(), to.trim()).then(result => complete(result, true))}>Add</button></div>
{error && <p className="set-action-msg error">{error}</p>}
</div></div>
<p className="set-cap">Unknown models are priced at $0 until aliased. A local model can instead be credited with what it would have cost via model-savings.</p>
</section>
}
function priceRateSummary(o: PriceOverrideRow): string {
const parts = [`in ${o.inputPerM}`, `out ${o.outputPerM}`]
if (typeof o.cacheReadPerM === 'number') parts.push(`read ${o.cacheReadPerM}`)
if (typeof o.cacheCreationPerM === 'number') parts.push(`create ${o.cacheCreationPerM}`)
return parts.join(' · ')
}
// '' -> not provided; a positive finite number -> a rate; 'invalid' otherwise.
function parseRate(raw: string): number | undefined | 'invalid' {
const trimmed = raw.trim()
if (trimmed === '') return undefined
const value = Number(trimmed)
if (!Number.isFinite(value) || value <= 0) return 'invalid'
return value
}
function PricingPane({ refreshToken, onConfigMutated }: { refreshToken: number; onConfigMutated?: () => void }) {
const [actionNonce, setActionNonce] = useState(0)
const overrides = usePolled<PriceOverrideList>(() => codeburn.getPriceOverrides(), [refreshToken, actionNonce])
const [model, setModel] = useState('')
const [input, setInput] = useState('')
const [output, setOutput] = useState('')
const [cacheRead, setCacheRead] = useState('')
const [cacheCreation, setCacheCreation] = useState('')
const [error, setError] = useState('')
const complete = (result: ActionResult, added = false) => {
if (!result.ok) { setError(result.stderr || 'Price override action failed'); return }
setError('')
if (added) { setModel(''); setInput(''); setOutput(''); setCacheRead(''); setCacheCreation('') }
setActionNonce(value => value + 1)
onConfigMutated?.()
}
const add = () => {
const fields: Array<[keyof PriceRates, string]> = [['input', input], ['output', output], ['cacheRead', cacheRead], ['cacheCreation', cacheCreation]]
const rates: PriceRates = {}
for (const [key, raw] of fields) {
const parsed = parseRate(raw)
if (parsed === 'invalid') { setError('Rates must be positive numbers (USD per 1M tokens).'); return }
if (parsed !== undefined) rates[key] = parsed
}
if (!model.trim()) { setError('Enter a model name.'); return }
if (rates.input === undefined || rates.output === undefined) { setError('Input and output rates are required.'); return }
setError('')
void codeburn.setPriceOverride(model.trim(), rates).then(result => complete(result, true))
}
return <section className="set-p on">
<div><h3 className="set-h">Pricing</h3><p className="set-sub">Override or add per-model rates so local or self-hosted models are priced. Rates are USD per 1,000,000 tokens.</p></div>
<div className="card"><div className="about-sec set-last-sec">
{overrides.error ? <SettingsErrorText error={overrides.error} /> : !overrides.data ? <p className="set-cap">Loading price overrides</p> : overrides.data.overrides.length === 0 ? <p className="set-cap set-alias-empty">No price overrides configured. Add one below to price an unrecognized or local model.</p> : overrides.data.overrides.map(override => <div className="set-price-row" key={override.model}><span className="set-mono">{override.model}</span><span className="set-price-rates">{priceRateSummary(override)}</span><ConfirmButton label="Remove" prompt="Remove?" onConfirm={() => void codeburn.removePriceOverride(override.model).then(result => complete(result))} /></div>)}
<div className="set-price-form">
<input aria-label="Override model" className="set-input set-mono set-price-model" placeholder="model name" value={model} onChange={event => setModel(event.target.value)} />
<input aria-label="Input rate" className="set-input" inputMode="decimal" placeholder="input" value={input} onChange={event => setInput(event.target.value)} />
<input aria-label="Output rate" className="set-input" inputMode="decimal" placeholder="output" value={output} onChange={event => setOutput(event.target.value)} />
<input aria-label="Cache read rate" className="set-input" inputMode="decimal" placeholder="cache read" value={cacheRead} onChange={event => setCacheRead(event.target.value)} />
<input aria-label="Cache creation rate" className="set-input" inputMode="decimal" placeholder="cache create" value={cacheCreation} onChange={event => setCacheCreation(event.target.value)} />
<button className="btnp btnp-primary" disabled={!model.trim() || !input.trim() || !output.trim()} onClick={add}>Add</button>
</div>
{error && <p className="set-action-msg error">{error}</p>}
</div></div>
<p className="set-cap">Rates are USD per 1,000,000 tokens. Input and output are required; cache read and cache creation are optional. A configured model is overridden; an unknown one is added.</p>
</section>
}
function planSummaries(status: StatusJson): JsonPlanSummary[] {
if (status.plans) return Object.values(status.plans).filter((plan): plan is JsonPlanSummary => Boolean(plan))
return status.plan ? [status.plan] : []
}
function DetectedRow({ quota, onReconnect }: { quota: QuotaProvider; onReconnect: () => void }) {
const name = quota.provider === 'claude' ? 'Claude' : 'Codex'
return <div className="about-row">
<ProviderLogo provider={quota.provider} />
<span className="tx">{name}</span>
{quota.connection === 'disconnected' || quota.connection === 'accessDenied'
? <div className="r set-status"><ConnectAffordance provider={quota.provider} connection={quota.connection} onRefresh={onReconnect} /></div>
: quota.rateLimited
? <span className="r set-status"><span className="set-dot" />{rateLimitedNote(quota.provider)}</span>
: <span className="r set-status"><span className="set-dot ok" />{quota.planLabel ?? 'Connected'}</span>}
</div>
}
function PlansPane({ period, refreshToken, onNavigate, onConfigMutated }: { period: Period; refreshToken: number; onNavigate?: (section: Section) => void; onConfigMutated?: () => void }) {
const [nonce, setNonce] = useState(0)
// Steady poll serves cached quota (force=false); the Connect affordance's
// Refresh forces a keychain-allowed fetch via the same path as Plans.tsx.
const [reconnectNonce, setReconnectNonce] = useState(0)
const lastForced = useRef(`${refreshToken}:${reconnectNonce}`)
const quota = usePolled<QuotaProvider[]>(() => {
const key = `${refreshToken}:${reconnectNonce}`
const force = key !== lastForced.current
lastForced.current = key
return codeburn.getQuota(force)
}, [refreshToken, reconnectNonce])
const plans = usePolled<StatusJson>(() => codeburn.getPlans(period), [period, refreshToken, nonce])
const [presetId, setPresetId] = useState(MANUAL_PLAN_PRESETS[0]!.id)
const configured = plans.data ? planSummaries(plans.data) : []
const finish = (result: ActionResult) => {
showToast(result.ok ? (result.stdout.trim() || 'Plan updated') : (result.stderr || 'Plan action failed'), result.ok ? 'ok' : 'error')
if (result.ok) { setNonce(value => value + 1); onConfigMutated?.() }
}
const remove = (plan: JsonPlanSummary) => {
void codeburn.resetPlan(plan.provider).then(finish)
}
const add = () => {
const preset = MANUAL_PLAN_PRESETS.find(item => item.id === presetId)!
void codeburn.setPlan(preset.id, preset.provider).then(finish)
}
return <section className="set-p on">
<div><h3 className="set-h">Plans</h3><p className="set-sub">Claude and Codex subscriptions connect and auto-detect your tier. Set a manual budget plan for any other provider.</p></div>
<div className="card">
<div className="about-sec set-last-sec">
<div className="about-sec-h">Detected subscriptions</div>
{quota.error && !quota.data ? <SettingsErrorText error={quota.error} /> : !quota.data ? <p className="set-cap">Detecting subscriptions</p> : quota.data.length === 0 ? <p className="set-cap">No detectable subscriptions.</p> : quota.data.map(provider => <DetectedRow key={provider.provider} quota={provider} onReconnect={() => setReconnectNonce(value => value + 1)} />)}
</div>
</div>
<div className="card">
<div className="about-sec">
<div className="about-sec-h">Budget plans (manual)</div>
{plans.error ? <SettingsErrorText error={plans.error} /> : !plans.data ? <p className="set-cap">Loading plans</p> : configured.length === 0 ? <p className="set-cap">No manual plans configured.</p> : configured.map(plan => <div className="about-row" key={plan.provider}><span className="tx">{PLAN_PRESETS.find(item => item.id === plan.id)?.label ?? plan.id}<small>{formatConverted(plan.budget)}/month · {plan.provider} · {plan.percentUsed}% used</small>{(plan.provider === 'claude' || plan.provider === 'codex') && <small>superseded by the detected subscription</small>}</span><span className="r"><ConfirmButton label="Remove" prompt="Remove?" onConfirm={() => remove(plan)} /></span></div>)}
</div>
<div className="about-sec set-last-sec">
<div className="about-row"><label className="tx" htmlFor="settings-plan-preset">Add a plan</label><span className="r"><Dropdown id="settings-plan-preset" ariaLabel="Add a plan" value={presetId} options={MANUAL_PLAN_PRESETS.map(preset => ({ value: preset.id, label: preset.label }))} onChange={value => setPresetId(value as PlanPreset['id'])} width={160} /><button className="btnp btnp-primary" onClick={add}>Add</button></span></div>
</div>
</div>
<p className="set-cap">Claude and Codex plans are detected automatically from your login. <button className="set-text-button" onClick={() => onNavigate?.('plans')}>Open Plans </button></p>
</section>
}
function ExportPane({ period, refreshToken }: { period: Period; refreshToken: number }) {
const overview = usePolled<MenubarPayload>(() => codeburn.getOverview(period, 'all'), [period, refreshToken])
const [format, setFormat] = useState<'csv' | 'json'>('csv')
const [provider, setProvider] = useState('all')
const [destination, setDestination] = useState<string | null>(null)
const [exporting, setExporting] = useState(false)
const providers = Object.keys(overview.data?.current.providers ?? {})
const chooseDirectory = async () => {
const selected = await codeburn.chooseDirectory()
if (selected) setDestination(selected)
}
const exportNow = async () => {
if (!destination) return
setExporting(true)
try {
const result = await codeburn.exportData(format, provider, destination)
showToast(result.ok ? `Exported to ${destination}` : (result.stderr || 'Export failed'), result.ok ? 'ok' : 'error')
} finally {
setExporting(false)
}
}
return <section className="set-p on">
<div><h3 className="set-h">Export</h3><p className="set-sub">Save your usage as CSV or JSON. Everything stays on your machine.</p></div>
<div className="card">
<div className="about-sec">
<div className="about-row"><span className="tx">Format</span><span className="r"><span className="seg"><button className={format === 'csv' ? 'on' : undefined} aria-pressed={format === 'csv'} onClick={() => setFormat('csv')}>CSV</button><button className={format === 'json' ? 'on' : undefined} aria-pressed={format === 'json'} onClick={() => setFormat('json')}>JSON</button></span></span></div>
<div className="about-row"><label className="tx" htmlFor="settings-export-provider">Provider</label><span className="r"><Dropdown id="settings-export-provider" ariaLabel="Provider" value={provider} options={[{ value: 'all', label: 'All providers' }, ...providers.map(value => ({ value, label: value.charAt(0).toUpperCase() + value.slice(1) }))]} onChange={setProvider} width={150} /></span></div>
<div className="about-row"><span className="tx">Destination</span><span className="r set-export-destination"><span className="set-mono">{destination ?? 'Choose a folder…'}</span><button className="btnp" onClick={() => void chooseDirectory()}>Choose folder</button></span></div>
</div>
<div className="about-sec set-last-sec"><div className="about-row"><span className="tx" /><span className="r"><button className="btnp btnp-primary" disabled={!destination || exporting} onClick={() => void exportNow()}>{exporting ? 'Exporting…' : 'Export'}</button></span></div></div>
</div>
<p className="set-cap">CSV writes a folder (summary, daily, models, projects, sessions, tools, mcp). JSON writes one file (schema codeburn.export.v2).</p>
</section>
}
function DevicesPane({ period, refreshToken }: { period: Period; refreshToken: number }) {
const [nonce, setNonce] = useState(0)
const identity = usePolled<Identity>(() => codeburn.getIdentity(), [refreshToken])
const shareStatus = usePolled<ShareStatus>(() => codeburn.getShareStatus(), [refreshToken])
const scan = usePolled<DeviceScanResult>(() => codeburn.getDevicesScan(), [refreshToken, nonce])
const devices = usePolled<CombinedUsage>(() => codeburn.getDevices(period), [period, refreshToken, nonce])
const refresh = () => setNonce(value => value + 1)
return <section className="set-p on"><div><h3 className="set-h">Devices</h3><p className="set-sub">Combine usage across your machines.</p></div><ThisDevicePanel identity={identity} shareStatus={shareStatus} /><DiscoveredPanel scan={scan} /><PairedPanel devices={devices} period={period} onRefresh={refresh} /></section>
}
function PrivacyPane() {
return <section className="set-p on"><div><h3 className="set-h">Privacy &amp; data</h3><p className="set-sub">What codeburn does, and does not do, with your data.</p></div><div className="card">
<PrivacyClaim title="Local-only" detail="Everything runs on your machine. Data is read from local session files." icon={<><rect x="4.5" y="10" width="15" height="11" rx="2" /><path d="M8 10V7a4 4 0 0 1 8 0v3" /></>} />
<PrivacyClaim title="No API keys" detail="Usage is detected from local files; no provider API keys are required." icon={<path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z" />} />
<TelemetryClaim />
</div></section>
}
/** The anonymous-telemetry consent toggle, mirroring the onboarding decision. */
function TelemetryClaim() {
const [status, setStatus] = useState<TelemetryStatus | null>(null)
useEffect(() => {
if (typeof codeburn.telemetryStatus !== 'function') return
codeburn.telemetryStatus().then(value => setStatus(value)).catch(() => {})
}, [])
if (!status) return null
const toggle = () => {
if (typeof codeburn.setTelemetryEnabled !== 'function') return
codeburn.setTelemetryEnabled(!status.enabled).then(value => setStatus(value)).catch(() => {})
}
return <div className="set-claim"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 19v-5M9 19V9M14 19v-8M19 19V5" /></svg><div style={{ flex: 1 }}><div className="set-claim-t">Anonymous telemetry</div><div className="set-claim-d">Optional usage statistics: model mix, task success, performance and errors. Never prompts, code or anything identifying.</div></div>
<button type="button" role="switch" aria-checked={status.enabled} aria-label="Anonymous telemetry" className={status.enabled ? 'switch on' : 'switch'} onClick={toggle}><span className="switch-knob" /></button>
</div>
}
function PrivacyClaim({ title, detail, icon }: { title: string; detail: string; icon: React.ReactNode }) {
return <div className="set-claim"><svg viewBox="0 0 24 24" aria-hidden="true">{icon}</svg><div><div className="set-claim-t">{title}</div><div className="set-claim-d">{detail}</div></div></div>
}
function ThisDevicePanel({ identity, shareStatus }: { identity: ReturnType<typeof usePolled<Identity>>; shareStatus: ReturnType<typeof usePolled<ShareStatus>> }) {
const status = shareStatus.data ? <span className="set-status"><span className={shareStatus.data.sharing ? 'set-dot ok' : 'set-dot'} />{shareStatus.data.sharing ? 'Visible' : 'Not sharing'}</span> : null
return <Panel title="This device" right={status}>{identity.data ? <div className="li"><div className="lx"><b>{identity.data.name}</b><span>Local device name: {identity.data.name}</span><span>{identity.data.fingerprint}</span></div></div> : identity.error ? <SettingsErrorText error={identity.error} /> : <p className="set-cap">Reading this device identity</p>}{shareStatus.error && <SettingsErrorText error={shareStatus.error} />}</Panel>
}
function DiscoveredPanel({ scan }: { scan: ReturnType<typeof usePolled<DeviceScanResult>> }) {
const found = scan.data?.found.filter(device => !device.paired) ?? []
return <Panel title="Discovered nearby" right={scan.loading ? 'listening…' : undefined}>{!scan.data && scan.error ? <SettingsErrorText error={scan.error} /> : !scan.data ? <p className="set-cap">listening</p> : found.length === 0 ? <p className="set-cap">No nearby devices found.</p> : found.map(device => <div className="li" key={`${device.host}:${device.port}:${device.fingerprint}`}><div className="lx"><b>{device.name}</b><span>fingerprint {shortFingerprint(device.fingerprint)}</span></div></div>)}<p className="set-cap set-device-caption">To pair a device, run <code>codeburn devices add</code> in a terminal. Pairing is interactive (approve on the other device).</p></Panel>
}
function PairedPanel({ devices, period, onRefresh }: { devices: ReturnType<typeof usePolled<CombinedUsage>>; period: Period; onRefresh: () => void }) {
const [error, setError] = useState('')
const paired = devices.data?.perDevice.filter(device => !device.local) ?? []
const remove = (name: string) => {
void codeburn.removeDevice(name).then(result => {
if (!result.ok) { setError(result.stderr || 'Unable to remove device'); return }
setError('')
onRefresh()
})
}
return <Panel title="Paired devices" right={<button className="set-text-button" onClick={onRefresh}>Refresh</button>}>{!devices.data && devices.error ? <SettingsErrorText error={devices.error} /> : !devices.data ? <p className="set-cap">Loading paired devices</p> : paired.length === 0 ? <p className="set-cap">No paired devices yet.</p> : paired.map(device => <div className="li" key={device.id}><div className="lx"><b>{device.name}</b><span>{device.sessions.toLocaleString('en-US')} sessions · {formatUsd(device.cost)} {periodLabel(period)}</span></div><ConfirmButton label="Remove" prompt="Remove?" onConfirm={() => remove(device.name)} /></div>)}{devices.data && devices.data.combined.deviceCount > 1 && <div className="li"><div className="lx"><b>Combined view active · {devices.data.combined.deviceCount} devices</b></div></div>}{error && <p className="set-action-msg error">{error}</p>}</Panel>
}
function SettingsErrorText({ error }: { error: CliError }) {
if (error.kind === 'not-found') { const display = cliErrorDisplay(error); return <p className="set-cap">{display.title}</p> }
return <CliErrorText error={error} />
}