import { Chalk, type ChalkInstance } from 'chalk' import { homedir } from 'os' import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js' import { formatCost as baseCost, getCurrency } from './currency.js' import { findUnpricedModels, getShortModelName } from './models.js' import { markEstimated } from './format.js' import { dateKey } from './day-aggregator.js' import type { DailyEntry } from './daily-cache.js' import type { BudgetStatus, BudgetTier } from './budget.js' // Display-only helpers. The shared formatters omit thousands separators and // abbreviate; here we show full, comma-grouped numbers so the tables read like // a precise statement. Aggregation uses raw numbers; these only affect render. function formatCost(usd: number): string { return baseCost(usd).replace(/(\d)(?=(\d{3})+(\.|$))/g, '$1,') } function formatDisplayCost(amount: number): string { const { rate } = getCurrency() return formatCost(rate > 0 ? amount / rate : amount) } function formatTokens(n: number): string { // Pin the locale so grouping is deterministic regardless of the host's // locale (e.g. en-IN groups as 2,00,20,00,000 instead of 2,002,000,000). return Math.round(n).toLocaleString('en-US') } // Integer counts (calls, sessions, turns, tool uses) — same locale pin so the // overview output is byte-identical across machines. function formatCount(n: number): string { return n.toLocaleString('en-US') } function isAbsoluteProjectPath(path: string): boolean { return path.startsWith('/') || path.startsWith('\\') || /^[a-zA-Z]:[/\\]/.test(path) } function projectName(p: ProjectSummary): string { const path = p.projectPath if (path) { if (path === homedir()) return 'Home' if (!isAbsoluteProjectPath(path)) return p.project || path 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 } type OverviewBudget = { tier: BudgetTier status: BudgetStatus inProgress: 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 gap = ' ' // 2-space cell padding so columns breathe const sep = gap + c.dim('│') + gap const edge = c.dim('│') const bar = (l: string, mid: string, r: string): string => c.dim(l + widths.map((w) => '─'.repeat(w + 4)).join(mid) + r) const line = (cells: string[], header = false): string => edge + gap + cells.map((cell, i) => { const padded = pad(cell, widths[i]!, cols[i]!.right) return header ? c.bold(padded) : padded }).join(sep) + gap + edge return [ bar('┌', '┬', '┐'), line(cols.map((col) => col.header), true), bar('├', '┼', '┤'), ...rows.map((r) => line(r)), bar('└', '┴', '┘'), ].join('\n') } /// The durable slice renderOverview needs: headline totals + the day set behind /// them + how much of the total came from carried (expired-source) days. export type OverviewDurable = { cost: number savingsUSD: number calls: number sessions: number inputTokens: number outputTokens: number cacheReadTokens: number cacheWriteTokens: number days: DailyEntry[] carriedCostUSD: number } export function renderOverview( projects: ProjectSummary[], opts: { label: string; color: boolean; budget?: OverviewBudget; durable?: OverviewDurable }, ): string { const c = new Chalk(opts.color ? {} : { level: 0 }) const heading = (text: string): string => c.cyan.bold(text) const out: string[] = [] const durable = opts.durable out.push(c.bold('CodeBurn') + c.dim(' ' + opts.label)) out.push('') if (projects.length === 0 && !(durable && durable.cost > 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() const byModel = new Map() const byCat = new Map() const byTool = new Map() const byDay = new Map }>() const byProject = new Map() 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, estimatedCost: 0 } e.cost += d.costUSD e.calls += d.calls e.estimatedCost += d.estimatedCostUSD ?? 0 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() } dd.cost += call.costUSD dd.tokens += tk dd.providers.add(call.provider) byDay.set(day, dd) } } } } } // Headline totals and the day-resolved views (Daily, Highest-value days) come // from the durable daily cache so they match the menubar exactly, carried // (expired-source) days included. The per-tool / per-model / per-project // breakdowns above stay live: they need surviving session detail. if (durable) { cost = durable.cost savings = durable.savingsUSD calls = durable.calls sessions = durable.sessions inTok = durable.inputTokens outTok = durable.outputTokens cacheR = durable.cacheReadTokens cacheW = durable.cacheWriteTokens byDay.clear() for (const d of durable.days) { byDay.set(d.date, { cost: d.cost, tokens: d.inputTokens + d.outputTokens + d.cacheReadTokens + d.cacheWriteTokens, providers: new Set(Object.keys(d.providers)), }) } } 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(' (breakdown below)'))) out.push(kv('Calls', formatCount(calls) + c.dim(' sessions ') + formatCount(sessions))) out.push(kv('Cache hit', `${cacheHit.toFixed(1)}%`)) if (savings > 0) out.push(kv('Savings', formatCost(savings) + c.dim(' (local models)'))) const unpriced = findUnpricedModels( [...byModel.entries()].map(([model, d]) => ({ model, calls: d.calls, cost: d.cost, tokens: d.tokens })), ) if (unpriced.length > 0) { const shown = unpriced.slice(0, 3) .map((u) => `${u.model} (${formatTokens(u.tokens)} tok)`) .join(', ') const more = unpriced.length > 3 ? ` +${unpriced.length - 3} more` : '' out.push(kv('Unpriced', c.yellow(`${unpriced.length} model${unpriced.length === 1 ? '' : 's'} at $0: `) + shown + more)) out.push(kv('', c.dim('Fix: codeburn model-alias "" '))) } if (opts.budget) { const label = opts.budget.tier === 'daily' ? 'Daily' : opts.budget.tier === 'weekly' ? 'Weekly' : 'Monthly' const status = opts.budget.status const pct = `${Math.floor(status.pct)}%` const statusColor = status.state === 'over' ? c.red : status.state === 'warn' ? c.yellow : c.green const projected = opts.budget.inProgress ? c.dim(` projected ${formatDisplayCost(status.projected)} by ${opts.budget.tier === 'monthly' ? 'month' : opts.budget.tier === 'weekly' ? 'week' : 'day'} end`) : '' out.push(' ' + statusColor(`${label} budget: ${formatDisplayCost(status.spent)} of ${formatDisplayCost(status.budget)} (${pct})`) + projected) } out.push('') // Tokens breakdown: input / output / cache in (written) / cache out (read) if (totalTokens > 0) { const share = (n: number): string => `${Math.round((n / totalTokens) * 100)}%` out.push(heading('Tokens')) out.push(renderTable(c, [{ header: 'Type' }, { header: 'Tokens', right: true }, { header: 'Share', right: true }], [ ['Input', formatTokens(inTok), share(inTok)], ['Output', formatTokens(outTok), share(outTok)], ['Cache in', formatTokens(cacheW), share(cacheW)], ['Cache out', formatTokens(cacheR), share(cacheR)], ['Total', formatTokens(totalTokens), '100%'], ], )) 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), markEstimated(formatCost(v.cost), v.estimatedCost > 0), formatCount(v.calls), formatTokens(v.tokens)]), )) if (modelRows.some(([, v]) => v.estimatedCost > 0)) { out.push(' ' + c.dim('~ estimated cost (priced from estimated 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), formatCount(v.sessions)]), )) 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), formatCount(v.turns)]), )) 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, formatCount(n)]), )) 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}.`) // When some of the period's total came from days whose session logs have since // expired, say so once. The figure is real (preserved in the durable daily // cache); it just can't be re-derived from surviving files anymore. if (durable && durable.carriedCostUSD > 0) { out.push(c.dim(` includes ${formatCost(durable.carriedCostUSD)} preserved from expired session logs`)) } return out.join('\n') + '\n' }