feat(app): T8 integration + cleanup — single refresh, ⌘-nav, shared error/format/period/series, README

- One 30s refresh + manual ⌘R; footer shows real last-fetch time; getOverview fetched once (no double-poll).
- Keyboard nav: ⌘1–⌘5/⌘, switch sections, ⌘R refresh.
- Verified period/provider re-polls all sections.
- Shared CliErrorPanel: amber permission ("Full Disk Access") vs red error, used by every section.
- DRY: shared lib/format.ts (USD), lib/period.ts (Overview migrated to it), lib/modelSeries.ts (unified model→series).
- Settings review-minor tests (!paired filter, empty/not-found states) + App.test.tsx.
- app/README.md incl. the approved M2 delivery model (self-contained, bundles engine, Electron auto-updater).
- 76 tests, typecheck clean. Live GUI smoke (npm --prefix app run dev) still pending a display.

Implemented by Codex gpt-5.5 (high); committed by Fable (git blocked in Codex sandbox).
This commit is contained in:
iamtoruk 2026-07-10 19:35:41 -07:00
parent dc52b3226b
commit 9e6ea306fe
21 changed files with 565 additions and 366 deletions

66
app/README.md Normal file
View file

@ -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 <period> [--provider <provider>]`
- Plans: `codeburn status --format json --period <period>`
- Models: `codeburn models --format json --period <period> [--provider <provider>] [--by-task]`
- Optimize: `codeburn yield --format json --period <period>`
- Spend flow: `codeburn spend --format flow-json --period <period> [--provider <provider>]`
- Devices: `codeburn devices --format json --period <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.

View file

@ -7,9 +7,6 @@ import { CliError, resolveCodeburnPath, spawnCli } from './cli'
// `kind` survives contextBridge serialization. preload.ts unwraps it.
export type Envelope<T = unknown> = { 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
}

View file

@ -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)

115
app/renderer/App.test.tsx Normal file
View file

@ -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<MenubarPayload>>(),
getSpendFlow: vi.fn<(period: string, provider: string) => Promise<SpendFlow>>(),
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<typeof import('./lib/ipc')>()
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(<App />)
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(<App />)
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')
})
})
})

View file

