From dc52b3226bb6ce41898c9fa175bbbb02644d0f30 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Fri, 10 Jul 2026 19:17:14 -0700 Subject: [PATCH] feat(app): Settings/Devices section + getDevicesScan bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings rail (Devices active) with This device (getIdentity), Discovered nearby (new getDevicesScan bridge → `codeburn devices scan --format json`), and Paired (getDevices perDevice). Pairing/approve/pull/visibility/combine are M1 visual affordances (mutations = M2); share status is process-local so not shown as authoritative. Settings uses a title-only bar (no period/provider). Implemented by Codex gpt-5.5 (high); committed by Fable (git blocked in Codex sandbox). --- app/electron/main.test.ts | 4 +- app/electron/main.ts | 1 + app/electron/preload.ts | 1 + app/renderer/App.tsx | 21 ++-- app/renderer/lib/types.ts | 1 + app/renderer/sections/Settings.test.tsx | 110 +++++++++++++++++ app/renderer/sections/Settings.tsx | 158 ++++++++++++++++++++++++ 7 files changed, 287 insertions(+), 9 deletions(-) create mode 100644 app/renderer/sections/Settings.test.tsx create mode 100644 app/renderer/sections/Settings.tsx 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 ( + <> +
+
Settings
+
+
+ +
+ + + +
+
+ + + ) +} + +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 +
+ +
+
+ ) +}