feat(devices): joined Totals by machine report

Rework codeburn devices into a spacious joined report matching the
screenshot: Host / Cost / Total tokens / Input / Output / Cache create /
Cache read, with full comma-grouped numbers and a bold Combined row. Add
a reusable box-drawing table renderer (text-table.ts).
This commit is contained in:
AgentSeal 2026-06-20 19:20:43 +02:00
parent 1dba4e0aa4
commit 4a86b90795
2 changed files with 93 additions and 22 deletions

View file

@ -7,11 +7,14 @@ import type { UsageQuery } from './share-server.js'
import { getSharingDir, loadRemotes, saveRemotes, type RemoteDevice } from './store.js'
import type { MenubarPayload } from '../menubar-json.js'
import { formatCost } from '../currency.js'
import { formatTokens } from '../format.js'
import { renderTable } from '../text-table.js'
import { Chalk } from 'chalk'
// Minimal shape we read from a device's usage payload (the menubar payload).
// Cache create/read are only in the daily history, so we sum those.
type DevicePayload = {
current?: { cost?: number; calls?: number; sessions?: number; inputTokens?: number; outputTokens?: number }
history?: { daily?: Array<{ cacheReadTokens?: number; cacheWriteTokens?: number }> }
}
export type DeviceUsage = {
@ -112,36 +115,63 @@ export async function pullDevices(
return [local, ...remoteResults]
}
// Joined "Totals by machine" report: one row per device plus a bold Combined
// row. Tokens are shown as full, comma-grouped numbers.
export function renderDevices(results: DeviceUsage[]): string {
const num = (n: number | undefined): number => n ?? 0
const n = (x: number): string => Math.round(x).toLocaleString()
const money = (x: number): string => formatCost(x).replace(/(\d)(?=(\d{3})+(\.|$))/g, '$1,')
const rows = results.map((d) => {
const c = d.payload?.current
const cur = d.payload?.current
const daily = d.payload?.history?.daily ?? []
const input = num(cur?.inputTokens)
const output = num(cur?.outputTokens)
const cacheCreate = daily.reduce((s, e) => s + num(e.cacheWriteTokens), 0)
const cacheRead = daily.reduce((s, e) => s + num(e.cacheReadTokens), 0)
return {
name: d.name + (d.local ? ' (this Mac)' : ''),
cost: num(c?.cost),
tokens: num(c?.inputTokens) + num(c?.outputTokens),
calls: num(c?.calls),
sessions: num(c?.sessions),
error: d.error,
cost: num(cur?.cost),
input,
output,
cacheCreate,
cacheRead,
total: input + output + cacheCreate + cacheRead,
}
})
const combined = rows.reduce(
(a, r) => ({ cost: a.cost + r.cost, tokens: a.tokens + r.tokens, calls: a.calls + r.calls, sessions: a.sessions + r.sessions }),
{ cost: 0, tokens: 0, calls: 0, sessions: 0 },
(a, r) => ({
cost: a.cost + r.cost,
input: a.input + r.input,
output: a.output + r.output,
cacheCreate: a.cacheCreate + r.cacheCreate,
cacheRead: a.cacheRead + r.cacheRead,
total: a.total + r.total,
}),
{ cost: 0, input: 0, output: 0, cacheCreate: 0, cacheRead: 0, total: 0 },
)
const nameW = Math.max(8, ...rows.map((r) => r.name.length), 'Combined'.length)
const line = (name: string, cost: string, tokens: string, calls: string): string =>
` ${name.padEnd(nameW)} ${cost.padStart(11)} ${tokens.padStart(9)} ${calls.padStart(8)}`
const out: string[] = []
out.push(line('Device', 'Cost', 'Tokens', 'Calls'))
out.push(' ' + '-'.repeat(nameW + 11 + 9 + 8 + 6))
for (const r of rows) {
if (r.error) out.push(line(r.name, '-', '-', r.error))
else out.push(line(r.name, formatCost(r.cost), formatTokens(r.tokens), r.calls.toLocaleString()))
}
out.push(' ' + '-'.repeat(nameW + 11 + 9 + 8 + 6))
out.push(line('Combined', formatCost(combined.cost), formatTokens(combined.tokens), combined.calls.toLocaleString()))
return out.join('\n') + '\n'
const tableRows = [
...rows.map((r) =>
r.error
? [r.name, r.error, '-', '-', '-', '-', '-']
: [r.name, money(r.cost), n(r.total), n(r.input), n(r.output), n(r.cacheCreate), n(r.cacheRead)],
),
['Combined', money(combined.cost), n(combined.total), n(combined.input), n(combined.output), n(combined.cacheCreate), n(combined.cacheRead)],
]
const table = renderTable(
[
{ header: 'Host' },
{ header: 'Cost', right: true },
{ header: 'Total tokens', right: true },
{ header: 'Input', right: true },
{ header: 'Output', right: true },
{ header: 'Cache create', right: true },
{ header: 'Cache read', right: true },
],
tableRows,
{ boldRows: new Set([tableRows.length - 1]) },
)
const heading = new Chalk({}).cyan('Totals by machine')
return heading + '\n' + table + '\n'
}

41
src/text-table.ts Normal file
View file

@ -0,0 +1,41 @@
import { Chalk } from 'chalk'
export type TableColumn = { header: string; right?: boolean }
// Visible width, ignoring ANSI color codes, so padding stays aligned.
function vlen(s: string): number {
// eslint-disable-next-line no-control-regex
return s.replace(/\[[0-9;]*m/g, '').length
}
// Box-drawing table with roomy 2-space cell padding, right-aligned numeric
// columns, and optional bold rows (e.g. a Combined/total row). Color is
// auto-detected; pass color:false to force plain text.
export function renderTable(
columns: TableColumn[],
rows: string[][],
opts: { color?: boolean; boldRows?: ReadonlySet<number> } = {},
): string {
const c = new Chalk(opts.color === false ? { level: 0 } : {})
const bold = opts.boldRows ?? new Set<number>()
const widths = columns.map((col, i) => Math.max(vlen(col.header), ...rows.map((r) => vlen(r[i] ?? ''))))
const pad = (s: string, w: number, right?: boolean): string => {
const fill = ' '.repeat(Math.max(0, w - vlen(s)))
return right ? fill + s : s + fill
}
const gap = ' '
const sep = gap + '│' + gap
const bar = (l: string, mid: string, r: string): string => l + widths.map((w) => '─'.repeat(w + 4)).join(mid) + r
const line = (cells: string[], makeBold: boolean): string =>
'│' + gap + columns.map((col, i) => {
const padded = pad(cells[i] ?? '', widths[i]!, col.right)
return makeBold ? c.bold(padded) : padded
}).join(sep) + gap + '│'
return [
bar('┌', '┬', '┐'),
line(columns.map((col) => col.header), false),
bar('├', '┼', '┤'),
...rows.map((r, i) => line(r, bold.has(i))),
bar('└', '┴', '┘'),
].join('\n')
}