Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions extensions/cate.usage/README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
Agent usage and cost dashboard powered by ccusage. Reads the local Claude Code data (~/.claude) on the machine running the extension server and shows a stat strip for today, this week, this month, and all time, a daily cost/token timeline, and a per-model cost split. No data leaves the machine.
Agent usage and cost dashboard powered by ccusage. Reads the local Claude Code data (~/.claude) on the machine running the extension server and shows a stat strip for today, this week, this month, and everything still on disk, a daily cost/token timeline, and a per-model cost split. No data leaves the machine.

# cate.usage

A server-backed Cate extension that turns the machine's Claude Code usage logs
into a single-screen dashboard panel (nothing scrolls):

- A stat strip: cost and tokens for today, this ISO week, this calendar month,
and all time.
and the full retained window (labelled "Since <date>", never "all time").
- The timeline: a daily bar chart (cost or tokens, 30/60/90 day window) with
hover details, flexing to the panel height.
- A per-model chip strip under the chart: cost and cost share per model, full
Expand Down Expand Up @@ -35,6 +35,15 @@ fails (offline machine), it falls back to ccusage's bundled table and the panel
shows an "offline pricing" badge, since models newer than the pinned ccusage
may then report zero cost.

### Retention: there is no "all time"

Claude Code deletes its own transcripts once they pass `cleanupPeriodDays`
(30 by default, set in `~/.claude/settings.json`). ccusage can only price what
survives, so the widest bucket this panel can honestly show is "everything
still on disk" — it is labelled `Since <first day with usage>`, and the report
carries that day as `coverageStart`. On a default install that window is the
last 30 days no matter how long the agent has actually been in use.

