codeburn/src/overview.ts
ozymandiashh a12db6e549
Show real Claude project leaf names; stop stray-.git over-grouping (#540)
The 'By Project' panel collapsed sibling Claude Code projects to a parent
folder in two cases:

1) resolveCanonicalProjectPath walked up to ANY ancestor .git and used it as
   the canonical project. A stray .git in a parent of several projects (a
   dotfiles bare repo, an accidental git init) made every sibling resolve to
   that parent ('Projects'/'home'). Now only a real linked worktree (.git is a
   FILE pointing at <main>/.git/worktrees/<name>) canonicalizes to its main
   repo; an ordinary repo or a stray ancestor .git keeps the session's own cwd
   as the project. Genuine worktree grouping is preserved.

2) When a Claude session has no cwd metadata, the dir slug was unsanitized by
   replacing every '-' with '/', inventing path segments that overview then
   split on, so 'Projects-Content-OS' rendered as 'os'. The lossy slug is now
   kept intact, and overview only basenames ABSOLUTE paths.

Fixes #493
2026-06-21 22:42:19 +02:00

260 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { getShortModelName } from './models.js'
import { dateKey } from './day-aggregator.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 formatTokens(n: number): string {
return Math.round(n).toLocaleString()
}
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 }
// 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')
}
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(' (breakdown below)')))
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('')
// 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), 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'
}