@ -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<Section, string> = {
@ -33,40 +34,89 @@ const PERIOD_LABELS: Record<Period, string> = {
}
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<Section>('overview')
const [period, setPeriod] = useState<Period>('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 T2T7.
const overview = usePolled<MenubarPayload>(() => 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 (
<Window>
<Sidebar active={section} onNavigate={setSection} status={<StatusLine polled={overview} />} />
<div className="ct">
{section === 'plans' ? (
<Plans period={period} />
<Plans period={period} refreshToken={refreshToken} />
) : section === 'settings' ? (
<Settings period={period} />
<Settings period={period} refreshToken={refreshToken} />
) : (
<>
<TopBar
@ -74,17 +124,18 @@ export function App() {
scope={scope}
period={period}
onPeriodChange={onPeriodChange}
providerLabel="All providers"
providerLabel={providerLabel}
onProviderClick={onProviderClick}
/>
<div className="body">
{section === 'overview' ? (
<Overview period={period} provider={provider} />
<OverviewContent period={period} overview={overview} />
) : section === 'spend' ? (
<Spend period={period} provider={provider} />
<SpendContent period={period} provider={provider} overview={overview} refreshToken={refreshToken} />
) : section === 'optimize' ? (
<Optimize period={period} provider={provider} />
<OptimizeContent period={period} overview={overview} refreshToken={refreshToken} />
) : section === 'models' ? (
<Models period={period} provider={provider} />
<Models period={period} provider={provider} refreshToken={refreshToken} />
) : (
<SectionPlaceholder title={SECTION_TITLES[section]} />
)}
@ -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)}
/>
)}
</div>
@ -109,7 +160,7 @@ function StatusLine({ polled }: { polled: ReturnType<typeof usePolled<MenubarPay
if (polled.data) {
return (
<>
{polled.data.current.label} <b>{fmtUsd(polled.data.current.cost)}</b>
{polled.data.current.label} <b>{formatUsd(polled.data.current.cost)}</b>
</>
)
}

View file

@ -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 <p style={{ color: colorForTone(display.tone), margin: 0, fontSize: 12 }}>{display.message}</p>
}
export function CliErrorPanel({ error, subject = 'usage' }: { error: CliError; subject?: string }) {
const display = cliErrorDisplay(error)
if (error.kind === 'not-found') {
return (
<Panel title={display.title}>
<p style={{ color: 'var(--t2)', margin: '0 0 6px', fontSize: 12.5 }}>
CodeBurn Desktop reads {subject} by running the{' '}
<code style={{ fontFamily: 'var(--mono)', color: 'var(--lav)' }}>codeburn</code> command, but it isn&apos;t
on your PATH yet.
</p>
<p style={{ color: colorForTone(display.tone), margin: 0, fontSize: 11.5 }}>
Install it with <code style={{ fontFamily: 'var(--mono)', color: 'var(--lav)' }}>npm i -g codeburn</code>,
then reopen this window.
</p>
</Panel>
)
}
return (
<Panel title={display.title}>
<CliErrorText error={error} />
</Panel>
)
}

View file

@ -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

View file

@ -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 => (
<text key={node.id} x="118" y={round(node.y + node.h / 2 + 3)} textAnchor="end" fontSize="10" fill="#9BA3B7">
{node.displayLabel} · {fmtUsd(node.cost)}
{node.displayLabel} · {formatUsd(node.cost)}
</text>
))}
{projects.map(node => (
<text key={node.id} x="534" y={round(node.y + node.h / 2 + 3)} fontSize="10" fill="#9BA3B7">
{node.displayLabel} · {fmtUsd(node.cost)}
{node.displayLabel} · {formatUsd(node.cost)}
</text>
))}
</svg>
@ -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()

View file

@ -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<SeriesKey, string> = {
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<SeriesKey>()
const maxTotal = Math.max(
@ -68,7 +19,7 @@ export function StackedBars({ daily }: { daily: DailyHistoryEntry[] }) {
<>
<div className="sbars" aria-label="Daily spend by model">
{daily.map(day => (
<div className="c" key={day.date} data-date={day.date} title={`${day.date} · ${fmtUsd(day.cost)}`}>
<div className="c" key={day.date} data-date={day.date} title={`${day.date} · ${formatUsd(day.cost)}`}>
{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 })}`
}

View file

@ -7,6 +7,8 @@ export type Polled<T> = {
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<T>(fetcher: () => Promise<T>, deps: unknown[], interva
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<CliError | null>(null)
const [loading, setLoading] = useState(true)
const [lastSuccessAt, setLastSuccessAt] = useState<number | null>(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<T>(fetcher: () => Promise<T>, 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<T>(fetcher: () => Promise<T>, deps: unknown[], interva
load()
}, [load])
return { data, error, loading, refresh }
return { data, error, loading, lastSuccessAt, refresh }
}

View file

@ -0,0 +1,3 @@
export function formatUsd(n: number): string {
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}

View file

@ -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') {

View file

@ -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<SeriesKey, string> = {
opus: 'Opus 4.8',
sonnet: 'Sonnet 5',
haiku: 'Haiku 4.5',
gpt: 'GPT-5.5 Codex',
other: 'Other',
}
const SERIES_CSS_VAR: Record<SeriesKey, string> = {
opus: 'var(--blue)',
sonnet: 'var(--purple)',
haiku: 'var(--lav)',
gpt: 'var(--cyan)',
other: 'var(--t3)',
}
const SERIES_CLASS: Record<SeriesKey, string> = {
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'
}

View file

@ -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 <p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>{children}</p>
}
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<ModelsLens>('model')
const byTask = lens === 'task'
const report = usePolled<ModelReportRow[]>(
() => codeburn.getModels(period, provider, byTask),
[period, provider, byTask],
[period, provider, byTask, refreshToken],
)
if (!report.data) {
if (report.error?.kind === 'not-found') {
return (
<Panel title="Locate the codeburn CLI">
<p style={{ color: 'var(--t2)', margin: '0 0 6px', fontSize: 12.5 }}>
CodeBurn Desktop reads your model usage by running the{' '}
<code style={{ fontFamily: 'var(--mono)', color: 'var(--lav)' }}>codeburn</code> command, but it isn&apos;t
on your PATH yet.
</p>
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 11.5 }}>
Install it with <code style={{ fontFamily: 'var(--mono)', color: 'var(--lav)' }}>npm i -g codeburn</code>,
then reopen this window.
</p>
</Panel>
)
}
if (report.error) {
return (
<Panel title="Couldn't read models">
<p style={{ color: 'var(--red)', margin: 0, fontSize: 12 }}>{report.error.message}</p>
</Panel>
)
}
if (report.error) return <CliErrorPanel error={report.error} subject="model usage" />
return (
<Panel title="Models">
<EmptyNote>Scanning model usage</EmptyNote>
@ -147,8 +124,8 @@ function ModelTableRow({ row, byTask }: { row: ModelReportRow; byTask: boolean }
<td className={cellClass}>{tokenValue(row.inputTokens)}</td>
<td className={cellClass}>{tokenValue(row.outputTokens)}</td>
<td className={cellClass}>{tokenValue(row.cacheReadTokens)}</td>
<td className={cellClass}>{unpriced ? '—' : fmtUsd(row.costUSD)}</td>
<td className={unpriced ? 'dim' : row.savingsUSD > 0 ? 'pos' : undefined}>{unpriced ? '—' : fmtUsd(row.savingsUSD)}</td>
<td className={cellClass}>{unpriced ? '—' : formatUsd(row.costUSD)}</td>
<td className={unpriced ? 'dim' : row.savingsUSD > 0 ? 'pos' : undefined}>{unpriced ? '—' : formatUsd(row.savingsUSD)}</td>
</tr>
)
}

View file

@ -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 <p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>{children}</p>
}
export function Optimize({ period, provider }: { period: Period; provider: string }) {
const overview = usePolled<MenubarPayload>(() => codeburn.getOverview(period, provider), [period, provider])
const yieldReport = usePolled<YieldJsonReport>(() => codeburn.getYield(period), [period])
return <OptimizeContent period={period} overview={overview} />
}
export function OptimizeContent({
period,
overview,
refreshToken = 0,
}: {
period: Period
overview: Polled<MenubarPayload>
refreshToken?: number
}) {
const yieldReport = usePolled<YieldJsonReport>(() => codeburn.getYield(period), [period, refreshToken])
const [tab, setTab] = useState<OptimizeTab>('waste')
if (!overview.data) {
if (overview.error?.kind === 'not-found') {
return (
<Panel title="Locate the codeburn CLI">
<p style={{ color: 'var(--t2)', margin: '0 0 6px', fontSize: 12.5 }}>
CodeBurn Desktop reads your usage by running the{' '}
<code style={{ fontFamily: 'var(--mono)', color: 'var(--lav)' }}>codeburn</code> command, but it isn&apos;t
on your PATH yet.
</p>
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 11.5 }}>
Install it with <code style={{ fontFamily: 'var(--mono)', color: 'var(--lav)' }}>npm i -g codeburn</code>,
then reopen this window.
</p>
</Panel>
)
}
if (overview.error) {
return (
<Panel title="Couldn't read optimize">
<p style={{ color: 'var(--red)', margin: 0, fontSize: 12 }}>{overview.error.message}</p>
</Panel>
)
}
if (overview.error) return <CliErrorPanel error={overview.error} subject="optimize findings" />
return (
<Panel title="Optimize">
<EmptyNote>Scanning optimize findings</EmptyNote>
@ -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 }) {
<b>{finding.title}</b>
<span>{finding.impact} impact</span>
</div>
<span className="val ok">{fmtUsd(finding.savingsUSD)}</span>
<span className="val ok">{formatUsd(finding.savingsUSD)}</span>
</div>
))}
</>
@ -131,7 +120,7 @@ function YieldRows({
{row.commitCount.toLocaleString('en-US')} {row.commitCount === 1 ? 'commit' : 'commits'} · {row.sessionId}
</span>
</div>
<span className="val">{fmtUsd(row.costUSD)}</span>
<span className="val">{formatUsd(row.costUSD)}</span>
</div>
))}
</>
@ -147,7 +136,7 @@ function FixesRows({ data }: { data: MenubarPayload }) {
<span className="no">{String(count).padStart(2, '0')}</span>
<div className="lx">
<b>
{count.toLocaleString('en-US')} findings · {fmtUsd(data.optimize.savingsUSD)} potential
{count.toLocaleString('en-US')} findings · {formatUsd(data.optimize.savingsUSD)} potential
</b>
</div>
</div>

View file

@ -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<MenubarPayload>(
const overview = usePolled<MenubarPayload>(
() => codeburn.getOverview(period, provider),
[period, provider],
)
return <OverviewContent period={period} overview={overview} />
}
export function OverviewContent({ period, overview }: { period: Period; overview: Polled<MenubarPayload> }) {
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 (
<Panel title="Locate the codeburn CLI">
<p style={{ color: 'var(--t2)', margin: '0 0 6px', fontSize: 12.5 }}>
CodeBurn Desktop reads your usage by running the{' '}
<code style={{ fontFamily: 'var(--mono)', color: 'var(--lav)' }}>codeburn</code> command, but it isn&apos;t
on your PATH yet.
</p>
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 11.5 }}>
Install it with <code style={{ fontFamily: 'var(--mono)', color: 'var(--lav)' }}>npm i -g codeburn</code>,
then reopen this window.
</p>
</Panel>
)
}
if (error) {
return (
<Panel title="Couldn't read usage">
<p style={{ color: 'var(--red)', margin: 0, fontSize: 12 }}>{error.message}</p>
</Panel>
)
}
if (error) return <CliErrorPanel error={error} subject="your usage" />
return (
<Panel title="Overview">
<EmptyNote>Scanning sessions</EmptyNote>
@ -219,13 +170,13 @@ export function Overview({ period, provider }: { period: Period; provider: strin
<div className="stats">
<Stat
label="Today"
value={fmtUsd(s.todayCost)}
value={formatUsd(s.todayCost)}
delta={s.todayEntry ? `${s.todayEntry.calls.toLocaleString('en-US')} calls` : undefined}
tone="info"
/>
<Stat
label="Month to date"
value={fmtUsd(s.mtd)}
value={formatUsd(s.mtd)}
delta={
s.pacePct !== null
? `${s.pacePct >= 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)} <small>est</small>
{formatUsd(s.projected)} <small>est</small>
</>
}
delta={s.restOfMonth > 0 ? `+${fmtUsd(s.restOfMonth)} rest of month` : undefined}
delta={s.restOfMonth > 0 ? `+${formatUsd(s.restOfMonth)} rest of month` : undefined}
tone="info"
/>
<Stat
label="Waste found"
value={
<>
{fmtUsd(s.weeklyWaste)} <small>/wk</small>
{formatUsd(s.weeklyWaste)} <small>/wk</small>
</>
}
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)}
/>
)
})

