diff --git a/app/electron/main.test.ts b/app/electron/main.test.ts
index 2fce9dc..1a84054 100644
--- a/app/electron/main.test.ts
+++ b/app/electron/main.test.ts
@@ -29,6 +29,7 @@ const CHANNELS = [
'codeburn:getYield',
'codeburn:getSpendFlow',
'codeburn:getDevices',
+ 'codeburn:getDevicesScan',
'codeburn:getShareStatus',
'codeburn:getIdentity',
'codeburn:cliStatus',
@@ -43,12 +44,13 @@ const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> =
{ channel: 'codeburn:getYield', args: ['today'], argv: ['yield', '--format', 'json', '--period', 'today'] },
{ channel: 'codeburn:getSpendFlow', args: ['month', 'openai'], argv: ['spend', '--format', 'flow-json', '--period', 'month', '--provider', 'openai'] },
{ channel: 'codeburn:getDevices', args: ['week'], argv: ['devices', '--format', 'json', '--period', 'week'] },
+ { channel: 'codeburn:getDevicesScan', args: [], argv: ['devices', 'scan', '--format', 'json'] },
{ channel: 'codeburn:getShareStatus', args: [], argv: ['share', 'status', '--format', 'json'] },
{ channel: 'codeburn:getIdentity', args: [], argv: ['identity', '--format', 'json'] },
]
describe('createBridgeHandlers (channel → argv for all channels)', () => {
- it('exposes exactly the nine codeburn:* channels', () => {
+ it('exposes exactly the ten codeburn:* channels', () => {
const handlers = createBridgeHandlers({ spawnCli: vi.fn(), resolveCodeburnPath: () => null })
expect(Object.keys(handlers).sort()).toEqual([...CHANNELS].sort())
})
diff --git a/app/electron/main.ts b/app/electron/main.ts
index 2409694..e97497b 100644
--- a/app/electron/main.ts
+++ b/app/electron/main.ts
@@ -53,6 +53,7 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, resolveCodeburnPat
'spend', '--format', 'flow-json', '--period', period, ...providerArgs(provider),
]),
'codeburn:getDevices': run((period: string) => ['devices', '--format', 'json', '--period', period]),
+ 'codeburn:getDevicesScan': run(() => ['devices', 'scan', '--format', 'json']),
'codeburn:getShareStatus': run(() => ['share', 'status', '--format', 'json']),
'codeburn:getIdentity': run(() => ['identity', '--format', 'json']),
'codeburn:cliStatus': async () => {
diff --git a/app/electron/preload.ts b/app/electron/preload.ts
index febe0d6..ff51902 100644
--- a/app/electron/preload.ts
+++ b/app/electron/preload.ts
@@ -22,6 +22,7 @@ const bridge = {
getYield: (period: string) => invoke('codeburn:getYield', period),
getSpendFlow: (period: string, provider: string) => invoke('codeburn:getSpendFlow', period, provider),
getDevices: (period: string) => invoke('codeburn:getDevices', period),
+ getDevicesScan: () => invoke('codeburn:getDevicesScan'),
getShareStatus: () => invoke('codeburn:getShareStatus'),
getIdentity: () => invoke('codeburn:getIdentity'),
cliStatus: () => invoke('codeburn:cliStatus'),
diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx
index f1092be..9e91658 100644
--- a/app/renderer/App.tsx
+++ b/app/renderer/App.tsx
@@ -11,6 +11,7 @@ import { Overview } from './sections/Overview'
import { Optimize } from './sections/Optimize'
import { Models } from './sections/Models'
import { Plans } from './sections/Plans'
+import { Settings } from './sections/Settings'
import { Spend } from './sections/Spend'
import type { MenubarPayload, Period } from './lib/types'
@@ -64,11 +65,13 @@ export function App() {
{section === 'plans' ? (
+ ) : section === 'settings' ? (
+
) : (
<>
>
)}
-
+ {section !== 'settings' && (
+
+ )}
)
diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts
index 7a1ad94..3923d33 100644
--- a/app/renderer/lib/types.ts
+++ b/app/renderer/lib/types.ts
@@ -357,6 +357,7 @@ export interface CodeburnBridge {
getYield(period: Period): Promise
getSpendFlow(period: Period, provider: string): Promise
getDevices(period: Period): Promise
+ getDevicesScan(): Promise
getShareStatus(): Promise
getIdentity(): Promise
cliStatus(): Promise<{ found: boolean; path: string | null; error?: string }>
diff --git a/app/renderer/sections/Settings.test.tsx b/app/renderer/sections/Settings.test.tsx
new file mode 100644
index 0000000..31e9141
--- /dev/null
+++ b/app/renderer/sections/Settings.test.tsx
@@ -0,0 +1,110 @@
+// @vitest-environment jsdom
+import { render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { CombinedUsage, DeviceScanResult, Identity } from '../lib/types'
+import { Settings } from './Settings'
+
+const { getIdentity, getDevices, getDevicesScan } = vi.hoisted(() => ({
+ getIdentity: vi.fn<() => Promise>(),
+ getDevices: vi.fn<(period: string) => Promise>(),
+ getDevicesScan: vi.fn<() => Promise>(),
+}))
+vi.mock('../lib/ipc', async orig => {
+ const actual = await orig()
+ return { ...actual, codeburn: { getIdentity, getDevices, getDevicesScan } }
+})
+
+const identity: Identity = {
+ name: 'Toruk MacBook Pro',
+ fingerprint: 'AA:11:22:33:44:55:66:77',
+}
+
+const devices: CombinedUsage = {
+ perDevice: [
+ {
+ id: 'local',
+ name: 'Toruk MacBook Pro',
+ local: true,
+ cost: 120.1,
+ calls: 100,
+ sessions: 10,
+ inputTokens: 1,
+ outputTokens: 2,
+ cacheCreateTokens: 3,
+ cacheReadTokens: 4,
+ totalTokens: 10,
+ },
+ {
+ id: 'mini',
+ name: 'toruk-mini',
+ local: false,
+ cost: 41.2,
+ calls: 680,
+ sessions: 34,
+ inputTokens: 11,
+ outputTokens: 12,
+ cacheCreateTokens: 13,
+ cacheReadTokens: 14,
+ totalTokens: 50,
+ },
+ ],
+ combined: {
+ cost: 161.3,
+ calls: 780,
+ sessions: 44,
+ inputTokens: 12,
+ outputTokens: 14,
+ cacheCreateTokens: 16,
+ cacheReadTokens: 18,
+ totalTokens: 60,
+ deviceCount: 2,
+ reachableCount: 2,
+ },
+}
+
+const scan: DeviceScanResult = {
+ found: [
+ {
+ name: 'Mac Studio',
+ host: 'mac-studio.local',
+ port: 9732,
+ fingerprint: '7F:2A:19:88:55:44:33:C4',
+ code: 'pair-1',
+ paired: false,
+ },
+ ],
+}
+
+describe('Settings', () => {
+ beforeEach(() => {
+ getIdentity.mockReset()
+ getDevices.mockReset()
+ getDevicesScan.mockReset()
+ })
+
+ it('renders device identity, scan results, paired devices, and M2 affordances', async () => {
+ getIdentity.mockResolvedValue(identity)
+ getDevices.mockResolvedValue(devices)
+ getDevicesScan.mockResolvedValue(scan)
+
+ const { container } = render()
+
+ expect(await screen.findByText('Toruk MacBook Pro')).toBeInTheDocument()
+ expect(screen.getByText('Visible on the local network as Toruk MacBook Pro.local')).toBeInTheDocument()
+ expect(screen.getByText('AA:11:22:33:44:55:66:77')).toBeInTheDocument()
+
+ expect(await screen.findByText('Mac Studio')).toBeInTheDocument()
+ expect(screen.getByText('wants to pair · fingerprint 7F:2A:…:C4')).toBeInTheDocument()
+
+ expect(await screen.findByText('toruk-mini')).toBeInTheDocument()
+ expect(screen.getByText('34 sessions · $41.20 this month')).toBeInTheDocument()
+
+ expect(screen.getByText('Visibility: on')).toBeInTheDocument()
+ expect(screen.getByText('Approve')).toBeInTheDocument()
+ expect(screen.getByText('Pull now')).toBeInTheDocument()
+ expect(screen.getByText('Combine usage from paired devices')).toBeInTheDocument()
+ expect(container.querySelector('.tglon')).toBeInTheDocument()
+ expect(getDevices).toHaveBeenCalledWith('month')
+ })
+})
diff --git a/app/renderer/sections/Settings.tsx b/app/renderer/sections/Settings.tsx
new file mode 100644
index 0000000..955675f
--- /dev/null
+++ b/app/renderer/sections/Settings.tsx
@@ -0,0 +1,158 @@
+import { Hint } from '../components/Hint'
+import { Panel } from '../components/Panel'
+import { usePolled } from '../hooks/usePolled'
+import { codeburn } from '../lib/ipc'
+import type { 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'
+ if (period === 'month') return 'this month'
+ if (period === '30days') return 'last 30 days'
+ return 'all time'
+}
+
+function shortFingerprint(fingerprint: string): string {
+ const parts = fingerprint.split(':').filter(Boolean)
+ if (parts.length < 3) return fingerprint
+ return `${parts[0]}:${parts[1]}:…:${parts[parts.length - 1]}`
+}
+
+function isPermissionError(message: string): boolean {
+ return /permission|full disk access|eacces/i.test(message)
+}
+
+function errorText(error: { kind: string; message: string } | null): string | null {
+ if (!error) return null
+ if (error.kind === 'not-found') return 'codeburn CLI not found'
+ if (error.kind === 'nonzero' && isPermissionError(error.message)) return 'permission denied — grant Full Disk Access'
+ return error.message
+}
+
+export function Settings({ period }: { period: Period }) {
+ const identity = usePolled(() => codeburn.getIdentity(), [])
+ const scan = usePolled(() => codeburn.getDevicesScan(), [])
+ const devices = usePolled(() => codeburn.getDevices(period), [period])
+
+ return (
+ <>
+
+
+
+
+
+
+ >
+ )
+}
+
+function ThisDevicePanel({ identity }: { identity: ReturnType> }) {
+ const error = errorText(identity.error)
+
+ return (
+
+ {identity.data ? (
+
+
+ {identity.data.name}
+ Visible on the local network as {identity.data.name}.local
+ {identity.data.fingerprint}
+
+
+ Visibility: on
+
+
+ ) : (
+
+ {error ?? 'Reading this device identity…'}
+
+ )}
+
+ )
+}
+
+function DiscoveredPanel({ scan }: { scan: ReturnType> }) {
+ const error = errorText(scan.error)
+ const found = scan.data?.found.filter(device => !device.paired) ?? []
+
+ return (
+
+ {!scan.data ? (
+
+ {error ?? 'listening…'}
+
+ ) : found.length === 0 ? (
+ No nearby devices found.
+ ) : (
+ found.map(device => (
+
+
+ {device.name}
+ wants to pair · fingerprint {shortFingerprint(device.fingerprint)}
+
+
+ Approve
+
+
+ ))
+ )}
+
+ )
+}
+
+function PairedPanel({ devices, period }: { devices: ReturnType>; period: Period }) {
+ const error = errorText(devices.error)
+ const paired = devices.data?.perDevice.filter(device => !device.local) ?? []
+ const deviceScope = devices.data ? `· ${devices.data.combined.deviceCount} devices` : '· paired devices'
+
+ return (
+
+ {!devices.data ? (
+
+ {error ?? 'Loading paired devices…'}
+
+ ) : paired.length === 0 ? (
+ No paired devices yet.
+ ) : (
+ paired.map(device => (
+
+
+ {device.name}
+
+ {device.sessions.toLocaleString('en-US')} sessions · {fmtUsd(device.cost)} {periodLabel(period)}
+
+
+
+ Pull now
+
+
+ ))
+ )}
+
+
+ Combine usage from paired devices
+ scope captions gain “{deviceScope}” when on
+
+
+
+
+ )
+}