Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/electron/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> =
{ channel: 'codeburn:getOverview', args: ['30days', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, 'claude-config:91dda17e8cf35193'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--claude-config-source', 'claude-config:91dda17e8cf35193'] },
{ channel: 'codeburn:getOverview', args: ['month', 'claude', { from: '2026-07-01', to: '2026-07-11' }, 'claude-desktop:980e1e488a654830'], argv: ['status', '--format', 'menubar-json', '--period', 'month', '--no-timeline', '--provider', 'claude', '--from', '2026-07-01', '--to', '2026-07-11', '--claude-config-source', 'claude-desktop:980e1e488a654830'] },
// Combined scope emits --scope combined; an explicit local scope is identical
// to the default (no flag). The CLI rejects --scope with --provider, so a
// provider passed alongside combined is dropped (the renderer forces 'all').
{ channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, undefined, undefined, 'combined'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--scope', 'combined'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'claude', undefined, undefined, undefined, 'combined'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--scope', 'combined'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'claude', undefined, undefined, undefined, 'local'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--provider', 'claude'] },
{ channel: 'codeburn:getModels', args: ['week', 'claude', true, { from: '2026-07-01', to: '2026-07-11' }], argv: ['models', '--format', 'json', '--period', 'week', '--provider', 'claude', '--by-task', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getYield', args: ['today', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['yield', '--format', 'json', '--period', 'today', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getSpendFlow', args: ['month', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['spend', '--format', 'flow-json', '--period', 'month', '--from', '2026-07-01', '--to', '2026-07-11'] },
Expand Down Expand Up @@ -212,6 +218,7 @@ describe('createBridgeHandlers (IPC input validation)', () => {
{ name: 'remove price override model that looks like a flag', channel: 'codeburn:removePriceOverride', args: ['--all'] },
{ name: 'claude config source that looks like a flag', channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, '-rf'] },
{ name: 'claude config source with shell metacharacters', channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, 'id; rm -rf'] },
{ name: 'unknown scope', channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, undefined, undefined, 'everything'] },
]

it.each(REJECTIONS)('rejects $name with a bad-args envelope and never spawns', async ({ channel, args }) => {
Expand Down
26 changes: 20 additions & 6 deletions app/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ function vConfigSource(source: string | null | undefined): string | null {
if (!/^[A-Za-z0-9][A-Za-z0-9:_-]*$/.test(source)) throw new CliError('bad-args', 'invalid claude config source')
return source
}
function vScope(scope: string | undefined): 'local' | 'combined' {
if (scope === 'combined') return 'combined'
if (scope === undefined || scope === 'local') return 'local'
throw new CliError('bad-args', 'invalid scope')
}
function vOutPath(outPath: string): string {
if (outPath.startsWith('-') || !path.isAbsolute(outPath)) throw new CliError('bad-args', 'export path must be absolute')
return outPath
Expand Down Expand Up @@ -269,19 +274,28 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
// The desktop never renders the granular timeline, so it always passes
// --no-timeline (skips buildGranularHistory on every poll). The Swift menubar
// omits the flag and keeps the timeline unchanged.
const buildOverviewArgs = (period: string, provider: string, range?: DateRange, configSource?: string | null): string[] => [
'status', '--format', 'menubar-json', '--period', vPeriod(period), '--no-timeline',
...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)), ...configSourceArgs(vConfigSource(configSource)),
]
//
// Combined scope aggregates paired-device usage: the CLI rejects --scope
// combined alongside --provider/--project/--exclude (paired devices report
// unfiltered usage), so the provider filter is dropped in that mode. The
// caller (renderer) forces provider='all' when combined, so nothing is lost.
const buildOverviewArgs = (period: string, provider: string, range?: DateRange, configSource?: string | null, scope?: string): string[] => {
const vScopeValue = vScope(scope)
return [
'status', '--format', 'menubar-json', '--period', vPeriod(period), '--no-timeline',
...(vScopeValue === 'combined' ? ['--scope', 'combined'] : providerArgs(vProvider(provider))),
...rangeArgs(vRange(range)), ...configSourceArgs(vConfigSource(configSource)),
]
}

// `background` (renderer prefetch only) drops this fetch to background priority
// so it yields the CLI's run slots to any interactive poll or click. Optional
// and defaulting to interactive, so an older preload that omits it is unchanged.
const getOverview: Handler = async (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => {
const getOverview: Handler = async (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => {
coldStartBegan ??= Date.now()
const priority: SpawnPriority | undefined = background ? 'background' : undefined
try {
const args = buildOverviewArgs(period, provider, range, configSource)
const args = buildOverviewArgs(period, provider, range, configSource, scope)
if (overviewWarmed) return { ok: true, value: await deps.spawnCli(args, priority ? { priority } : undefined) }
const value = await deps.spawnCli(args, {
timeoutMs: WARMUP_TIMEOUT_MS,
Expand Down
2 changes: 1 addition & 1 deletion app/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ async function invoke<T>(channel: string, ...args: unknown[]): Promise<T> {
// renderer-side where `window.codeburn` is declared as CodeburnBridge.
const bridge = {
getQuota: (force?: boolean) => invoke('codeburn:getQuota', force),
getOverview: (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => invoke('codeburn:getOverview', period, provider, range, configSource, background),
getOverview: (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => invoke('codeburn:getOverview', period, provider, range, configSource, background, scope),
getPlans: (period: string) => invoke('codeburn:getPlans', period),
getActReport: () => invoke('codeburn:getActReport'),
getModels: (period: string, provider: string, byTask: boolean, range?: DateRange) => invoke('codeburn:getModels', period, provider, byTask, range),
Expand Down
21 changes: 20 additions & 1 deletion app/renderer/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ vi.stubGlobal('localStorage', {
})

const mocks = vi.hoisted(() => ({
getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => Promise<MenubarPayload>>(),
getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => Promise<MenubarPayload>>(),
getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<SpendFlow>>(),
getOptimizeReport: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<OptimizeJsonReport>>(),
getModels: vi.fn(),
Expand Down Expand Up @@ -273,6 +273,25 @@ describe('App shortcuts', () => {
})
})

it('drives combined-scope overview fetches and persists the Scope setting', async () => {
render(<App />)
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all'))

fireEvent.keyDown(document, { key: ',', metaKey: true })
fireEvent.click(await screen.findByLabelText('Scope'))
fireEvent.click(await screen.findByRole('option', { name: 'Combined' }))

// Combined scope forces provider='all' and passes --scope combined (6th arg).
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, undefined, undefined, 'combined'))
expect(localStorage.getItem('codeburn.scope')).toBe('combined')
})

it('boots in combined scope from the persisted Scope setting', async () => {
localStorage.setItem('codeburn.scope', 'combined')
render(<App />)
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, undefined, undefined, 'combined'))
})

it('builds the provider picker from providerDetails so display-name providers round-trip their internal id', async () => {
// grok's display name is "Grok Build"; the picker must show the label but
// send the internal id `grok` as --provider (which assertProvider accepts).
Expand Down
72 changes: 56 additions & 16 deletions app/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { Compare } from './sections/Compare'
import { Plans } from './sections/Plans'
import { Settings, type SettingsPane } from './sections/Settings'
import { SpendContent } from './sections/Spend'
import type { DateRange, MenubarPayload, ModelReportRow, Period, TelemetryStatus } from './lib/types'
import type { DateRange, MenubarPayload, ModelReportRow, Period, Scope, TelemetryStatus } from './lib/types'

// Bucket raw dollar amounts before they leave the machine: telemetry carries
// coarse ranges, never exact spend.
Expand Down Expand Up @@ -130,8 +130,8 @@ const STANDARD_PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all', '
// Instant-switch memo key for an overview result. Shared by the overview poll
// and the provider prefetcher so the two never drift out of sync. Exported so
// the prefetch-storm test can assert warmed keys survive between polls.
export function overviewMemoKey(provider: string, period: Period, range: DateRange | null, configSource: string | null): string {
return `overview|${provider}|${period}|${range?.from ?? ''}-${range?.to ?? ''}|${configSource ?? ''}`
export function overviewMemoKey(provider: string, period: Period, range: DateRange | null, configSource: string | null, scope: Scope = 'local'): string {
return `overview|${provider}|${period}|${range?.from ?? ''}-${range?.to ?? ''}|${configSource ?? ''}|${scope}`
}

// Prefetch pacing: wait a short idle after the first paint, then warm one
Expand Down Expand Up @@ -172,6 +172,15 @@ function persistConfigSource(id: string | null): void {
} catch { /* storage can be unavailable */ }
}

/** Boot scope = the persisted dashboard Scope setting, else local. */
function initialScope(): Scope {
try { return globalThis.localStorage?.getItem('codeburn.scope') === 'combined' ? 'combined' : 'local' } catch { return 'local' }
}

function persistScope(scope: Scope): void {
try { globalThis.localStorage?.setItem('codeburn.scope', scope) } catch { /* storage can be unavailable */ }
}

function providerName(provider: string): string {
if (provider === 'all') return 'All providers'
return provider
Expand Down Expand Up @@ -218,20 +227,27 @@ function AppMain() {
const [detectedProviders, setDetectedProviders] = useState<Array<{ id: string; label: string }>>([])
const [customRange, setCustomRange] = useState<DateRange | null>(null)
const [claudeConfigSource, setClaudeConfigSource] = useState<string | null>(initialConfigSource)
const [scope, setScopeState] = useState<Scope>(initialScope)
const [refreshToken, setRefreshToken] = useState(0)
const [now, setNow] = useState(() => Date.now())
const [, setCurrencyTick] = useState(0)

// Preserve the 2/3-arg call shapes when no config is scoped so the CLI argv
// stays flag-free; only add --claude-config-source once a config is picked.
// Combined scope aggregates paired-device usage; the CLI rejects it alongside
// a provider/config filter, so onScopeChange forces provider='all' and clears
// the config scope before this poll runs. Passing scope='local' produces the
// same flag-free argv as before, so local users are unaffected.
const overview = usePolled<MenubarPayload>(
() => claudeConfigSource
() => scope === 'combined'
? codeburn.getOverview(period, 'all', customRange ?? undefined, undefined, undefined, 'combined')
: claudeConfigSource
? codeburn.getOverview(period, provider, customRange ?? undefined, claudeConfigSource)
: customRange
? codeburn.getOverview(period, provider, customRange)
: codeburn.getOverview(period, provider),
[period, provider, customRange?.from, customRange?.to, claudeConfigSource],
{ memoKey: overviewMemoKey(provider, period, customRange, claudeConfigSource) },
[period, provider, customRange?.from, customRange?.to, claudeConfigSource, scope],
{ memoKey: overviewMemoKey(provider, period, customRange, claudeConfigSource, scope) },
)
const refreshOverview = overview.refresh

Expand Down Expand Up @@ -273,7 +289,7 @@ function AppMain() {
// fails we still emit the snapshot, just without the model x category cross.
const snapshotDayRef = useRef<string | null>(null)
useEffect(() => {
if (!overview.data || provider !== 'all' || customRange || claudeConfigSource) return
if (!overview.data || provider !== 'all' || customRange || claudeConfigSource || scope !== 'local') return
const today = localDateKey(new Date())
if (snapshotDayRef.current === today) return
snapshotDayRef.current = today
Expand All @@ -285,7 +301,7 @@ function AppMain() {
} catch { /* degrade: emit the snapshot without per-model topCategory */ }
trackEvent('usage_snapshot', usageSnapshotProps(payload, modelCategories))
})()
}, [overview.data, provider, customRange, claudeConfigSource, period, trackEvent])
}, [overview.data, provider, customRange, claudeConfigSource, scope, period, trackEvent])

useEffect(() => {
let saved: string | null = null
Expand Down Expand Up @@ -360,7 +376,9 @@ function AppMain() {
overviewBusyRef.current = overview.loading
const warmedKeys = useRef<Set<string>>(new Set())
useEffect(() => {
if (!ready || overview.data == null || customRange || claudeConfigSource) return
// Combined scope has no provider picker to warm — it always shows unfiltered
// all-device usage — so the per-provider prefetch is local-scope only.
if (!ready || overview.data == null || customRange || claudeConfigSource || scope !== 'local') return
const targets = detectedProviders.map(entry => entry.id).filter(id => id !== provider)
if (targets.length === 0) return
let cancelled = false
Expand Down Expand Up @@ -391,7 +409,7 @@ function AppMain() {
// `overview.data == null` (a boolean) gates on first-resolution without
// re-running every poll; the data content itself is intentionally not a dep.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, period, provider, customRange, claudeConfigSource, detectedProviders, overview.data == null])
}, [ready, period, provider, customRange, claudeConfigSource, scope, detectedProviders, overview.data == null])

useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), 1000)
Expand Down Expand Up @@ -451,24 +469,42 @@ function AppMain() {

// A Claude config scopes Claude usage only, so a non-Claude provider filter
// would make the CLI reject the flag: reset it to 'all' first (a 'claude'
// filter is already compatible and is left alone).
// filter is already compatible and is left alone). Picking a config also
// implies a device-specific view, so drop combined scope back to local.
const onConfigSelect = (id: string) => {
const next = id || null
if (next && provider !== 'all' && provider !== 'claude') setProvider('all')
if (next && scope === 'combined') { setScopeState('local'); persistScope('local') }
setClaudeConfigSource(next)
persistConfigSource(next)
}

// Symmetric direction: picking a non-Claude provider while a config is
// scoped would hit the same CLI rejection, so drop the config scope.
// scoped would hit the same CLI rejection, so drop the config scope. A
// specific provider filter is a device-specific view, so it also drops
// combined scope back to local (combined reports unfiltered usage).
const onProviderSelect = (value: string) => {
if (value !== 'all' && scope === 'combined') { setScopeState('local'); persistScope('local') }
if (claudeConfigSource && value !== 'all' && value !== 'claude') {
setClaudeConfigSource(null)
persistConfigSource(null)
}
setProvider(value)
}

// Combined scope reports unfiltered, all-provider usage across paired devices,
// so switching to it resets the provider filter and Claude-config scope (which
// the CLI would otherwise reject), mirroring the menubar's setMenubarScope.
const onScopeChange = (value: string) => {
const next: Scope = value === 'combined' ? 'combined' : 'local'
if (next === 'combined') {
if (provider !== 'all') setProvider('all')
if (claudeConfigSource) { setClaudeConfigSource(null); persistConfigSource(null) }
}
setScopeState(next)
persistScope(next)
}

const claudeConfigs = overview.data?.claudeConfigs
const providerOptions = [
{ value: 'all', label: 'All providers' },
Expand All @@ -478,7 +514,11 @@ function AppMain() {
const activeConfigLabel = claudeConfigSource
? claudeConfigs?.options.find(option => option.id === claudeConfigSource)?.label ?? null
: null
const scope = `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · ${providerLabel}${activeConfigLabel ? ` · ${activeConfigLabel}` : ''}`
// Combined scope reports unfiltered all-device usage, so the caption reads
// "Combined" in place of the (forced-'all') provider label.
const scopeCaption = scope === 'combined'
? `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · Combined`
: `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · ${providerLabel}${activeConfigLabel ? ` · ${activeConfigLabel}` : ''}`

return (
<Window>
Expand All @@ -494,12 +534,12 @@ function AppMain() {
{section === 'plans' ? (
<Plans period={period} refreshToken={refreshToken} onNavigate={navigate} ready={ready} />
) : section === 'settings' ? (
<Settings period={period} refreshToken={refreshToken} onNavigate={navigate} initialPane={settingsPane} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} />
<Settings period={period} refreshToken={refreshToken} onNavigate={navigate} initialPane={settingsPane} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} scope={scope} onScopeChange={onScopeChange} />
) : (
<>
<TopBar
title={SECTION_TITLES[section]}
scope={scope}
scope={scopeCaption}
period={period}
onPeriodChange={onPeriodChange}
customRange={customRange}
Expand All @@ -514,7 +554,7 @@ function AppMain() {
/>
<div className={motionClass('body', 'section-fade')}>
{section === 'overview' ? (
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} ready={ready} />
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} ready={ready} scope={scope} />
) : section === 'sessions' ? (
<Sessions period={period} provider={provider} range={customRange} refreshToken={refreshToken} detectedProviders={detectedProviders} onProviderChange={onProviderSelect} ready={ready} />
) : section === 'pullRequests' ? (
Expand Down
Loading