View file

@ -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(<Plans period="week" />)
expect(await screen.findByText('Permission denied')).toBeInTheDocument()
expect(screen.getByText('permission denied — grant Full Disk Access')).toHaveStyle({ color: 'var(--amber)' })
})
})

View file

@ -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<PlanId, string> = {
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<StatusJson>(() => codeburn.getPlans(period), [period])
export function Plans({ period, refreshToken = 0 }: { period: Period; refreshToken?: number }) {
const report = usePolled<StatusJson>(() => 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<typeof usePolled<StatusJson>>['error'], plans: JsonPlanSummary[]) {
if (!data) {
if (error?.kind === 'not-found') {
return (
<Panel title="Locate the codeburn CLI">
<p style={{ color: 'var(--t2)', margin: '0 0 6px', fontSize: 12.5 }}>
CodeBurn Desktop reads plan pacing by running the{' '}
<code style={{ fontFamily: 'var(--mono)', color: 'var(--lav)' }}>codeburn</code> command, but it
isn&apos;t on your PATH yet.
</p>
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 11.5 }}>
Install it with <code style={{ fontFamily: 'var(--mono)', color: 'var(--lav)' }}>npm i -g codeburn</code>,
then reopen this window.
</p>
</Panel>
)
}
if (error) {
const permission = error.kind === 'nonzero' && isPermissionError(error.message)
return (
<Panel title={permission ? 'Permission denied' : "Couldn't read plans"}>
<p style={{ color: permission ? 'var(--amber)' : 'var(--red)', margin: 0, fontSize: 12 }}>
{permission ? 'Grant Full Disk Access, then refresh plan pacing.' : error.message}
</p>
</Panel>
)
}
if (error) return <CliErrorPanel error={error} subject="plan pacing" />
return (
<Panel title="Plans">
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>Scanning plan usage</p>
@ -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 (
<Panel>
@ -182,14 +152,14 @@ function PaceLine({ plan }: { plan: JsonPlanSummary }) {
if (plan.status === 'over' || plan.projectedMonthEnd > plan.budget) {
return (
<div className="pace hot">
On pace to exceed projected {fmtUsd(plan.projectedMonthEnd)} by {endLabel}
On pace to exceed projected {formatUsd(plan.projectedMonthEnd)} by {endLabel}
</div>
)
}
if (plan.status === 'near') {
return (
<div className="pace hot">
{fmtPct(plan.percentUsed)} of budget used projected {fmtUsd(plan.projectedMonthEnd)} by {endLabel}
{fmtPct(plan.percentUsed)} of budget used projected {formatUsd(plan.projectedMonthEnd)} by {endLabel}
</div>
)
}

View file

@ -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(<Settings period="month" />)
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(<Settings period="week" />)
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(<Settings period="week" />)
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(<Settings period="week" />)
expect(await screen.findByText('permission denied — grant Full Disk Access')).toHaveStyle({ color: 'var(--amber)' })
expect(screen.getByText('scan failed')).toHaveStyle({ color: 'var(--red)' })
})
})

View file

@ -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<Identity>(() => codeburn.getIdentity(), [])
const scan = usePolled<DeviceScanResult>(() => codeburn.getDevicesScan(), [])
const devices = usePolled<CombinedUsage>(() => codeburn.getDevices(period), [period])
export function Settings({ period, refreshToken = 0 }: { period: Period; refreshToken?: number }) {
const identity = usePolled<Identity>(() => codeburn.getIdentity(), [refreshToken])
const scan = usePolled<DeviceScanResult>(() => codeburn.getDevicesScan(), [refreshToken])
const devices = usePolled<CombinedUsage>(() => codeburn.getDevices(period), [period, refreshToken])
return (
<>
@ -65,8 +52,6 @@ export function Settings({ period }: { period: Period }) {
}
function ThisDevicePanel({ identity }: { identity: ReturnType<typeof usePolled<Identity>> }) {
const error = errorText(identity.error)
return (
<Panel title="This device">
{identity.data ? (
@ -80,25 +65,24 @@ function ThisDevicePanel({ identity }: { identity: ReturnType<typeof usePolled<I
Visibility: on
</span>
</div>
) : identity.error ? (
<SettingsErrorText error={identity.error} />
) : (
<p style={{ color: error ? 'var(--amber)' : 'var(--t3)', margin: 0, fontSize: 12 }}>
{error ?? 'Reading this device identity…'}
</p>
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>Reading this device identity</p>
)}
</Panel>
)
}
function DiscoveredPanel({ scan }: { scan: ReturnType<typeof usePolled<DeviceScanResult>> }) {
const error = errorText(scan.error)
const found = scan.data?.found.filter(device => !device.paired) ?? []
return (
<Panel title="Discovered nearby" right={scan.loading ? 'listening…' : undefined}>
{!scan.data ? (
<p style={{ color: error ? 'var(--amber)' : 'var(--t3)', margin: 0, fontSize: 12 }}>
{error ?? 'listening…'}
</p>
{!scan.data && scan.error ? (
<SettingsErrorText error={scan.error} />
) : !scan.data ? (
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>listening</p>
) : found.length === 0 ? (
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>No nearby devices found.</p>
) : (
@ -119,16 +103,15 @@ function DiscoveredPanel({ scan }: { scan: ReturnType<typeof usePolled<DeviceSca
}
function PairedPanel({ devices, period }: { devices: ReturnType<typeof usePolled<CombinedUsage>>; 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 (
<Panel title="Paired">
{!devices.data ? (
<p style={{ color: error ? 'var(--amber)' : 'var(--t3)', margin: 0, fontSize: 12 }}>
{error ?? 'Loading paired devices…'}
</p>
{!devices.data && devices.error ? (
<SettingsErrorText error={devices.error} />
) : !devices.data ? (
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>Loading paired devices</p>
) : paired.length === 0 ? (
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>No paired devices yet.</p>
) : (
@ -137,7 +120,7 @@ function PairedPanel({ devices, period }: { devices: ReturnType<typeof usePolled
<div className="lx">
<b>{device.name}</b>
<span>
{device.sessions.toLocaleString('en-US')} sessions · {fmtUsd(device.cost)} {periodLabel(period)}
{device.sessions.toLocaleString('en-US')} sessions · {formatUsd(device.cost)} {periodLabel(period)}
</span>
</div>
<span className="btn btn-s" aria-disabled="true">
@ -156,3 +139,11 @@ function PairedPanel({ devices, period }: { devices: ReturnType<typeof usePolled
</Panel>
)
}
function SettingsErrorText({ error }: { error: CliError }) {
if (error.kind === 'not-found') {
const display = cliErrorDisplay(error)
return <p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>{display.title}</p>
}
return <CliErrorText error={error} />
}

View file

@ -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 <p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>{children}</p>
}
export function Spend({ period, provider }: { period: Period; provider: string }) {
const overview = usePolled<MenubarPayload>(() => codeburn.getOverview(period, provider), [period, provider])
const flow = usePolled<SpendFlow>(() => codeburn.getSpendFlow(period, provider), [period, provider])
return <SpendContent period={period} provider={provider} overview={overview} />
}
export function SpendContent({
period,
provider,
overview,
refreshToken = 0,
}: {
period: Period
provider: string
overview: Polled<MenubarPayload>
refreshToken?: number
}) {
const flow = usePolled<SpendFlow>(() => codeburn.getSpendFlow(period, provider), [period, provider, refreshToken])
const [lens, setLens] = useState<Lens>('projects')
if (!overview.data) {
if (overview.error?.kind === 'not-found') {
return (
<Panel title="Locate the codeburn CLI">
<p style={{ color: 'var(--t2)', margin: '0 0 6px', fontSize: 12.5 }}>
CodeBurn Desktop reads your usage by running the{' '}
<code style={{ fontFamily: 'var(--mono)', color: 'var(--lav)' }}>codeburn</code> command, but it isn&apos;t
on your PATH yet.
</p>
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 11.5 }}>
Install it with <code style={{ fontFamily: 'var(--mono)', color: 'var(--lav)' }}>npm i -g codeburn</code>,
then reopen this window.
</p>
</Panel>
)
}
if (overview.error) {
return (
<Panel title="Couldn't read spend">
<p style={{ color: 'var(--red)', margin: 0, fontSize: 12 }}>{overview.error.message}</p>
</Panel>
)
}
if (overview.error) return <CliErrorPanel error={overview.error} subject="spend" />
return (
<Panel title="Spend">
<EmptyNote>Scanning spend</EmptyNote>
@ -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 ? (
<Sankey flow={flow.data} />
) : flow.error ? (
<p style={{ color: 'var(--red)', margin: 0, fontSize: 12 }}>{flow.error.message}</p>
<CliErrorText error={flow.error} />
) : (
<EmptyNote>{flow.loading ? 'Loading cost flow…' : 'No model-project flow in this range yet.'}</EmptyNote>
)}
@ -130,13 +121,13 @@ function DetailLens({ data, lens }: { data: MenubarPayload; lens: Exclude<Lens,
key: `activity-${row.name}`,
title: row.name,
sub: `${row.turns.toLocaleString('en-US')} turns`,
value: fmtUsd(row.cost),
value: formatUsd(row.cost),
})),
...data.current.skills.map(row => ({
key: `skill-${row.name}`,
title: row.name,
sub: `${row.turns.toLocaleString('en-US')} turns · skill`,
value: fmtUsd(row.cost),
value: formatUsd(row.cost),
})),
]
return <RowsPanel title="Activity" rows={rows} empty="No activity or skill spend in this range yet." />
@ -166,7 +157,7 @@ function DetailLens({ data, lens }: { data: MenubarPayload; lens: Exclude<Lens,
key: row.name,
title: row.name,
sub: `${row.calls.toLocaleString('en-US')} calls`,
value: fmtUsd(row.cost),
value: formatUsd(row.cost),
}))
return <RowsPanel title="Subagents" rows={rows} empty="No subagent spend in this range yet." />
}