mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-30 03:15:30 +00:00
feat(app): Settings/Devices section + getDevicesScan bridge
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).
This commit is contained in:
parent
73fb86dec6
commit
dc52b3226b
7 changed files with 287 additions and 9 deletions
|
|
@ -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())
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div className="ct">
|
||||
{section === 'plans' ? (
|
||||
<Plans period={period} />
|
||||
) : section === 'settings' ? (
|
||||
<Settings period={period} />
|
||||
) : (
|
||||
<>
|
||||
<TopBar
|
||||
title={SECTION_TITLES[section]}
|
||||
scope={section === 'settings' ? undefined : scope}
|
||||
scope={scope}
|
||||
period={period}
|
||||
onPeriodChange={onPeriodChange}
|
||||
providerLabel="All providers"
|
||||
|
|
@ -88,13 +91,15 @@ export function App() {
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
<Hint
|
||||
items={[
|
||||
{ k: '⌘K', label: 'Command' },
|
||||
{ k: '⌘E', label: 'Export view' },
|
||||
]}
|
||||
right={overview.loading ? 'refreshing…' : 'auto-refresh 30s'}
|
||||
/>
|
||||
{section !== 'settings' && (
|
||||
<Hint
|
||||
items={[
|
||||
{ k: '⌘K', label: 'Command' },
|
||||
{ k: '⌘E', label: 'Export view' },
|
||||
]}
|
||||
right={overview.loading ? 'refreshing…' : 'auto-refresh 30s'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Window>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -357,6 +357,7 @@ export interface CodeburnBridge {
|
|||
getYield(period: Period): Promise<YieldJsonReport>
|
||||
getSpendFlow(period: Period, provider: string): Promise<SpendFlow>
|
||||
getDevices(period: Period): Promise<CombinedUsage>
|
||||
getDevicesScan(): Promise<DeviceScanResult>
|
||||
getShareStatus(): Promise<ShareStatus>
|
||||
getIdentity(): Promise<Identity>
|
||||
cliStatus(): Promise<{ found: boolean; path: string | null; error?: string }>
|
||||
|
|
|
|||
110
app/renderer/sections/Settings.test.tsx
Normal file
110
app/renderer/sections/Settings.test.tsx
Normal file
|
|
@ -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<Identity>>(),
|
||||
getDevices: vi.fn<(period: string) => Promise<CombinedUsage>>(),
|
||||
getDevicesScan: vi.fn<() => Promise<DeviceScanResult>>(),
|
||||
}))
|
||||
vi.mock('../lib/ipc', async orig => {
|
||||
const actual = await orig<typeof import('../lib/ipc')>()
|
||||
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(<Settings period="month" />)
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
158
app/renderer/sections/Settings.tsx
Normal file
158
app/renderer/sections/Settings.tsx
Normal file
|
|
@ -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<Identity>(() => codeburn.getIdentity(), [])
|
||||
const scan = usePolled<DeviceScanResult>(() => codeburn.getDevicesScan(), [])
|
||||
const devices = usePolled<CombinedUsage>(() => codeburn.getDevices(period), [period])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bar">
|
||||
<div className="t">Settings</div>
|
||||
</div>
|
||||
<div className="body" style={{ flexDirection: 'row', gap: 14 }}>
|
||||
<nav className="rail">
|
||||
{RAIL_ITEMS.map(item => (
|
||||
<div key={item} className={item === 'Devices' ? 'ni on' : 'ni'}>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<ThisDevicePanel identity={identity} />
|
||||
<DiscoveredPanel scan={scan} />
|
||||
<PairedPanel devices={devices} period={period} />
|
||||
</div>
|
||||
</div>
|
||||
<Hint items={[{ k: 'esc', label: 'Back' }]} right="pairing uses mutual TLS · approve-style, no PIN" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ThisDevicePanel({ identity }: { identity: ReturnType<typeof usePolled<Identity>> }) {
|
||||
const error = errorText(identity.error)
|
||||
|
||||
return (
|
||||
<Panel title="This device">
|
||||
{identity.data ? (
|
||||
<div className="li">
|
||||
<div className="lx">
|
||||
<b>{identity.data.name}</b>
|
||||
<span>Visible on the local network as {identity.data.name}.local</span>
|
||||
<span>{identity.data.fingerprint}</span>
|
||||
</div>
|
||||
<span className="btn btn-s" aria-disabled="true">
|
||||
Visibility: on
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ color: error ? 'var(--amber)' : 'var(--t3)', margin: 0, fontSize: 12 }}>
|
||||
{error ?? '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>
|
||||
) : found.length === 0 ? (
|
||||
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>No nearby devices found.</p>
|
||||
) : (
|
||||
found.map(device => (
|
||||
<div className="li" key={`${device.host}:${device.port}:${device.fingerprint}`}>
|
||||
<div className="lx">
|
||||
<b>{device.name}</b>
|
||||
<span className="hot">wants to pair · fingerprint {shortFingerprint(device.fingerprint)}</span>
|
||||
</div>
|
||||
<span className="btn btn-p" aria-disabled="true">
|
||||
Approve
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
) : paired.length === 0 ? (
|
||||
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>No paired devices yet.</p>
|
||||
) : (
|
||||
paired.map(device => (
|
||||
<div className="li" key={device.id}>
|
||||
<div className="lx">
|
||||
<b>{device.name}</b>
|
||||
<span>
|
||||
{device.sessions.toLocaleString('en-US')} sessions · {fmtUsd(device.cost)} {periodLabel(period)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="btn btn-s" aria-disabled="true">
|
||||
Pull now
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div className="li">
|
||||
<div className="lx">
|
||||
<b>Combine usage from paired devices</b>
|
||||
<span>scope captions gain “{deviceScope}” when on</span>
|
||||
</div>
|
||||
<span className="tglon" aria-disabled="true" />
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue