mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-30 03:15:30 +00:00
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:
parent
dc52b3226b
commit
9e6ea306fe
21 changed files with 565 additions and 366 deletions
66
app/README.md
Normal file
66
app/README.md
Normal 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.
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
115
app/renderer/App.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 T2–T7.
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
59
app/renderer/components/CliErrorPanel.tsx
Normal file
59
app/renderer/components/CliErrorPanel.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 })}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
3
app/renderer/lib/format.ts
Normal file
3
app/renderer/lib/format.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function formatUsd(n: number): string {
|
||||
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
|
@ -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') {
|
||||
|
|
|
|||
59
app/renderer/lib/modelSeries.ts
Normal file
59
app/renderer/lib/modelSeries.ts
Normal 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'
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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'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)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'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." />
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue