diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..afccd54 --- /dev/null +++ b/app/README.md @@ -0,0 +1,66 @@ +# CodeBurn Desktop + +Electron desktop shell for CodeBurn's local-first usage views. M1 runs as a developer app and reads data by spawning the installed `codeburn` CLI; it does not run a daemon or HTTP server. + +## Development + +```sh +npm --prefix app install +npm --prefix app run dev +``` + +Validation: + +```sh +npm --prefix app run test +npm --prefix app run typecheck +``` + +## CLI Dependency + +The app depends on a working `codeburn` CLI on the local machine. Electron resolves and spawns the CLI from the main process, then sends decoded JSON through the secure preload bridge into the renderer. + +This follows the menubar pattern: + +- `contextIsolation: true`, `nodeIntegration: false`, and `sandbox: true`. +- Renderer code calls `window.codeburn` only through `app/renderer/lib/ipc.ts`. +- Main process handlers return JSON envelopes so structured CLI errors survive IPC. +- Missing CLI, bad JSON, timeout, and nonzero exits are surfaced as honest UI states. +- The renderer never imports CodeBurn engine code from `src/`; the data contract is spawn CLI, decode JSON, poll. + +## Data Contract + +Current bridge calls: + +- Overview: `codeburn status --format menubar-json --period [--provider ]` +- Plans: `codeburn status --format json --period ` +- Models: `codeburn models --format json --period [--provider ] [--by-task]` +- Optimize: `codeburn yield --format json --period ` +- Spend flow: `codeburn spend --format flow-json --period [--provider ]` +- Devices: `codeburn devices --format json --period ` +- Device scan: `codeburn devices scan --format json` +- Share status: `codeburn share status --format json` +- Identity: `codeburn identity --format json` + +Supported M1 periods are `today`, `week`, `30days`, `month`, and `all`. Provider filtering is passed through where the CLI command supports it. + +## Sections + +- Overview: daily spend, spend stats, waste summary, and expensive sessions from `menubar-json`. +- Spend: project/activity/tool/MCP/subagent lenses plus model-to-project flow. +- Optimize: waste findings from `menubar-json` and reverted/abandoned yield data. +- Models: model and task tables from `models --format json`. +- Plans: plan pacing from `status --format json`. +- Settings: device identity, nearby scan results, paired-device usage, and M2 visual affordances. + +## M2 Backlog + +- Deliver a self-contained app that bundles the CodeBurn engine and auto-updates itself with Electron `autoUpdater`. +- Ship end-user installs via `.dmg` and `install.sh`; end users should not need npm. +- Keep npm as a separate CLI-user channel at the same version as the desktop app. +- Add `electron-builder` packaging plus macOS code signing and notarization. +- Add a `codeburn desktop` launcher subcommand. +- Implement in-app pairing, approve, pull, and visibility mutations currently shown as M2 affordances. +- Build the Models Compare sheet. +- Add light theme support. +- Expand `codeburn optimize --format json` with evidence and fix commands so Optimize can show richer actionable fixes. diff --git a/app/electron/main.ts b/app/electron/main.ts index e97497b..84f9680 100644 --- a/app/electron/main.ts +++ b/app/electron/main.ts @@ -7,9 +7,6 @@ import { CliError, resolveCodeburnPath, spawnCli } from './cli' // `kind` survives contextBridge serialization. preload.ts unwraps it. export type Envelope = { ok: true; value: T } | { ok: false; error: { kind: string; message: string } } -const REFRESH_CHANNEL = 'codeburn:refresh' -const REFRESH_INTERVAL_MS = 30_000 - function providerArgs(provider: string | undefined): string[] { return provider && provider !== 'all' ? ['--provider', provider] : [] } @@ -95,12 +92,6 @@ function createWindow(): BrowserWindow { void win.loadFile(path.join(__dirname, '..', 'renderer', 'index.html')) } - // 30s refresh tick — the renderer's usePolled hooks revalidate on it. - const timer = setInterval(() => { - if (!win.isDestroyed()) win.webContents.send(REFRESH_CHANNEL) - }, REFRESH_INTERVAL_MS) - win.on('closed', () => clearInterval(timer)) - return win } diff --git a/app/electron/preload.ts b/app/electron/preload.ts index ff51902..bf7f850 100644 --- a/app/electron/preload.ts +++ b/app/electron/preload.ts @@ -28,13 +28,4 @@ const bridge = { cliStatus: () => invoke('codeburn:cliStatus'), } -const events = { - onRefresh: (cb: () => void): (() => void) => { - const listener = () => cb() - ipcRenderer.on('codeburn:refresh', listener) - return () => ipcRenderer.removeListener('codeburn:refresh', listener) - }, -} - contextBridge.exposeInMainWorld('codeburn', bridge) -contextBridge.exposeInMainWorld('codeburnEvents', events) diff --git a/app/renderer/App.test.tsx b/app/renderer/App.test.tsx new file mode 100644 index 0000000..cb7cfef --- /dev/null +++ b/app/renderer/App.test.tsx @@ -0,0 +1,115 @@ +// @vitest-environment jsdom +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { App } from './App' +import type { MenubarPayload, SpendFlow } from './lib/types' + +const mocks = vi.hoisted(() => ({ + getOverview: vi.fn<(period: string, provider: string) => Promise>(), + getSpendFlow: vi.fn<(period: string, provider: string) => Promise>(), + getModels: vi.fn(), + getPlans: vi.fn(), + getYield: vi.fn(), + getDevices: vi.fn(), + getDevicesScan: vi.fn(), + getIdentity: vi.fn(), + cliStatus: vi.fn(), +})) + +vi.mock('./lib/ipc', async orig => { + const actual = await orig() + return { ...actual, codeburn: mocks } +}) + +function dateKey(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + +function overviewPayload(): MenubarPayload { + const now = new Date() + return { + generated: now.toISOString(), + current: { + label: 'Last 30 days', + cost: 12.34, + calls: 12, + sessions: 2, + oneShotRate: null, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + cacheHitPercent: 0, + codexCredits: 0, + topActivities: [], + topModels: [], + localModelSavings: { totalUSD: 0, calls: 0, byModel: [], byProvider: [] }, + providers: {}, + topProjects: [], + modelEfficiency: [], + topSessions: [], + retryTax: { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] }, + routingWaste: { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] }, + tools: [], + skills: [], + subagents: [], + mcpServers: [], + }, + optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] }, + history: { + daily: [ + { + date: dateKey(now), + cost: 12.34, + savingsUSD: 0, + calls: 12, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + topModels: [], + }, + ], + }, + } +} + +describe('App shortcuts', () => { + beforeEach(() => { + for (const mock of Object.values(mocks)) mock.mockReset() + mocks.getOverview.mockResolvedValue(overviewPayload()) + mocks.getSpendFlow.mockResolvedValue({ period: { label: 'Last 30 days', start: '', end: '' }, models: [], projects: [], links: [] }) + }) + + it('switches sections with command-number shortcuts', async () => { + render() + + expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument() + + fireEvent.keyDown(document, { key: '2', metaKey: true }) + + expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument() + }) + + it('re-polls visible section data when period or provider changes', async () => { + render() + + fireEvent.keyDown(document, { key: '2', metaKey: true }) + expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument() + + fireEvent.click(screen.getByText('Today')) + + await waitFor(() => { + expect(mocks.getOverview).toHaveBeenCalledWith('today', 'all') + expect(mocks.getSpendFlow).toHaveBeenCalledWith('today', 'all') + }) + + fireEvent.click(screen.getByText('All providers')) + + await waitFor(() => { + expect(mocks.getOverview).toHaveBeenCalledWith('today', 'claude') + expect(mocks.getSpendFlow).toHaveBeenCalledWith('today', 'claude') + }) + }) +}) diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx index 9e91658..a513f9b 100644 --- a/app/renderer/App.tsx +++ b/app/renderer/App.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { Hint } from './components/Hint' import { Panel } from './components/Panel' @@ -6,13 +6,14 @@ import { Sidebar, type Section } from './components/Sidebar' import { TopBar } from './components/TopBar' import { Window } from './components/Window' import { usePolled } from './hooks/usePolled' +import { formatUsd } from './lib/format' import { codeburn } from './lib/ipc' -import { Overview } from './sections/Overview' -import { Optimize } from './sections/Optimize' +import { OverviewContent } from './sections/Overview' +import { OptimizeContent } from './sections/Optimize' import { Models } from './sections/Models' import { Plans } from './sections/Plans' import { Settings } from './sections/Settings' -import { Spend } from './sections/Spend' +import { SpendContent } from './sections/Spend' import type { MenubarPayload, Period } from './lib/types' const SECTION_TITLES: Record = { @@ -33,40 +34,89 @@ const PERIOD_LABELS: Record = { } const STANDARD_PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all'] +const PROVIDER_OPTIONS = ['all', 'claude', 'codex', 'cursor', 'grok'] as const +const PROVIDER_LABELS: Record<(typeof PROVIDER_OPTIONS)[number], string> = { + all: 'All providers', + claude: 'Claude', + codex: 'Codex', + cursor: 'Cursor', + grok: 'Grok', +} function isPeriod(value: string): value is Period { return (STANDARD_PERIODS as string[]).includes(value) } -function fmtUsd(n: number): string { - return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` +function refreshedLabel(lastSuccessAt: number | null, loading: boolean, now: number): string { + if (loading && lastSuccessAt === null) return 'refreshing…' + if (lastSuccessAt === null) return 'not refreshed yet' + const seconds = Math.max(0, Math.floor((now - lastSuccessAt) / 1000)) + if (seconds < 1) return 'refreshed just now' + if (seconds < 60) return `refreshed ${seconds}s ago` + const minutes = Math.floor(seconds / 60) + return `refreshed ${minutes}m ago` } export function App() { const [section, setSection] = useState
('overview') const [period, setPeriod] = useState('30days') - const provider = 'all' + const [provider, setProvider] = useState<(typeof PROVIDER_OPTIONS)[number]>('all') + const [refreshToken, setRefreshToken] = useState(0) + const [now, setNow] = useState(() => Date.now()) - // Polled at the app level: proves the spawn→IPC→render path and feeds the - // sidebar status line. Section-specific fetching lands in T2–T7. const overview = usePolled(() => codeburn.getOverview(period, provider), [period, provider]) + const refreshOverview = overview.refresh + + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), 1000) + return () => window.clearInterval(id) + }, []) + + const refreshVisible = useCallback(() => { + refreshOverview() + setRefreshToken(token => token + 1) + }, [refreshOverview]) + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (!event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return + const key = event.key.toLowerCase() + if (key === '1') setSection('overview') + else if (key === '2') setSection('spend') + else if (key === '3') setSection('optimize') + else if (key === '4') setSection('models') + else if (key === '5') setSection('plans') + else if (key === ',') setSection('settings') + else if (key === 'r') refreshVisible() + else return + event.preventDefault() + } + + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [refreshVisible]) const onPeriodChange = (value: string) => { - // 6M / Custom (date ranges) are not wired until T8; ignore for now so the + // 6M / Custom (date ranges) are M2; ignore for now so the // highlight never lies about what was fetched. if (isPeriod(value)) setPeriod(value) } - const scope = `${PERIOD_LABELS[period]} · All providers` + const onProviderClick = () => { + setProvider(current => PROVIDER_OPTIONS[(PROVIDER_OPTIONS.indexOf(current) + 1) % PROVIDER_OPTIONS.length]) + } + + const providerLabel = PROVIDER_LABELS[provider] + const scope = `${PERIOD_LABELS[period]} · ${providerLabel}` return ( } />
{section === 'plans' ? ( - + ) : section === 'settings' ? ( - + ) : ( <>
{section === 'overview' ? ( - + ) : section === 'spend' ? ( - + ) : section === 'optimize' ? ( - + ) : section === 'models' ? ( - + ) : ( )} @@ -97,7 +148,7 @@ export function App() { { k: '⌘K', label: 'Command' }, { k: '⌘E', label: 'Export view' }, ]} - right={overview.loading ? 'refreshing…' : 'auto-refresh 30s'} + right={refreshedLabel(overview.lastSuccessAt, overview.loading, now)} /> )}
@@ -109,7 +160,7 @@ function StatusLine({ polled }: { polled: ReturnType - {polled.data.current.label} {fmtUsd(polled.data.current.cost)} + {polled.data.current.label} {formatUsd(polled.data.current.cost)} ) } diff --git a/app/renderer/components/CliErrorPanel.tsx b/app/renderer/components/CliErrorPanel.tsx new file mode 100644 index 0000000..b2525ca --- /dev/null +++ b/app/renderer/components/CliErrorPanel.tsx @@ -0,0 +1,59 @@ +import { Panel } from './Panel' +import type { CliError } from '../lib/types' + +export function isPermissionCliError(error: CliError | null): boolean { + return error?.kind === 'nonzero' && /permission|full disk access|eacces/i.test(error.message) +} + +export function cliErrorDisplay(error: CliError): { title: string; message: string; tone: 'amber' | 'red' | 'muted' } { + if (error.kind === 'not-found') { + return { + title: 'Locate the codeburn CLI', + message: 'Install it with npm i -g codeburn, then reopen this window.', + tone: 'muted', + } + } + if (isPermissionCliError(error)) { + return { + title: 'Permission denied', + message: 'permission denied — grant Full Disk Access', + tone: 'amber', + } + } + return { title: "Couldn't read data", message: error.message, tone: 'red' } +} + +function colorForTone(tone: 'amber' | 'red' | 'muted'): string { + if (tone === 'amber') return 'var(--amber)' + if (tone === 'red') return 'var(--red)' + return 'var(--t3)' +} + +export function CliErrorText({ error }: { error: CliError }) { + const display = cliErrorDisplay(error) + return

{display.message}

+} + +export function CliErrorPanel({ error, subject = 'usage' }: { error: CliError; subject?: string }) { + const display = cliErrorDisplay(error) + if (error.kind === 'not-found') { + return ( + +

+ CodeBurn Desktop reads {subject} by running the{' '} + codeburn command, but it isn't + on your PATH yet. +

+

+ Install it with npm i -g codeburn, + then reopen this window. +

+
+ ) + } + return ( + + + + ) +} diff --git a/app/renderer/components/ListRow.tsx b/app/renderer/components/ListRow.tsx index c1b5cbc..42b9d50 100644 --- a/app/renderer/components/ListRow.tsx +++ b/app/renderer/components/ListRow.tsx @@ -1,18 +1,6 @@ import type { CSSProperties, ReactNode } from 'react' -/** - * Series-dot colour for a model name, per the wireframe palette - * (blue=Opus, purple=Sonnet, lavender=Haiku, cyan=GPT/Codex). Unknown or - * unmatched models fall back to a neutral slate rather than a fabricated series. - */ -export function seriesColorForModel(model?: string): string { - const m = (model ?? '').toLowerCase() - if (m.includes('opus')) return 'var(--blue)' - if (m.includes('sonnet')) return 'var(--purple)' - if (m.includes('haiku')) return 'var(--lav)' - if (m.includes('gpt') || m.includes('codex')) return 'var(--cyan)' - return 'var(--t3)' -} +export { seriesColorForModel } from '../lib/modelSeries' /** * A `.li` list row: optional rank `.no`, optional model series `.mdot`, a diff --git a/app/renderer/components/Sankey.tsx b/app/renderer/components/Sankey.tsx index 16bd8c3..2bfac69 100644 --- a/app/renderer/components/Sankey.tsx +++ b/app/renderer/components/Sankey.tsx @@ -1,4 +1,5 @@ -import { isOtherNode, seriesHexForModel } from './StackedBars' +import { formatUsd } from '../lib/format' +import { isOtherNode, seriesHexForModel } from '../lib/modelSeries' import type { SpendFlow, SpendFlowNode } from '../lib/types' type LayoutNode = SpendFlowNode & { @@ -98,12 +99,12 @@ export function Sankey({ flow }: { flow: SpendFlow }) { {models.map(node => ( - {node.displayLabel} · {fmtUsd(node.cost)} + {node.displayLabel} · {formatUsd(node.cost)} ))} {projects.map(node => ( - {node.displayLabel} · {fmtUsd(node.cost)} + {node.displayLabel} · {formatUsd(node.cost)} ))} @@ -143,10 +144,6 @@ function round(n: number): number { return Math.round(n * 10) / 10 } -function fmtUsd(n: number): string { - return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` -} - function modelDisplayLabel(raw: string): string { const value = raw.trim() const lower = value.toLowerCase() diff --git a/app/renderer/components/StackedBars.tsx b/app/renderer/components/StackedBars.tsx index fc3f16c..64501ee 100644 --- a/app/renderer/components/StackedBars.tsx +++ b/app/renderer/components/StackedBars.tsx @@ -1,56 +1,7 @@ +import { formatUsd } from '../lib/format' +import { SERIES_HEX, SERIES_LABELS, type SeriesKey, seriesClassForModel, seriesKeyForModel } from '../lib/modelSeries' import type { DailyHistoryEntry } from '../lib/types' -export const SERIES_HEX = { - opus: '#5B8CFF', - sonnet: '#8B7CF6', - haiku: '#B5A8FF', - gpt: '#4DD8E6', - other: '#5F6780', -} as const - -export type SeriesKey = keyof typeof SERIES_HEX - -const SERIES_LABELS: Record = { - opus: 'Opus 4.8', - sonnet: 'Sonnet 5', - haiku: 'Haiku 4.5', - gpt: 'GPT-5.5 Codex', - other: 'Other', -} - -export function seriesKeyForModel(model?: string): SeriesKey { - const m = (model ?? '').toLowerCase() - if (m.includes('opus')) return 'opus' - if (m.includes('sonnet')) return 'sonnet' - if (m.includes('haiku')) return 'haiku' - if (m.includes('gpt') || m.includes('codex')) return 'gpt' - return 'other' -} - -export function seriesClassForModel(model?: string): string { - switch (seriesKeyForModel(model)) { - case 'opus': - return 's-opus' - case 'sonnet': - return 's-son' - case 'haiku': - return 's-hai' - case 'gpt': - return 's-gpt' - case 'other': - return 's-other' - } -} - -export function seriesHexForModel(model?: string): string { - return SERIES_HEX[seriesKeyForModel(model)] -} - -export function isOtherNode(idOrLabel?: string): boolean { - const value = (idOrLabel ?? '').trim().toLowerCase() - return value === '__other__' || value === 'other' || value === 'others' -} - export function StackedBars({ daily }: { daily: DailyHistoryEntry[] }) { const presentSeries = new Set() const maxTotal = Math.max( @@ -68,7 +19,7 @@ export function StackedBars({ daily }: { daily: DailyHistoryEntry[] }) { <>
{daily.map(day => ( -
+
{day.topModels.map(model => { const pct = Math.max(2, (Math.max(0, model.cost) / maxTotal) * 100) return ( @@ -76,7 +27,7 @@ export function StackedBars({ daily }: { daily: DailyHistoryEntry[] }) { key={`${day.date}-${model.name}`} className={`s ${seriesClassForModel(model.name)}`} style={{ height: `${pct}%` }} - title={`${model.name} · ${fmtUsd(model.cost)}`} + title={`${model.name} · ${formatUsd(model.cost)}`} /> ) })} @@ -94,7 +45,3 @@ export function StackedBars({ daily }: { daily: DailyHistoryEntry[] }) { ) } - -function fmtUsd(n: number): string { - return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` -} diff --git a/app/renderer/hooks/usePolled.ts b/app/renderer/hooks/usePolled.ts index b6d32c9..14f8223 100644 --- a/app/renderer/hooks/usePolled.ts +++ b/app/renderer/hooks/usePolled.ts @@ -7,6 +7,8 @@ export type Polled = { data: T | null error: CliError | null loading: boolean + /** Wall-clock timestamp for the most recent successful fetch. */ + lastSuccessAt: number | null /** Re-run the fetcher immediately (period/provider change, manual refresh). */ refresh: () => void } @@ -20,6 +22,7 @@ export function usePolled(fetcher: () => Promise, deps: unknown[], interva const [data, setData] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) + const [lastSuccessAt, setLastSuccessAt] = useState(null) // Generation counter: every load() (mount, deps change, interval, refresh) // claims the next epoch; a fetch applies its result only while its epoch is // still current. This is what keeps a slow fetch from an older deps/period @@ -34,6 +37,7 @@ export function usePolled(fetcher: () => Promise, deps: unknown[], interva if (epochRef.current !== epoch) return setData(result) setError(null) + setLastSuccessAt(Date.now()) }) .catch(err => { if (epochRef.current !== epoch) return @@ -62,5 +66,5 @@ export function usePolled(fetcher: () => Promise, deps: unknown[], interva load() }, [load]) - return { data, error, loading, refresh } + return { data, error, loading, lastSuccessAt, refresh } } diff --git a/app/renderer/lib/format.ts b/app/renderer/lib/format.ts new file mode 100644 index 0000000..e944e50 --- /dev/null +++ b/app/renderer/lib/format.ts @@ -0,0 +1,3 @@ +export function formatUsd(n: number): string { + return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` +} diff --git a/app/renderer/lib/ipc.ts b/app/renderer/lib/ipc.ts index 18902c7..88dd35d 100644 --- a/app/renderer/lib/ipc.ts +++ b/app/renderer/lib/ipc.ts @@ -2,25 +2,16 @@ import type { CliError, CodeburnBridge } from './types' // The preload script (app/electron/preload.ts) exposes `window.codeburn` as the // typed CodeburnBridge (methods resolve to CLI JSON or reject with a plain -// CliError), and `window.codeburnEvents` for main→renderer push events. +// CliError). declare global { interface Window { codeburn: CodeburnBridge - codeburnEvents: { - /** Subscribe to the main-process 30s refresh tick. Returns an unsubscribe fn. */ - onRefresh(cb: () => void): () => void - } } } /** The typed bridge. Import this instead of touching `window` directly. */ export const codeburn: CodeburnBridge = window.codeburn -/** Subscribe to the main-process refresh tick. */ -export function onRefresh(cb: () => void): () => void { - return window.codeburnEvents.onRefresh(cb) -} - /** Coerce anything thrown across the IPC boundary into a CliError shape. */ export function normalizeCliError(err: unknown): CliError { if (err && typeof err === 'object' && 'kind' in err && typeof (err as CliError).kind === 'string') { diff --git a/app/renderer/lib/modelSeries.ts b/app/renderer/lib/modelSeries.ts new file mode 100644 index 0000000..ea728f9 --- /dev/null +++ b/app/renderer/lib/modelSeries.ts @@ -0,0 +1,59 @@ +export const SERIES_HEX = { + opus: '#5B8CFF', + sonnet: '#8B7CF6', + haiku: '#B5A8FF', + gpt: '#4DD8E6', + other: '#5F6780', +} as const + +export type SeriesKey = keyof typeof SERIES_HEX + +export const SERIES_LABELS: Record = { + opus: 'Opus 4.8', + sonnet: 'Sonnet 5', + haiku: 'Haiku 4.5', + gpt: 'GPT-5.5 Codex', + other: 'Other', +} + +const SERIES_CSS_VAR: Record = { + opus: 'var(--blue)', + sonnet: 'var(--purple)', + haiku: 'var(--lav)', + gpt: 'var(--cyan)', + other: 'var(--t3)', +} + +const SERIES_CLASS: Record = { + opus: 's-opus', + sonnet: 's-son', + haiku: 's-hai', + gpt: 's-gpt', + other: 's-other', +} + +export function seriesKeyForModel(model?: string): SeriesKey { + const m = (model ?? '').toLowerCase() + if (m.includes('opus')) return 'opus' + if (m.includes('sonnet')) return 'sonnet' + if (m.includes('haiku')) return 'haiku' + if (m.includes('gpt') || m.includes('codex')) return 'gpt' + return 'other' +} + +export function seriesColorForModel(model?: string): string { + return SERIES_CSS_VAR[seriesKeyForModel(model)] +} + +export function seriesClassForModel(model?: string): string { + return SERIES_CLASS[seriesKeyForModel(model)] +} + +export function seriesHexForModel(model?: string): string { + return SERIES_HEX[seriesKeyForModel(model)] +} + +export function isOtherNode(idOrLabel?: string): boolean { + const value = (idOrLabel ?? '').trim().toLowerCase() + return value === '__other__' || value === 'other' || value === 'others' +} diff --git a/app/renderer/sections/Models.tsx b/app/renderer/sections/Models.tsx index 01e0368..675afa2 100644 --- a/app/renderer/sections/Models.tsx +++ b/app/renderer/sections/Models.tsx @@ -1,9 +1,11 @@ import { useState } from 'react' +import { CliErrorPanel } from '../components/CliErrorPanel' import { seriesColorForModel } from '../components/ListRow' import { Panel } from '../components/Panel' import { SegTabs } from '../components/SegTabs' import { usePolled } from '../hooks/usePolled' +import { formatUsd } from '../lib/format' import { codeburn } from '../lib/ipc' import type { ModelReportRow, Period } from '../lib/types' @@ -14,10 +16,6 @@ const LENSES = [ { value: 'task', label: 'By task' }, ] -function fmtUsd(n: number): string { - return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` -} - function fmtInt(n: number): string { return n.toLocaleString('en-US') } @@ -35,37 +33,16 @@ function EmptyNote({ children }: { children: React.ReactNode }) { return

