} />
+
{section === 'plans' ? (
@@ -291,3 +294,64 @@ function SectionPlaceholder({ title }: { title: string }) {
)
}
+
+/** App-wide daily-budget alert: reads today's usage from the overview payload and
+ * warns at >=80% / alerts at >=100% of the configured cap. Dismissible per day. */
+function DailyBudgetBanner({ payload, provider }: { payload: MenubarPayload | null; provider: string }) {
+ const [, bumpDismiss] = useState(0)
+ const budget = readDailyBudget()
+ if (!budget || !payload) return null
+
+ // Token totals in history.daily are zeroed under a specific-provider filter
+ // (only cost is per-provider), so a token cap can only be evaluated honestly on
+ // the all-providers view; otherwise we'd compare usage against a false zero.
+ if (budget.kind === 'tokens' && provider !== 'all') return null
+
+ const todayKey = localDateKey(new Date())
+ let dismissed: string | null = null
+ try { dismissed = globalThis.localStorage?.getItem('codeburn.dailyBudget.dismissed') ?? null } catch { /* storage can be unavailable */ }
+ if (dismissed === todayKey) return null
+
+ // Today's entry may be absent when there has been no activity yet: that's 0 used.
+ const entry = payload.history.daily.find(day => day.date === todayKey)
+ const used = budget.kind === 'usd'
+ ? entry?.cost ?? 0
+ : entry ? entry.inputTokens + entry.outputTokens : 0
+ const percent = (used / budget.value) * 100
+ if (percent < 80) return null
+
+ const exceeded = percent >= 100
+ const accent = exceeded ? 'var(--bad)' : 'var(--warn)'
+ const spent = budget.kind === 'usd' ? formatUsd(used) : formatCompact(used)
+ const cap = budget.kind === 'usd' ? formatUsd(budget.value) : formatCompact(budget.value)
+ const text = exceeded
+ ? `Daily budget exceeded: ${spent} of ${cap}`
+ : `Today's spend is at ${Math.floor(percent)}% of your daily budget`
+
+ const dismiss = () => {
+ try { globalThis.localStorage?.setItem('codeburn.dailyBudget.dismissed', todayKey) } catch { /* storage can be unavailable */ }
+ bumpDismiss(tick => tick + 1)
+ }
+
+ return (
+
+ {text}
+
+
+ )
+}
diff --git a/app/renderer/lib/budget.ts b/app/renderer/lib/budget.ts
new file mode 100644
index 0000000..1719249
--- /dev/null
+++ b/app/renderer/lib/budget.ts
@@ -0,0 +1,19 @@
+// Renderer-only daily budget setting (localStorage `codeburn.dailyBudget`).
+// A 'usd' cap is raw-USD (compared against history.daily cost, then displayed
+// via the currency-aware formatUsd); a 'tokens' cap counts input+output tokens.
+
+export type DailyBudget = { kind: 'usd' | 'tokens'; value: number }
+
+/** Parse the persisted budget, returning null when absent or malformed. */
+export function readDailyBudget(): DailyBudget | null {
+ let raw: string | null = null
+ try { raw = globalThis.localStorage?.getItem('codeburn.dailyBudget') ?? null } catch { return null }
+ if (!raw) return null
+ try {
+ const parsed = JSON.parse(raw) as Partial
+ if ((parsed.kind === 'usd' || parsed.kind === 'tokens') && typeof parsed.value === 'number' && Number.isFinite(parsed.value) && parsed.value > 0) {
+ return { kind: parsed.kind, value: parsed.value }
+ }
+ } catch { /* malformed JSON */ }
+ return null
+}
diff --git a/app/renderer/sections/Settings.test.tsx b/app/renderer/sections/Settings.test.tsx
index fb21f2f..604dd7f 100644
--- a/app/renderer/sections/Settings.test.tsx
+++ b/app/renderer/sections/Settings.test.tsx
@@ -114,6 +114,25 @@ describe('Settings', () => {
expect(screen.queryByText('Claude config')).not.toBeInTheDocument()
})
+ it('stores a positive daily budget from General', async () => {
+ const user = userEvent.setup()
+ render()
+ await user.click(screen.getByLabelText('Daily budget'))
+ await user.click(screen.getByRole('option', { name: 'USD amount' }))
+ await user.type(screen.getByLabelText('Daily budget amount'), '25')
+ expect(JSON.parse(localStorage.getItem('codeburn.dailyBudget')!)).toEqual({ kind: 'usd', value: 25 })
+ })
+
+ it('rejects a non-positive daily budget without persisting it', async () => {
+ const user = userEvent.setup()
+ render()
+ await user.click(screen.getByLabelText('Daily budget'))
+ await user.click(screen.getByRole('option', { name: 'Tokens' }))
+ await user.type(screen.getByLabelText('Daily budget amount'), '-5')
+ expect(screen.getByText('Enter a positive number.')).toBeInTheDocument()
+ expect(localStorage.getItem('codeburn.dailyBudget')).toBeFalsy()
+ })
+
it('lists providers from the real overview payload', async () => {
const user = userEvent.setup()
render()
diff --git a/app/renderer/sections/Settings.tsx b/app/renderer/sections/Settings.tsx
index 21db722..0110180 100644
--- a/app/renderer/sections/Settings.tsx
+++ b/app/renderer/sections/Settings.tsx
@@ -7,6 +7,7 @@ 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 type { ActionResult, AliasRow, ClaudeConfigSelector, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, ShareStatus, StatusJson } from '../lib/types'
@@ -120,6 +121,9 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource }
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('')
const [message, setMessage] = useState<{ text: string; error: boolean } | null>(null)
useEffect(() => {
@@ -127,6 +131,17 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource }
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)
@@ -165,6 +180,8 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource }
{ setDefaultPeriod(value); writeSetting('codeburn.defaultPeriod', value) }} width={92} />
+ { const kind = value as 'off' | 'usd' | 'tokens'; setBudgetKind(kind); persistBudget(kind, budgetInput) }} width={120} />{budgetKind !== 'off' && { setBudgetInput(event.target.value); persistBudget(budgetKind, event.target.value) }} style={{ width: 90 }} />}
+ {budgetError && {budgetError}
}
{message && {message.text}
}