Note: usage data lives on the machine where the extension server runs. For a
remote workspace that is the remote host, so the dashboard shows that machine's
agent usage, not the local laptop's. If no Claude Code data exists there, the
Expand Down
2 changes: 1 addition & 1 deletion extensions/cate.usage/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"id": "cate.usage",
"name": "Agent Usage",
"version": "1.1.0",
"description": "Agent usage and cost dashboard powered by ccusage. Reads the local Claude Code data (~/.claude) on the machine running the extension server and shows cost and tokens for today, this week, this month, and all time, a daily cost/token timeline, and a per-model cost split. No data leaves the machine.",
"description": "Agent usage and cost dashboard powered by ccusage. Reads the local Claude Code data (~/.claude) on the machine running the extension server and shows cost and tokens for today, this week, this month, and everything still on disk, a daily cost/token timeline, and a per-model cost split. No data leaves the machine.",
"panels": [
{
"id": "dashboard",
Expand Down
16 changes: 14 additions & 2 deletions extensions/cate.usage/src/public/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,8 +436,9 @@ function renderChart(container: HTMLElement, points: DailyPoint[], which: Metric

// --- sections ---------------------------------------------------------------------

function stat(label: string, period: PeriodSummary): HTMLElement {
function stat(label: string, period: PeriodSummary, hint?: string): HTMLElement {
const wrap = el('div', 'us-stat')
if (hint) wrap.title = hint
wrap.appendChild(el('span', 'us-stat__label', label))
const row = el('div', 'us-stat__row')
row.appendChild(el('span', 'us-stat__value', fmtUsd(period.cost)))
Expand Down Expand Up @@ -578,7 +579,18 @@ function render(): void {
statsEl.appendChild(stat('Today', report.summary.today))
statsEl.appendChild(stat('Week', report.summary.thisWeek))
statsEl.appendChild(stat('Month', report.summary.thisMonth))
statsEl.appendChild(stat('All time', report.summary.allTime))
// NOT "all time": agents purge their own transcripts (Claude Code drops
// ~/.claude/projects entries past `cleanupPeriodDays`, 30 by default), so
// this tile covers only what is still on disk. Label it with that window.
statsEl.appendChild(
stat(
report.coverageStart ? `Since ${fmtDayLabel(report.coverageStart)}` : 'On disk',
report.summary.retained,
report.coverageStart
? `Everything still on disk: ${report.coverageStart} to ${report.today}. Not all-time — Claude Code deletes transcripts older than cleanupPeriodDays (30 by default), and usage from before ${report.coverageStart} is already gone.`
: undefined,
),
)

// Hero timeline card: metric + window toggles, flexing chart, model footer.
const metricSeg = segmented<Metric>(['cost', 'tokens'], metric, (m) => (m === 'cost' ? 'Cost' : 'Tokens'), (m) => {
Expand Down
40 changes: 32 additions & 8 deletions extensions/cate.usage/src/shape.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,24 +143,24 @@ describe('summarize', () => {
expect(s.today.cost).toBe(3)
expect(s.thisWeek.cost).toBe(50) // 40 + 7 + 3
expect(s.thisMonth.cost).toBe(10) // 7 + 3
expect(s.allTime.cost).toBe(150)
expect(s.allTime.days).toBe(4)
expect(s.retained.cost).toBe(150)
expect(s.retained.days).toBe(4)
})

it('sums token totals across all four buckets', () => {
const s = summarize(daily, today)
expect(s.today.totalTokens).toBe(10_000)
expect(s.allTime.totalTokens).toBe(40_000)
expect(s.allTime.inputTokens).toBe(4000)
expect(s.allTime.outputTokens).toBe(8000)
expect(s.retained.totalTokens).toBe(40_000)
expect(s.retained.inputTokens).toBe(4000)
expect(s.retained.outputTokens).toBe(8000)
})

it('ignores rows dated after today and handles empty input', () => {
const s = summarize([day('2026-07-05', { totalCost: 99 })], today)
expect(s.allTime.cost).toBe(0)
expect(s.retained.cost).toBe(0)
const empty = summarize([], today)
expect(empty.today.cost).toBe(0)
expect(empty.allTime.days).toBe(0)
expect(empty.retained.days).toBe(0)
})
})

Expand Down Expand Up @@ -337,6 +337,30 @@ describe('buildReport', () => {
expect(report.pricingSource).toBe('online')
})

it('reports the retained window rather than implying all-time coverage', () => {
// MULTI_MODEL_DAY is 2026-06-03; the retained bucket starts there, not at
// whenever the user actually started using the agent.
const report = buildReport(raw, '2026-07-04', { days: 30 })
expect(report.coverageStart).toBe('2026-06-03')
expect(report.summary.retained.days).toBe(2)
})

it('has no coverage start when nothing is on disk', () => {
const report = buildReport(
{ claudePaths: ['/home/user/.claude'], pricingSource: 'online', daily: [], sessions: [], monthly: [] },
'2026-07-04',
)
expect(report.coverageStart).toBeNull()
})

it('ignores future-dated rows when picking the coverage start', () => {
const report = buildReport(
{ ...raw, daily: [day('2099-01-01', { totalCost: 1 }), ...raw.daily] },
'2026-07-04',
)
expect(report.coverageStart).toBe('2026-06-03')
})

it('flags the no-claude-data empty state', () => {
const report = buildReport(
{ claudePaths: [], pricingSource: 'none', daily: [], sessions: [], monthly: [] },
Expand All @@ -345,7 +369,7 @@ describe('buildReport', () => {
expect(report.available).toBe(false)
expect(report.reason).toBe('no-claude-data')
expect(report.daily).toHaveLength(30) // zero-filled, chart-safe
expect(report.summary.allTime.cost).toBe(0)
expect(report.summary.retained.cost).toBe(0)
})

it('flags the no-usage-entries empty state when a data dir exists but is empty', () => {
Expand Down
23 changes: 21 additions & 2 deletions extensions/cate.usage/src/shape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,11 @@ export interface Summary {
today: PeriodSummary
thisWeek: PeriodSummary
thisMonth: PeriodSummary
allTime: PeriodSummary
/** Everything on disk. NOT all-time: agents purge their own transcripts
* (Claude Code deletes ~/.claude/projects entries older than
* `cleanupPeriodDays`, 30 by default), so this covers only what survived.
* Pair it with UsageReport.coverageStart when labelling. */
retained: PeriodSummary
}

export interface DailyPoint {
Expand Down Expand Up @@ -104,6 +108,9 @@ export interface UsageReport {
reason?: 'no-claude-data' | 'no-usage-entries'
generatedAt: string
today: string
/** First day with usage on disk, or null when there is none. The summary's
* `retained` bucket spans coverageStart..today and nothing earlier. */
coverageStart: string | null
claudePaths: string[]
pricingSource: 'online' | 'offline' | 'none'
summary: Summary
Expand Down Expand Up @@ -172,10 +179,21 @@ export function summarize(daily: DailyRow[], today: string): Summary {
today: sumPeriod(upToToday.filter((r) => r.date === today)),
thisWeek: sumPeriod(upToToday.filter((r) => r.date >= week)),
thisMonth: sumPeriod(upToToday.filter((r) => r.date >= month)),
allTime: sumPeriod(upToToday),
retained: sumPeriod(upToToday),
}
}

/** Earliest day with usage on disk (null when there is none) — the real start
* of the retained window, which the panel labels instead of "all time". */
export function coverageStartOf(daily: DailyRow[], today: string): string | null {
let first: string | null = null
for (const row of daily) {
if (row.date > today) continue
if (first === null || row.date < first) first = row.date
}
return first
}

/** The last `days` calendar days ending at `today`, zero-filled so the chart
* has one point per day even when nothing ran. */
export function dailySeries(daily: DailyRow[], today: string, days: number): DailyPoint[] {
Expand Down Expand Up @@ -306,6 +324,7 @@ export function buildReport(raw: RawUsage, today: string, opts: ReportOptions =
...(available ? {} : { reason: hasPaths ? ('no-usage-entries' as const) : ('no-claude-data' as const) }),
generatedAt: new Date().toISOString(),
today,
coverageStart: coverageStartOf(raw.daily, today),
claudePaths: raw.claudePaths,
pricingSource: raw.pricingSource,
summary: summarize(raw.daily, today),
Expand Down
Loading