{children}

} -export function Models({ period, provider }: { period: Period; provider: string }) { +export function Models({ period, provider, refreshToken = 0 }: { period: Period; provider: string; refreshToken?: number }) { const [lens, setLens] = useState('model') const byTask = lens === 'task' const report = usePolled( () => codeburn.getModels(period, provider, byTask), - [period, provider, byTask], + [period, provider, byTask, refreshToken], ) if (!report.data) { - if (report.error?.kind === 'not-found') { - return ( - -

- CodeBurn Desktop reads your model usage by running the{' '} - codeburn command, but it isn't - on your PATH yet. -

-

- Install it with npm i -g codeburn, - then reopen this window. -

-
- ) - } - if (report.error) { - return ( - -

{report.error.message}

-
- ) - } + if (report.error) return return ( Scanning model usage… @@ -147,8 +124,8 @@ function ModelTableRow({ row, byTask }: { row: ModelReportRow; byTask: boolean } {tokenValue(row.inputTokens)} {tokenValue(row.outputTokens)} {tokenValue(row.cacheReadTokens)} - {unpriced ? '—' : fmtUsd(row.costUSD)} - 0 ? 'pos' : undefined}>{unpriced ? '—' : fmtUsd(row.savingsUSD)} + {unpriced ? '—' : formatUsd(row.costUSD)} + 0 ? 'pos' : undefined}>{unpriced ? '—' : formatUsd(row.savingsUSD)} ) } diff --git a/app/renderer/sections/Optimize.tsx b/app/renderer/sections/Optimize.tsx index 80764c1..46bc4c9 100644 --- a/app/renderer/sections/Optimize.tsx +++ b/app/renderer/sections/Optimize.tsx @@ -1,49 +1,38 @@ import { useState } from 'react' +import { CliErrorPanel } from '../components/CliErrorPanel' import { Panel } from '../components/Panel' import { SegTabs } from '../components/SegTabs' import { type Polled, usePolled } from '../hooks/usePolled' +import { formatUsd } from '../lib/format' import { codeburn } from '../lib/ipc' import type { MenubarPayload, Period, SessionYieldJson, YieldJsonReport } from '../lib/types' type OptimizeTab = 'waste' | 'reverts' | 'abandoned' | 'fixes' -function fmtUsd(n: number): string { - return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` -} - function EmptyNote({ children }: { children: React.ReactNode }) { return

{children}

} export function Optimize({ period, provider }: { period: Period; provider: string }) { const overview = usePolled(() => codeburn.getOverview(period, provider), [period, provider]) - const yieldReport = usePolled(() => codeburn.getYield(period), [period]) + return +} + +export function OptimizeContent({ + period, + overview, + refreshToken = 0, +}: { + period: Period + overview: Polled + refreshToken?: number +}) { + const yieldReport = usePolled(() => codeburn.getYield(period), [period, refreshToken]) const [tab, setTab] = useState('waste') if (!overview.data) { - if (overview.error?.kind === 'not-found') { - return ( - -

- CodeBurn Desktop reads your usage by running the{' '} - codeburn command, but it isn't - on your PATH yet. -

-

- Install it with npm i -g codeburn, - then reopen this window. -

-
- ) - } - if (overview.error) { - return ( - -

{overview.error.message}

-
- ) - } + if (overview.error) return return ( Scanning optimize findings… @@ -52,10 +41,10 @@ export function Optimize({ period, provider }: { period: Period; provider: strin } const yieldData = !yieldReport.loading && !yieldReport.error ? yieldReport.data : null - const revertedTotal = yieldData ? fmtUsd(yieldData.summary.reverted.costUSD) : '—' - const abandonedTotal = yieldData ? fmtUsd(yieldData.summary.abandoned.costUSD) : '—' + const revertedTotal = yieldData ? formatUsd(yieldData.summary.reverted.costUSD) : '—' + const abandonedTotal = yieldData ? formatUsd(yieldData.summary.abandoned.costUSD) : '—' const options = [ - { value: 'waste', label: `Waste ${fmtUsd(overview.data.optimize.savingsUSD)}` }, + { value: 'waste', label: `Waste ${formatUsd(overview.data.optimize.savingsUSD)}` }, { value: 'reverts', label: `Reverts ${revertedTotal}` }, { value: 'abandoned', label: `Abandoned ${abandonedTotal}` }, { value: 'fixes', label: `Fixes ${overview.data.optimize.findingCount.toLocaleString('en-US')}` }, @@ -98,7 +87,7 @@ function WasteRows({ data }: { data: MenubarPayload }) { {finding.title} {finding.impact} impact
- {fmtUsd(finding.savingsUSD)} + {formatUsd(finding.savingsUSD)}
))} @@ -131,7 +120,7 @@ function YieldRows({ {row.commitCount.toLocaleString('en-US')} {row.commitCount === 1 ? 'commit' : 'commits'} · {row.sessionId}
- {fmtUsd(row.costUSD)} + {formatUsd(row.costUSD)}
))} @@ -147,7 +136,7 @@ function FixesRows({ data }: { data: MenubarPayload }) { {String(count).padStart(2, '0')}
- {count.toLocaleString('en-US')} findings · {fmtUsd(data.optimize.savingsUSD)} potential + {count.toLocaleString('en-US')} findings · {formatUsd(data.optimize.savingsUSD)} potential
diff --git a/app/renderer/sections/Overview.tsx b/app/renderer/sections/Overview.tsx index f719633..e2574bc 100644 --- a/app/renderer/sections/Overview.tsx +++ b/app/renderer/sections/Overview.tsx @@ -1,49 +1,15 @@ import { CapsuleChart, fmtDay } from '../components/CapsuleChart' +import { CliErrorPanel } from '../components/CliErrorPanel' import { ListRow, seriesColorForModel } from '../components/ListRow' import { Panel } from '../components/Panel' import { Stat } from '../components/Stat' -import { usePolled } from '../hooks/usePolled' +import { type Polled, usePolled } from '../hooks/usePolled' +import { formatUsd } from '../lib/format' import { codeburn } from '../lib/ipc' +import { localDateKey, sliceDailyToPeriod } from '../lib/period' import type { DailyHistoryEntry, MenubarPayload, Period } from '../lib/types' -function fmtUsd(n: number): string { - return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` -} - -/** Local calendar date key "YYYY-MM-DD", matching the CLI's `dateKey` (src/day-aggregator.ts). */ -export function localDateKey(d: Date): string { - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` -} - -/** - * `history.daily` is NOT scoped to the selected period — the CLI backfills up to - * 365 days regardless of `period`, and zero-activity days are simply absent. So - * the chart (and any per-period rate) must slice it to the selected window here. - * Returns the inclusive lower-bound date key ("YYYY-MM-DD"), or null for `all`: - * today → today only week → last 7 calendar days - * 30days → last 30 calendar days month → 1st of the current month all → no lower bound - */ -export function periodWindowStart(period: Period, now: Date): string | null { - switch (period) { - case 'today': - return localDateKey(now) - case 'week': - return localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6)) - case '30days': - return localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 29)) - case 'month': - return localDateKey(new Date(now.getFullYear(), now.getMonth(), 1)) - case 'all': - return null - } -} - -/** `history.daily` entries within the selected period's date window (see above). */ -export function sliceDailyToPeriod(daily: DailyHistoryEntry[], period: Period, now: Date): DailyHistoryEntry[] { - const start = periodWindowStart(period, now) - const todayKey = localDateKey(now) - return daily.filter(d => (start === null || d.date >= start) && d.date <= todayKey) -} +export { localDateKey } from '../lib/period' /** * Length of the selected period in days, for normalizing period-scoped totals @@ -168,36 +134,21 @@ function EmptyNote({ children }: { children: React.ReactNode }) { } export function Overview({ period, provider }: { period: Period; provider: string }) { - const { data, error } = usePolled( + const overview = usePolled( () => codeburn.getOverview(period, provider), [period, provider], ) + return +} + +export function OverviewContent({ period, overview }: { period: Period; overview: Polled }) { + const { data, error } = overview + // Retain last-good data across a failed refresh; only fall to a state when we // have nothing to show. if (!data) { - if (error?.kind === 'not-found') { - return ( - -

- CodeBurn Desktop reads your usage by running the{' '} - codeburn command, but it isn't - on your PATH yet. -

-

- Install it with npm i -g codeburn, - then reopen this window. -

-
- ) - } - if (error) { - return ( - -

{error.message}

-
- ) - } + if (error) return return ( Scanning sessions… @@ -219,13 +170,13 @@ export function Overview({ period, provider }: { period: Period; provider: strin
= 0 ? '+' : ''}${Math.round(s.pacePct)}% vs ${s.prevMonthName} pace` @@ -237,17 +188,17 @@ export function Overview({ period, provider }: { period: Period; provider: strin label="Projected month" value={ <> - {fmtUsd(s.projected)} est + {formatUsd(s.projected)} est } - delta={s.restOfMonth > 0 ? `+${fmtUsd(s.restOfMonth)} rest of month` : undefined} + delta={s.restOfMonth > 0 ? `+${formatUsd(s.restOfMonth)} rest of month` : undefined} tone="info" /> - {fmtUsd(s.weeklyWaste)} /wk + {formatUsd(s.weeklyWaste)} /wk } delta={s.findingCount > 0 ? `${s.findingCount} fixes ready` : undefined} @@ -271,7 +222,7 @@ export function Overview({ period, provider }: { period: Period; provider: strin dotColor={seriesColorForModel(model)} title={session.project} sub={sub} - value={fmtUsd(session.cost)} + value={formatUsd(session.cost)} /> ) }) diff --git a/app/renderer/sections/Plans.test.tsx b/app/renderer/sections/Plans.test.tsx index a875abb..cc225f4 100644 --- a/app/renderer/sections/Plans.test.tsx +++ b/app/renderer/sections/Plans.test.tsx @@ -172,4 +172,13 @@ describe('Plans', () => { expect(await screen.findByText('Locate the codeburn CLI')).toBeInTheDocument() }) + + it('renders permission-denied CLI failures as the amber Full Disk Access state', async () => { + getPlans.mockRejectedValue({ kind: 'nonzero', message: 'Cursor permission denied: grant Full Disk Access' }) + + render() + + expect(await screen.findByText('Permission denied')).toBeInTheDocument() + expect(screen.getByText('permission denied — grant Full Disk Access')).toHaveStyle({ color: 'var(--amber)' }) + }) }) diff --git a/app/renderer/sections/Plans.tsx b/app/renderer/sections/Plans.tsx index 227f29d..ca6e731 100644 --- a/app/renderer/sections/Plans.tsx +++ b/app/renderer/sections/Plans.tsx @@ -1,5 +1,7 @@ +import { CliErrorPanel } from '../components/CliErrorPanel' import { Panel } from '../components/Panel' import { usePolled } from '../hooks/usePolled' +import { formatUsd } from '../lib/format' import { codeburn } from '../lib/ipc' import type { JsonPlanSummary, Period, PlanId, PlanProvider, StatusJson } from '../lib/types' @@ -17,10 +19,6 @@ const PLAN_NAMES: Record = { none: 'API usage', } -function fmtUsd(n: number): string { - return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` -} - function fmtPct(n: number): string { return Number.isInteger(n) ? `${n}%` : `${n.toFixed(1)}%` } @@ -78,12 +76,8 @@ function planSummaries(status: StatusJson): JsonPlanSummary[] { return status.plan ? [status.plan] : [] } -function isPermissionError(message: string): boolean { - return /permission|full disk access|eacces/i.test(message) -} - -export function Plans({ period }: { period: Period }) { - const report = usePolled(() => codeburn.getPlans(period), [period]) +export function Plans({ period, refreshToken = 0 }: { period: Period; refreshToken?: number }) { + const report = usePolled(() => codeburn.getPlans(period), [period, refreshToken]) const plans = report.data ? planSummaries(report.data) : [] const cycle = cycleLabels(plans[0]) @@ -105,31 +99,7 @@ export function Plans({ period }: { period: Period }) { function renderBody(data: StatusJson | null, error: ReturnType>['error'], plans: JsonPlanSummary[]) { if (!data) { - if (error?.kind === 'not-found') { - return ( - -

- CodeBurn Desktop reads plan pacing by running the{' '} - codeburn command, but it - isn't on your PATH yet. -

-

- Install it with npm i -g codeburn, - then reopen this window. -

-
- ) - } - if (error) { - const permission = error.kind === 'nonzero' && isPermissionError(error.message) - return ( - -

- {permission ? 'Grant Full Disk Access, then refresh plan pacing.' : error.message} -

-
- ) - } + if (error) return return (

Scanning plan usage…

@@ -157,9 +127,9 @@ function PlanPanel({ plan }: { plan: JsonPlanSummary }) { const trackClass = hasBudget ? (over ? 'over' : undefined) : 'mut' const overage = Math.max(0, plan.spent - plan.budget) const right = hasBudget - ? `${fmtUsd(plan.spent)} · ${fmtPct(plan.percentUsed)}${overage > 0 ? ` · ${fmtUsd(overage)} over` : ''}` - : `${fmtUsd(plan.spent)} this cycle` - const detail = hasBudget ? `${fmtUsd(plan.budget)} / month · ${plan.provider}` : `${plan.provider} · pay as you go, no plan` + ? `${formatUsd(plan.spent)} · ${fmtPct(plan.percentUsed)}${overage > 0 ? ` · ${formatUsd(overage)} over` : ''}` + : `${formatUsd(plan.spent)} this cycle` + const detail = hasBudget ? `${formatUsd(plan.budget)} / month · ${plan.provider}` : `${plan.provider} · pay as you go, no plan` return ( @@ -182,14 +152,14 @@ function PaceLine({ plan }: { plan: JsonPlanSummary }) { if (plan.status === 'over' || plan.projectedMonthEnd > plan.budget) { return (
- On pace to exceed — projected {fmtUsd(plan.projectedMonthEnd)} by {endLabel} + On pace to exceed — projected {formatUsd(plan.projectedMonthEnd)} by {endLabel}
) } if (plan.status === 'near') { return (
- {fmtPct(plan.percentUsed)} of budget used — projected {fmtUsd(plan.projectedMonthEnd)} by {endLabel} + {fmtPct(plan.percentUsed)} of budget used — projected {formatUsd(plan.projectedMonthEnd)} by {endLabel}
) } diff --git a/app/renderer/sections/Settings.test.tsx b/app/renderer/sections/Settings.test.tsx index 31e9141..ca2a143 100644 --- a/app/renderer/sections/Settings.test.tsx +++ b/app/renderer/sections/Settings.test.tsx @@ -107,4 +107,63 @@ describe('Settings', () => { expect(container.querySelector('.tglon')).toBeInTheDocument() expect(getDevices).toHaveBeenCalledWith('month') }) + + it('excludes already-paired scanned devices from wants-to-pair results', async () => { + getIdentity.mockResolvedValue(identity) + getDevices.mockResolvedValue(devices) + getDevicesScan.mockResolvedValue({ + found: [ + ...scan.found, + { + name: 'Already Paired', + host: 'paired.local', + port: 9732, + fingerprint: 'AA:BB:CC:DD', + code: 'pair-2', + paired: true, + }, + ], + }) + + render() + + expect(await screen.findByText('Mac Studio')).toBeInTheDocument() + expect(screen.queryByText('Already Paired')).not.toBeInTheDocument() + }) + + it('renders empty settings states when no devices are discovered or paired', async () => { + getIdentity.mockResolvedValue(identity) + getDevicesScan.mockResolvedValue({ found: [] }) + getDevices.mockResolvedValue({ + perDevice: [devices.perDevice[0]], + combined: { ...devices.combined, deviceCount: 1, reachableCount: 1 }, + }) + + render() + + expect(await screen.findByText('Toruk MacBook Pro')).toBeInTheDocument() + expect(screen.getByText('No nearby devices found.')).toBeInTheDocument() + expect(screen.getByText('No paired devices yet.')).toBeInTheDocument() + }) + + it('renders not-found states for Settings CLI reads', async () => { + getIdentity.mockRejectedValue({ kind: 'not-found', message: 'codeburn not found' }) + getDevicesScan.mockRejectedValue({ kind: 'not-found', message: 'codeburn not found' }) + getDevices.mockRejectedValue({ kind: 'not-found', message: 'codeburn not found' }) + + render() + + expect(await screen.findAllByText('Locate the codeburn CLI')).toHaveLength(3) + }) + + it('uses amber for permission errors and red for other Settings errors', async () => { + getIdentity.mockRejectedValue({ kind: 'nonzero', message: 'Cursor permission denied: Full Disk Access required' }) + getDevicesScan.mockRejectedValue({ kind: 'nonzero', message: 'scan failed' }) + getDevices.mockResolvedValue(devices) + + render() + + expect(await screen.findByText('permission denied — grant Full Disk Access')).toHaveStyle({ color: 'var(--amber)' }) + expect(screen.getByText('scan failed')).toHaveStyle({ color: 'var(--red)' }) + }) }) diff --git a/app/renderer/sections/Settings.tsx b/app/renderer/sections/Settings.tsx index 955675f..7b5d797 100644 --- a/app/renderer/sections/Settings.tsx +++ b/app/renderer/sections/Settings.tsx @@ -1,15 +1,13 @@ import { Hint } from '../components/Hint' +import { CliErrorText, cliErrorDisplay } from '../components/CliErrorPanel' import { Panel } from '../components/Panel' import { usePolled } from '../hooks/usePolled' +import { formatUsd } from '../lib/format' import { codeburn } from '../lib/ipc' -import type { CombinedUsage, DeviceScanResult, Identity, Period } from '../lib/types' +import type { CliError, CombinedUsage, DeviceScanResult, Identity, Period } from '../lib/types' const RAIL_ITEMS = ['General', 'Providers', 'Model aliases', 'Plans', 'Devices', 'Export', 'Privacy & data'] as const -function fmtUsd(n: number): string { - return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` -} - function periodLabel(period: Period): string { if (period === 'today') return 'today' if (period === 'week') return 'last 7 days' @@ -24,21 +22,10 @@ function shortFingerprint(fingerprint: string): string { return `${parts[0]}:${parts[1]}:…:${parts[parts.length - 1]}` } -function isPermissionError(message: string): boolean { - return /permission|full disk access|eacces/i.test(message) -} - -function errorText(error: { kind: string; message: string } | null): string | null { - if (!error) return null - if (error.kind === 'not-found') return 'codeburn CLI not found' - if (error.kind === 'nonzero' && isPermissionError(error.message)) return 'permission denied — grant Full Disk Access' - return error.message -} - -export function Settings({ period }: { period: Period }) { - const identity = usePolled(() => codeburn.getIdentity(), []) - const scan = usePolled(() => codeburn.getDevicesScan(), []) - const devices = usePolled(() => codeburn.getDevices(period), [period]) +export function Settings({ period, refreshToken = 0 }: { period: Period; refreshToken?: number }) { + const identity = usePolled(() => codeburn.getIdentity(), [refreshToken]) + const scan = usePolled(() => codeburn.getDevicesScan(), [refreshToken]) + const devices = usePolled(() => codeburn.getDevices(period), [period, refreshToken]) return ( <> @@ -65,8 +52,6 @@ export function Settings({ period }: { period: Period }) { } function ThisDevicePanel({ identity }: { identity: ReturnType> }) { - const error = errorText(identity.error) - return ( {identity.data ? ( @@ -80,25 +65,24 @@ function ThisDevicePanel({ identity }: { identity: ReturnType
+ ) : identity.error ? ( + ) : ( -

- {error ?? 'Reading this device identity…'} -

+

Reading this device identity…

)}
) } function DiscoveredPanel({ scan }: { scan: ReturnType> }) { - const error = errorText(scan.error) const found = scan.data?.found.filter(device => !device.paired) ?? [] return ( - {!scan.data ? ( -

- {error ?? 'listening…'} -

+ {!scan.data && scan.error ? ( + + ) : !scan.data ? ( +

listening…

) : found.length === 0 ? (

No nearby devices found.

) : ( @@ -119,16 +103,15 @@ function DiscoveredPanel({ scan }: { scan: ReturnType>; period: Period }) { - const error = errorText(devices.error) const paired = devices.data?.perDevice.filter(device => !device.local) ?? [] const deviceScope = devices.data ? `· ${devices.data.combined.deviceCount} devices` : '· paired devices' return ( - {!devices.data ? ( -

- {error ?? 'Loading paired devices…'} -

+ {!devices.data && devices.error ? ( + + ) : !devices.data ? ( +

Loading paired devices…

) : paired.length === 0 ? (

No paired devices yet.

) : ( @@ -137,7 +120,7 @@ function PairedPanel({ devices, period }: { devices: ReturnType {device.name} - {device.sessions.toLocaleString('en-US')} sessions · {fmtUsd(device.cost)} {periodLabel(period)} + {device.sessions.toLocaleString('en-US')} sessions · {formatUsd(device.cost)} {periodLabel(period)} @@ -156,3 +139,11 @@ function PairedPanel({ devices, period }: { devices: ReturnType ) } + +function SettingsErrorText({ error }: { error: CliError }) { + if (error.kind === 'not-found') { + const display = cliErrorDisplay(error) + return

{display.title}

+ } + return +} diff --git a/app/renderer/sections/Spend.tsx b/app/renderer/sections/Spend.tsx index 6fb16f8..dd65f02 100644 --- a/app/renderer/sections/Spend.tsx +++ b/app/renderer/sections/Spend.tsx @@ -1,11 +1,13 @@ import { useState } from 'react' +import { CliErrorPanel, CliErrorText } from '../components/CliErrorPanel' import { ListRow } from '../components/ListRow' import { Panel } from '../components/Panel' import { Sankey } from '../components/Sankey' import { SegTabs } from '../components/SegTabs' import { StackedBars } from '../components/StackedBars' -import { usePolled } from '../hooks/usePolled' +import { type Polled, usePolled } from '../hooks/usePolled' +import { formatUsd } from '../lib/format' import { codeburn } from '../lib/ipc' import { sliceDailyToPeriod } from '../lib/period' import type { MenubarPayload, Period, SpendFlow } from '../lib/types' @@ -20,42 +22,31 @@ const LENSES = [ { value: 'subagents', label: 'Subagents' }, ] -function fmtUsd(n: number): string { - return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` -} - function EmptyNote({ children }: { children: React.ReactNode }) { return

{children}

} export function Spend({ period, provider }: { period: Period; provider: string }) { const overview = usePolled(() => codeburn.getOverview(period, provider), [period, provider]) - const flow = usePolled(() => codeburn.getSpendFlow(period, provider), [period, provider]) + return +} + +export function SpendContent({ + period, + provider, + overview, + refreshToken = 0, +}: { + period: Period + provider: string + overview: Polled + refreshToken?: number +}) { + const flow = usePolled(() => codeburn.getSpendFlow(period, provider), [period, provider, refreshToken]) const [lens, setLens] = useState('projects') if (!overview.data) { - if (overview.error?.kind === 'not-found') { - return ( - -

- CodeBurn Desktop reads your usage by running the{' '} - codeburn command, but it isn't - on your PATH yet. -

-

- Install it with npm i -g codeburn, - then reopen this window. -

-
- ) - } - if (overview.error) { - return ( - -

{overview.error.message}

-
- ) - } + if (overview.error) return return ( Scanning spend… @@ -101,7 +92,7 @@ function ProjectsLens({ no={String(i + 1).padStart(2, '0')} title={project.name} sub={`${project.sessions.toLocaleString('en-US')} ${project.sessions === 1 ? 'session' : 'sessions'}`} - value={fmtUsd(project.cost)} + value={formatUsd(project.cost)} /> )) ) : ( @@ -114,7 +105,7 @@ function ProjectsLens({ {flow.data && flow.data.links.length ? ( ) : flow.error ? ( -

{flow.error.message}

+ ) : ( {flow.loading ? 'Loading cost flow…' : 'No model-project flow in this range yet.'} )} @@ -130,13 +121,13 @@ function DetailLens({ data, lens }: { data: MenubarPayload; lens: Exclude ({ key: `skill-${row.name}`, title: row.name, sub: `${row.turns.toLocaleString('en-US')} turns · skill`, - value: fmtUsd(row.cost), + value: formatUsd(row.cost), })), ] return @@ -166,7 +157,7 @@ function DetailLens({ data, lens }: { data: MenubarPayload; lens: Exclude }