codeburn/app/renderer/sections/Settings.tsx
iamtoruk 28d294cde5 fix(app): provider logos key on internal ids + monogram fallback
Settings Providers pane keyed logos by lowercased display names
('grok build' never matched THEMED_LOGOS 'grok'); rows now come from
providerDetails ids like the rest of the app. 20 official marks added
from the repo's own assets (hermes, openclaw, antigravity, kiro, devin,
cline, roo-code, ...) and unknown providers render a first-letter
monogram badge so no detected provider is ever logoless.
2026-07-16 09:27:46 -07:00

454 lines
34 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 { readDailyBudget } from '../lib/budget'
import { formatConverted, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import { motionClass } from '../lib/motion'
import { showToast } from '../lib/toast'
import { ToastHost } from '../components/ToastHost'
import type { ActionResult, AliasRow, ClaudeConfigSelector, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, PriceOverrideList, PriceOverrideRow, PriceRates, QuotaProvider, ShareStatus, StatusJson } 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 }: { period: Period; refreshToken?: number; onNavigate?: (section: Section) => void; initialPane?: SettingsPane; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource?: string | null }) {
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} />}
{pane === 'providers' && <ProvidersPane period={period} refreshToken={refreshToken} />}
{pane === 'aliases' && <AliasesPane refreshToken={refreshToken} />}
{pane === 'pricing' && <PricingPane refreshToken={refreshToken} />}
{pane === 'plans' && <PlansPane period={period} refreshToken={refreshToken} onNavigate={onNavigate} />}
{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 }: { period: Period; refreshToken: number; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource: string | null }) {
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 [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('')
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)
}
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 set-last-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-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>
</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 }: { refreshToken: number }) {
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)
}
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 }: { refreshToken: number }) {
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)
}
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>
: <span className="r set-status"><span className="set-dot ok" />{quota.planLabel ?? 'Connected'}</span>}
</div>
}
function PlansPane({ period, refreshToken, onNavigate }: { period: Period; refreshToken: number; onNavigate?: (section: Section) => 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)
}
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 telemetry" detail="codeburn does not collect or send telemetry." icon={<><path d="M2 12s3.5-7 10-7 10 7 10 7" /><line x1="3" y1="3" x2="21" y2="21" /></>} />
<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" />} />
</div></section>
}
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} />
}