mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 01:29:41 +00:00
feat(overview): plain-text monthly usage overview command (#528)
* feat(overview): plain-text monthly usage overview command Add 'codeburn overview', a copy-pasteable text report. Defaults to the current month, with --from/--to to filter and --no-color for plain output. Renders Totals, By tool, Top models, Highest-value days, Top projects, a daily table, By activity, and Tools, with colored section headings (stripped under --no-color or when piped). Reuses the existing session aggregation. Cost gains thousands separators and tokens roll up to a B unit for readability via display-only wrappers, without changing the shared formatters. Project names use the path basename instead of the sanitized full path. * fix(cli): exit cleanly on EPIPE when the stdout pipe closes early Piping output to a reader that closes early (| head, quitting less, a missing command) made stdout writes throw EPIPE and crash with an unhandled error event. Handle EPIPE on process.stdout and exit 0 so piping the overview and other commands behaves normally. * docs(readme): document and highlight the overview command Add a 'Your month at a glance' section featuring codeburn overview with examples and sample output, a Commands-table entry, and the provider flag note.
This commit is contained in:
parent
fac7d141ce
commit
c55dba2dd2
4 changed files with 411 additions and 1 deletions
35
README.md
35
README.md
|
|
@ -65,6 +65,38 @@ codeburn menubar
|
|||
|
||||
Requires **Node.js 22.13+** and at least one supported tool with session data on disk. For Cursor and OpenCode, `better-sqlite3` installs automatically.
|
||||
|
||||
## Your month at a glance
|
||||
|
||||
```bash
|
||||
codeburn overview # this month, clean tables
|
||||
codeburn overview --no-color # plain text, ready to paste
|
||||
codeburn overview --from 2026-06-01 --to 2026-06-15 # any date range
|
||||
codeburn overview -p all # all time
|
||||
codeburn overview --provider claude # one tool only
|
||||
```
|
||||
|
||||
`codeburn overview` prints a copy-pasteable summary of where your AI spend went: totals (cost, tokens, cache hit), a breakdown by tool and by top model, your highest-value days, top projects, a per-day table, and activity and tool usage. Pipe it anywhere (into `pbcopy`, a PR, Slack, or a tweet); color drops automatically when the output is not a terminal, or pass `--no-color`.
|
||||
|
||||
```text
|
||||
CodeBurn June 2026
|
||||
|
||||
Totals
|
||||
Cost $2,795.10
|
||||
Tokens 3.49B in 23.9M / out 20.2M / cache-w 72.5M / cache-r 3.38B
|
||||
Calls 14,755 sessions 753
|
||||
Cache hit 99.3%
|
||||
|
||||
By tool
|
||||
┌──────────┬───────────┬────────┬───────┐
|
||||
│ Tool │ Cost │ Tokens │ Share │
|
||||
├──────────┼───────────┼────────┼───────┤
|
||||
│ claude │ $2,662.37 │ 3.34B │ 95% │
|
||||
│ codex │ $119.12 │ 128.1M │ 4% │
|
||||
└──────────┴───────────┴────────┴───────┘
|
||||
|
||||
(plus Top models, Highest-value days, Top projects, a per-day table, By activity, and Tools)
|
||||
```
|
||||
|
||||
## Find and fix waste
|
||||
|
||||
```bash
|
||||
|
|
@ -213,7 +245,7 @@ If multiple providers have session data on disk, press `p` in the dashboard to t
|
|||
|
||||
Each provider doc lists the exact data location, storage format, and known quirks. Linux and Windows paths are detected automatically. If a path has changed or is wrong, please [open an issue](https://github.com/getagentseal/codeburn/issues).
|
||||
|
||||
The `--provider` flag filters any command to a single provider: `codeburn report --provider claude`, `codeburn today --provider codex`, `codeburn export --provider cursor`. Works on all commands: `report`, `today`, `month`, `status`, `export`, `optimize`, `compare`, `yield`.
|
||||
The `--provider` flag filters any command to a single provider: `codeburn report --provider claude`, `codeburn today --provider codex`, `codeburn export --provider cursor`. Works on all commands: `report`, `today`, `month`, `overview`, `status`, `export`, `optimize`, `compare`, `yield`.
|
||||
|
||||
Adding a new provider is a single file. See `src/providers/codex.ts` for an example.
|
||||
|
||||
|
|
@ -231,6 +263,7 @@ Run `codeburn` for the dashboard, or use a subcommand below. Most commands also
|
|||
| `codeburn` | Interactive dashboard, last 7 days (the default view) |
|
||||
| `codeburn today` | Today's usage |
|
||||
| `codeburn month` | This calendar month's usage |
|
||||
| `codeburn overview` | Plain-text monthly summary, copy-pasteable (`--no-color`, `--from`/`--to`) |
|
||||
| `codeburn report -p 30days` | Rolling 30-day window |
|
||||
| `codeburn report -p all` | Every recorded session |
|
||||
| `codeburn report --from 2026-04-01 --to 2026-04-10` | An exact date range |
|
||||
|
|
|
|||
36
src/main.ts
36
src/main.ts
|
|
@ -13,6 +13,7 @@ import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory
|
|||
import { aggregateModelEfficiency } from './model-efficiency.js'
|
||||
import { buildPeriodData, buildMenubarPayloadForRange } from './usage-aggregator.js'
|
||||
import { renderDashboard } from './dashboard.js'
|
||||
import { renderOverview } from './overview.js'
|
||||
import { formatDateRangeLabel, parseDateRangeFlags, parseDayFlag, parseDaysFlag, getDateRange, toPeriod, type Period } from './cli-date.js'
|
||||
import { runOptimize } from './optimize.js'
|
||||
import { renderCompare } from './compare.js'
|
||||
|
|
@ -30,6 +31,14 @@ const require = createRequire(import.meta.url)
|
|||
const { version } = require('../package.json')
|
||||
import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js'
|
||||
|
||||
// A downstream reader that closes the pipe early (`| head`, quitting `less`, or
|
||||
// a missing command) makes stdout writes fail with EPIPE. Exit cleanly rather
|
||||
// than crashing with an unhandled error event.
|
||||
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EPIPE') process.exit(0)
|
||||
throw err
|
||||
})
|
||||
|
||||
function collect(val: string, acc: string[]): string[] {
|
||||
acc.push(val)
|
||||
return acc
|
||||
|
|
@ -468,6 +477,33 @@ program
|
|||
await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange, customRangeLabel, daySelection?.day)
|
||||
})
|
||||
|
||||
program
|
||||
.command('overview')
|
||||
.description('Plain-text usage overview, copy-pasteable (defaults to this month)')
|
||||
.option('-p, --period <period>', 'Period: today, week, 30days, month, all', 'month')
|
||||
.option('--from <date>', 'Start date (YYYY-MM-DD). Overrides --period when set')
|
||||
.option('--to <date>', 'End date (YYYY-MM-DD). Overrides --period when set')
|
||||
.option('--provider <provider>', 'Filter by provider (e.g. claude, codex, copilot)', 'all')
|
||||
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
.option('--no-color', 'Disable ANSI colors')
|
||||
.action(async (opts) => {
|
||||
assertProvider(opts.provider, 'overview')
|
||||
await loadPricing()
|
||||
let customRange: DateRange | null = null
|
||||
try {
|
||||
customRange = parseDateRangeFlags(opts.from, opts.to)
|
||||
} catch (err) {
|
||||
console.error(`\n Error: ${err instanceof Error ? err.message : String(err)}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
const { range, label } = customRange
|
||||
? { range: customRange, label: formatDateRangeLabel(opts.from, opts.to) }
|
||||
: getDateRange(toPeriod(opts.period))
|
||||
const projects = filterProjectsByName(await parseAllSessions(range, opts.provider), opts.project, opts.exclude)
|
||||
process.stdout.write(renderOverview(projects, { label, color: opts.color }))
|
||||
})
|
||||
|
||||
|
||||
program
|
||||
.command('status')
|
||||
|
|
|
|||
239
src/overview.ts
Normal file
239
src/overview.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { Chalk, type ChalkInstance } from 'chalk'
|
||||
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
|
||||
import { formatCost as baseCost } from './currency.js'
|
||||
import { formatTokens as baseTokens } from './format.js'
|
||||
import { getShortModelName } from './models.js'
|
||||
import { dateKey } from './day-aggregator.js'
|
||||
|
||||
// Display-only helpers. The shared formatters omit thousands separators and stop
|
||||
// at M; aggregation uses raw numbers, these only affect rendering.
|
||||
function formatCost(usd: number): string {
|
||||
return baseCost(usd).replace(/(\d)(?=(\d{3})+(\.|$))/g, '$1,')
|
||||
}
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B`
|
||||
return baseTokens(n)
|
||||
}
|
||||
function projectName(p: ProjectSummary): string {
|
||||
const path = p.projectPath
|
||||
if (path) {
|
||||
if (path === homedir()) return 'Home'
|
||||
const base = path.replace(/[/\\]+$/, '').split(/[/\\]/).filter(Boolean).pop()
|
||||
if (base) return base
|
||||
}
|
||||
return p.project.split('-').filter(Boolean).pop() || p.project
|
||||
}
|
||||
|
||||
type Col = { 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
|
||||
}
|
||||
|
||||
function renderTable(c: ChalkInstance, cols: Col[], rows: string[][]): string {
|
||||
const widths = cols.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 sep = ' ' + c.dim('│') + ' '
|
||||
const edge = c.dim('│')
|
||||
const bar = (l: string, mid: string, r: string): string =>
|
||||
c.dim(l + widths.map((w) => '─'.repeat(w + 2)).join(mid) + r)
|
||||
const line = (cells: string[], header = false): string =>
|
||||
edge + ' ' + cells.map((cell, i) => {
|
||||
const padded = pad(cell, widths[i]!, cols[i]!.right)
|
||||
return header ? c.bold(padded) : padded
|
||||
}).join(sep) + ' ' + edge
|
||||
return [
|
||||
bar('┌', '┬', '┐'),
|
||||
line(cols.map((col) => col.header), true),
|
||||
bar('├', '┼', '┤'),
|
||||
...rows.map((r) => line(r)),
|
||||
bar('└', '┴', '┘'),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function renderOverview(
|
||||
projects: ProjectSummary[],
|
||||
opts: { label: string; color: boolean },
|
||||
): string {
|
||||
const c = new Chalk(opts.color ? {} : { level: 0 })
|
||||
const heading = (text: string): string => c.cyan.bold(text)
|
||||
const out: string[] = []
|
||||
|
||||
out.push(c.bold('CodeBurn') + c.dim(' ' + opts.label))
|
||||
out.push('')
|
||||
|
||||
if (projects.length === 0) {
|
||||
out.push(c.dim(`No usage found for ${opts.label}.`))
|
||||
return out.join('\n') + '\n'
|
||||
}
|
||||
|
||||
let cost = 0, savings = 0, calls = 0, sessions = 0
|
||||
let inTok = 0, outTok = 0, cacheR = 0, cacheW = 0
|
||||
const byProvider = new Map<string, { cost: number; tokens: number }>()
|
||||
const byModel = new Map<string, { cost: number; calls: number; tokens: number }>()
|
||||
const byCat = new Map<string, { cost: number; turns: number }>()
|
||||
const byTool = new Map<string, number>()
|
||||
const byDay = new Map<string, { cost: number; tokens: number; providers: Set<string> }>()
|
||||
const byProject = new Map<string, { cost: number; sessions: number }>()
|
||||
|
||||
for (const p of projects) {
|
||||
cost += p.totalCostUSD
|
||||
savings += p.totalSavingsUSD
|
||||
calls += p.totalApiCalls
|
||||
sessions += p.sessions.length
|
||||
const pname = projectName(p)
|
||||
const pe = byProject.get(pname) ?? { cost: 0, sessions: 0 }
|
||||
pe.cost += p.totalCostUSD
|
||||
pe.sessions += p.sessions.length
|
||||
byProject.set(pname, pe)
|
||||
for (const s of p.sessions) {
|
||||
inTok += s.totalInputTokens
|
||||
outTok += s.totalOutputTokens
|
||||
cacheR += s.totalCacheReadTokens
|
||||
cacheW += s.totalCacheWriteTokens
|
||||
for (const [m, d] of Object.entries(s.modelBreakdown)) {
|
||||
const e = byModel.get(m) ?? { cost: 0, calls: 0, tokens: 0 }
|
||||
e.cost += d.costUSD
|
||||
e.calls += d.calls
|
||||
e.tokens += d.tokens.inputTokens + d.tokens.outputTokens + d.tokens.cacheReadInputTokens + d.tokens.cacheCreationInputTokens
|
||||
byModel.set(m, e)
|
||||
}
|
||||
for (const [cat, d] of Object.entries(s.categoryBreakdown)) {
|
||||
const e = byCat.get(cat) ?? { cost: 0, turns: 0 }
|
||||
e.cost += d.costUSD
|
||||
e.turns += d.turns
|
||||
byCat.set(cat, e)
|
||||
}
|
||||
for (const [tool, d] of Object.entries(s.toolBreakdown)) {
|
||||
byTool.set(tool, (byTool.get(tool) ?? 0) + d.calls)
|
||||
}
|
||||
for (const t of s.turns) {
|
||||
const day = dateKey(t.timestamp || t.assistantCalls[0]?.timestamp || '')
|
||||
for (const call of t.assistantCalls) {
|
||||
const tk = call.usage.inputTokens + call.usage.outputTokens + call.usage.cacheReadInputTokens + call.usage.cacheCreationInputTokens
|
||||
const pv = byProvider.get(call.provider) ?? { cost: 0, tokens: 0 }
|
||||
pv.cost += call.costUSD
|
||||
pv.tokens += tk
|
||||
byProvider.set(call.provider, pv)
|
||||
if (day) {
|
||||
const dd = byDay.get(day) ?? { cost: 0, tokens: 0, providers: new Set<string>() }
|
||||
dd.cost += call.costUSD
|
||||
dd.tokens += tk
|
||||
dd.providers.add(call.provider)
|
||||
byDay.set(day, dd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalTokens = inTok + outTok + cacheR + cacheW
|
||||
const cacheHitDenom = inTok + cacheR
|
||||
const cacheHit = cacheHitDenom > 0 ? (cacheR / cacheHitDenom) * 100 : 0
|
||||
|
||||
// Totals
|
||||
out.push(heading('Totals'))
|
||||
const kv = (k: string, v: string): string => ' ' + c.dim(k.padEnd(11)) + v
|
||||
out.push(kv('Cost', c.bold(formatCost(cost))))
|
||||
out.push(kv('Tokens', formatTokens(totalTokens) + c.dim(` in ${formatTokens(inTok)} / out ${formatTokens(outTok)} / cache-w ${formatTokens(cacheW)} / cache-r ${formatTokens(cacheR)}`)))
|
||||
out.push(kv('Calls', calls.toLocaleString() + c.dim(' sessions ') + sessions.toLocaleString()))
|
||||
out.push(kv('Cache hit', `${cacheHit.toFixed(1)}%`))
|
||||
if (savings > 0) out.push(kv('Savings', formatCost(savings) + c.dim(' (local models)')))
|
||||
out.push('')
|
||||
|
||||
// By tool (provider)
|
||||
const providerRows = [...byProvider.entries()]
|
||||
.filter(([, v]) => v.cost > 0 || v.tokens > 0)
|
||||
.sort((a, b) => b[1].cost - a[1].cost)
|
||||
if (providerRows.length) {
|
||||
out.push(heading('By tool'))
|
||||
out.push(renderTable(c,
|
||||
[{ header: 'Tool' }, { header: 'Cost', right: true }, { header: 'Tokens', right: true }, { header: 'Share', right: true }],
|
||||
providerRows.map(([name, v]) => [name, formatCost(v.cost), formatTokens(v.tokens), cost > 0 ? `${Math.round((v.cost / cost) * 100)}%` : '0%']),
|
||||
))
|
||||
out.push('')
|
||||
}
|
||||
|
||||
// Top models
|
||||
const modelRows = [...byModel.entries()].filter(([, v]) => v.cost > 0 || v.tokens > 0).sort((a, b) => b[1].cost - a[1].cost).slice(0, 10)
|
||||
if (modelRows.length) {
|
||||
out.push(heading('Top models'))
|
||||
out.push(renderTable(c,
|
||||
[{ header: 'Model' }, { header: 'Cost', right: true }, { header: 'Calls', right: true }, { header: 'Tokens', right: true }],
|
||||
modelRows.map(([m, v]) => [getShortModelName(m), formatCost(v.cost), v.calls.toLocaleString(), formatTokens(v.tokens)]),
|
||||
))
|
||||
out.push('')
|
||||
}
|
||||
|
||||
// Highest-value days
|
||||
const topDays = [...byDay.entries()].sort((a, b) => b[1].cost - a[1].cost).slice(0, 5)
|
||||
if (topDays.length) {
|
||||
out.push(heading('Highest-value days'))
|
||||
out.push(renderTable(c,
|
||||
[{ header: '#' }, { header: 'Date' }, { header: 'Cost', right: true }, { header: 'Tokens', right: true }],
|
||||
topDays.map(([d, v], i) => [String(i + 1), d, formatCost(v.cost), formatTokens(v.tokens)]),
|
||||
))
|
||||
out.push('')
|
||||
}
|
||||
|
||||
// Top projects
|
||||
const projRows = [...byProject.entries()].sort((a, b) => b[1].cost - a[1].cost).slice(0, 10)
|
||||
if (projRows.length) {
|
||||
out.push(heading('Top projects'))
|
||||
out.push(renderTable(c,
|
||||
[{ header: 'Project' }, { header: 'Cost', right: true }, { header: 'Sessions', right: true }],
|
||||
projRows.map(([name, v]) => [name, formatCost(v.cost), v.sessions.toLocaleString()]),
|
||||
))
|
||||
out.push('')
|
||||
}
|
||||
|
||||
// Daily
|
||||
const dailyRows = [...byDay.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
if (dailyRows.length) {
|
||||
out.push(heading('Daily'))
|
||||
out.push(renderTable(c,
|
||||
[{ header: 'Date' }, { header: 'Cost', right: true }, { header: 'Tokens', right: true }, { header: 'Providers' }],
|
||||
dailyRows.map(([d, v]) => [d, formatCost(v.cost), formatTokens(v.tokens), [...v.providers].sort().join(', ')]),
|
||||
))
|
||||
out.push('')
|
||||
}
|
||||
|
||||
// By activity
|
||||
const catRows = [...byCat.entries()].filter(([, v]) => v.cost > 0 || v.turns > 0).sort((a, b) => b[1].cost - a[1].cost)
|
||||
if (catRows.length) {
|
||||
out.push(heading('By activity'))
|
||||
out.push(renderTable(c,
|
||||
[{ header: 'Activity' }, { header: 'Cost', right: true }, { header: 'Turns', right: true }],
|
||||
catRows.map(([cat, v]) => [CATEGORY_LABELS[cat as TaskCategory] ?? cat, formatCost(v.cost), v.turns.toLocaleString()]),
|
||||
))
|
||||
out.push('')
|
||||
}
|
||||
|
||||
// Tools
|
||||
const toolRows = [...byTool.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12)
|
||||
if (toolRows.length) {
|
||||
out.push(heading('Tools'))
|
||||
out.push(renderTable(c,
|
||||
[{ header: 'Tool' }, { header: 'Calls', right: true }],
|
||||
toolRows.map(([t, n]) => [t, n.toLocaleString()]),
|
||||
))
|
||||
out.push('')
|
||||
}
|
||||
|
||||
const topTool = providerRows[0]?.[0]
|
||||
const topModel = modelRows[0] ? getShortModelName(modelRows[0][0]) : ''
|
||||
const mostly = topTool ? `, mostly ${topTool}${topModel ? ` / ${topModel}` : ''}` : ''
|
||||
out.push(c.dim('Bottom line: ') + `${opts.label} totals ${formatCost(cost)} across ${formatTokens(totalTokens)} tokens${mostly}.`)
|
||||
|
||||
return out.join('\n') + '\n'
|
||||
}
|
||||
102
tests/overview.test.ts
Normal file
102
tests/overview.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { renderOverview } from '../src/overview.js'
|
||||
import type { ProjectSummary } from '../src/types.js'
|
||||
|
||||
function makeProject(opts: {
|
||||
project: string
|
||||
projectPath: string
|
||||
cost: number
|
||||
calls: number
|
||||
model: string
|
||||
provider: string
|
||||
tokens: { input: number; output: number; cacheR: number; cacheW: number }
|
||||
}): ProjectSummary {
|
||||
const usage = {
|
||||
inputTokens: opts.tokens.input,
|
||||
outputTokens: opts.tokens.output,
|
||||
cacheReadInputTokens: opts.tokens.cacheR,
|
||||
cacheCreationInputTokens: opts.tokens.cacheW,
|
||||
}
|
||||
return {
|
||||
project: opts.project,
|
||||
projectPath: opts.projectPath,
|
||||
totalCostUSD: opts.cost,
|
||||
totalSavingsUSD: 0,
|
||||
totalProxiedCostUSD: 0,
|
||||
totalApiCalls: opts.calls,
|
||||
sessions: [{
|
||||
sessionId: 's1',
|
||||
project: opts.project,
|
||||
totalInputTokens: opts.tokens.input,
|
||||
totalOutputTokens: opts.tokens.output,
|
||||
totalCacheReadTokens: opts.tokens.cacheR,
|
||||
totalCacheWriteTokens: opts.tokens.cacheW,
|
||||
apiCalls: opts.calls,
|
||||
modelBreakdown: { [opts.model]: { calls: opts.calls, costUSD: opts.cost, savingsUSD: 0, tokens: usage } },
|
||||
categoryBreakdown: { coding: { turns: 1, costUSD: opts.cost, savingsUSD: 0, retries: 0, editTurns: 1, oneShotTurns: 1 } },
|
||||
toolBreakdown: { Bash: { calls: 5 }, Read: { calls: 2 } },
|
||||
mcpBreakdown: {},
|
||||
bashBreakdown: {},
|
||||
skillBreakdown: {},
|
||||
subagentBreakdown: {},
|
||||
turns: [{
|
||||
userMessage: 'hi',
|
||||
timestamp: '2026-06-15T10:00:00Z',
|
||||
sessionId: 's1',
|
||||
category: 'coding',
|
||||
retries: 0,
|
||||
hasEdits: true,
|
||||
assistantCalls: [{ provider: opts.provider, model: opts.model, costUSD: opts.cost, usage }],
|
||||
}],
|
||||
}],
|
||||
} as unknown as ProjectSummary
|
||||
}
|
||||
|
||||
describe('renderOverview', () => {
|
||||
it('renders the detailed sections from real aggregation', () => {
|
||||
const out = renderOverview([makeProject({
|
||||
project: 'myproject',
|
||||
projectPath: '/Users/test/myproject',
|
||||
cost: 12.5,
|
||||
calls: 3,
|
||||
model: 'claude-opus-4-8',
|
||||
provider: 'claude',
|
||||
tokens: { input: 1000, output: 200, cacheR: 5000, cacheW: 100 },
|
||||
})], { label: 'June 2026', color: false })
|
||||
|
||||
for (const section of ['Totals', 'By tool', 'Top models', 'Highest-value days', 'Top projects', 'Daily', 'By activity', 'Tools']) {
|
||||
expect(out).toContain(section)
|
||||
}
|
||||
expect(out).toContain('Opus 4.8') // model display name
|
||||
expect(out).toContain('claude') // provider in By tool
|
||||
expect(out).toContain('myproject') // clean project name from path basename
|
||||
expect(out).toContain('$12.50')
|
||||
expect(out).toContain('2026-06-15')
|
||||
expect(out).toContain('Coding')
|
||||
expect(out).toContain('Bash')
|
||||
})
|
||||
|
||||
it('uses thousands separators and a B unit, and strips color in no-color mode', () => {
|
||||
const out = renderOverview([makeProject({
|
||||
project: 'big',
|
||||
projectPath: '/Users/test/big',
|
||||
cost: 1234.56,
|
||||
calls: 10,
|
||||
model: 'claude-opus-4-8',
|
||||
provider: 'claude',
|
||||
tokens: { input: 1_000_000, output: 1_000_000, cacheR: 2_000_000_000, cacheW: 0 },
|
||||
})], { label: 'June 2026', color: false })
|
||||
|
||||
expect(out).toContain('$1,234.56')
|
||||
expect(out).toMatch(/2\.\d\dB/)
|
||||
// no-color mode must not emit ANSI escape codes
|
||||
// eslint-disable-next-line no-control-regex
|
||||
expect(out).not.toMatch(/\[/)
|
||||
})
|
||||
|
||||
it('reports no usage for an empty range', () => {
|
||||
const out = renderOverview([], { label: 'June 2026', color: false })
|
||||
expect(out).toContain('No usage found for June 2026')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue