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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ Track coding-agent token usage and estimated API cost across every machine enrol

- Collect usage from Codex, Claude Code, DeepSeek Harness, Devin, FX, Grok Agent, OpenCode, Pi, Prime Agent, Antigravity, and Thaura.
- Separate the coding agent from the underlying model provider.
- Group charts and cost summaries by agent or model provider.
- Group charts and usage shares by agent or model provider.
- Switch the chart and provider shares between cost and tokens.
- Sort breakdowns by tokens or cost using column headers, with each metric’s share shown beneath its value. Unknown costs stay visible without a misleading percentage.
- Break usage down by model, project, or day.
- Filter by machine, agent, model provider, and the last 7, 30, or 90 days.
- Show exact, alias-matched, agent-reported, and unknown pricing in the breakdown table.
Expand Down
257 changes: 171 additions & 86 deletions app.tsx

Large diffs are not rendered by default.

121 changes: 65 additions & 56 deletions components/usage-dashboard-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Icon } from "@/components/ui/icon";
import { ToggleGroupPreview } from "@/components/ui/toggle-group";

// The theme exposes its colors as complete color-mix() values rather than HSL
Expand Down Expand Up @@ -331,9 +332,9 @@ export function UsageDashboardSkeleton() {
</div>
</section>

{/* breakdown: real heading, real toggle, real column headers */}
{/* Match the fixed metric columns and default token sort. */}
<section>
<div className="flex items-center justify-between gap-4">
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<Label className="text-sm font-semibold">Breakdown</Label>
<ToggleGroupPreview
value="model"
Expand All @@ -346,65 +347,73 @@ export function UsageDashboardSkeleton() {
</div>

<div className={`mt-3 overflow-hidden ${CARD_CLASSES}`}>
{/* wide layouts: the real table header */}
<table className="hidden w-full border-collapse text-sm sm:table">
<thead>
<tr className="border-b border-border bg-muted/20 text-xs text-muted-foreground">
<th className="px-4 py-2.5 text-left font-medium">Model</th>
<th className="px-4 py-2.5 text-left font-medium">Agent</th>
<th className="px-4 py-2.5 text-right font-medium">Cost</th>
<th className="px-4 py-2.5 text-right font-medium">Share</th>
<th className="px-4 py-2.5 text-right font-medium">Tokens</th>
</tr>
</thead>
<tbody>
{compactView ? (
<div>
<div className="flex items-center justify-between gap-3 border-b border-border/60 bg-muted/20 px-3.5 py-1 text-xs text-muted-foreground">
<span>Sort by</span>
<div className="flex items-center gap-3" aria-hidden="true">
<span className="inline-flex min-h-8 items-center gap-1.5 px-1 font-medium text-foreground [@media(pointer:coarse)]:min-h-11">Tokens <Icon name="ArrowDown" className="size-3.5" /></span>
<span className="inline-flex min-h-8 items-center gap-1.5 px-1 font-medium [@media(pointer:coarse)]:min-h-11">Cost <Icon name="ArrowUpDown" className="size-3.5" /></span>
</div>
</div>
{[0, 1, 2, 3, 4].map((row) => (
<tr key={row} className="border-b border-border/60 last:border-0">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Shimmer className="size-[18px] shrink-0 rounded-[4px]" />
<Shimmer className="h-3.5 w-40 rounded" style={{ animationDelay: `${-row * 0.18}s` }} />
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Shimmer className="size-[18px] shrink-0 rounded-[4px]" />
<Shimmer className="h-3.5 w-20 rounded" style={{ animationDelay: `${-row * 0.18}s` }} />
</div>
</td>
<td className="px-4 py-3">
<Shimmer className="ml-auto h-3.5 w-16 rounded" style={{ animationDelay: `${-row * 0.18}s` }} />
</td>
<td className="px-4 py-3">
<Shimmer className="ml-auto h-3.5 w-12 rounded" style={{ animationDelay: `${-row * 0.18}s` }} />
</td>
<td className="px-4 py-3">
<Shimmer className="ml-auto h-3.5 w-12 rounded" style={{ animationDelay: `${-row * 0.18}s` }} />
</td>
</tr>
))}
</tbody>
</table>

{/* narrow layouts: the stacked card rows */}
<div className="sm:hidden">
{[0, 1, 2, 3, 4].map((row) => (
<div key={row} className="border-t border-border/60 px-3.5 py-3 first:border-t-0">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div key={row} className="border-b border-border/60 px-3.5 py-3 last:border-b-0">
<div className="flex items-center gap-2">
<Shimmer className="size-[18px] shrink-0 rounded-[4px]" />
<Shimmer className="h-3.5 w-full max-w-[150px] rounded" style={{ animationDelay: `${-row * 0.18}s` }} />
</div>
<Shimmer className="h-3.5 w-14 shrink-0 rounded" style={{ animationDelay: `${-row * 0.18}s` }} />
</div>
<div className="mt-2 flex items-center gap-2">
<Shimmer className="h-[22px] w-24 rounded-md" />
<Shimmer className="h-[22px] w-20 rounded-md" />
<Shimmer className="ml-auto h-3 w-10 rounded" />
<Shimmer className="mt-1.5 h-4 w-24 rounded" />
<div className="mt-3 grid grid-cols-2 gap-4">
{[0, 1].map((metric) => (
<div key={metric} className={metric === 1 ? "justify-self-end" : ""}>
<Shimmer className={`h-5 w-16 rounded ${metric === 1 ? "ml-auto" : ""}`} />
<Shimmer className="mt-0.5 h-4 w-24 rounded" />
</div>
))}
</div>
</div>
</div>
))}
</div>
))}
</div>
) : (
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-border bg-muted/20 text-xs text-muted-foreground">
<th className="px-4 py-2.5 text-left font-medium">Model</th>
<th className="px-4 py-2.5 text-left font-medium">Agent</th>
<th className="px-3 py-1 text-right font-medium text-foreground">
<span className="inline-flex min-h-8 items-center gap-1.5 px-1 [@media(pointer:coarse)]:min-h-11">Tokens <Icon name="ArrowDown" className="size-3.5" aria-hidden="true" /></span>
</th>
<th className="px-3 py-1 text-right font-medium">
<span className="inline-flex min-h-8 items-center gap-1.5 px-1 [@media(pointer:coarse)]:min-h-11">Cost <Icon name="ArrowUpDown" className="size-3.5" aria-hidden="true" /></span>
</th>
</tr>
</thead>
<tbody>
{[0, 1, 2, 3, 4].map((row) => (
<tr key={row} className="border-b border-border/60 last:border-0">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Shimmer className="size-[18px] shrink-0 rounded-[4px]" />
<Shimmer className="h-3.5 w-40 rounded" style={{ animationDelay: `${-row * 0.18}s` }} />
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Shimmer className="size-[18px] shrink-0 rounded-[4px]" />
<Shimmer className="h-3.5 w-20 rounded" style={{ animationDelay: `${-row * 0.18}s` }} />
</div>
</td>
{[0, 1].map((metric) => (
<td key={metric} className="px-4 py-3">
<Shimmer className="ml-auto h-5 w-16 rounded" style={{ animationDelay: `${-row * 0.18}s` }} />
<Shimmer className="ml-auto mt-0.5 h-4 w-24 rounded" style={{ animationDelay: `${-row * 0.18}s` }} />
</td>
))}
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
</main>
Expand Down
38 changes: 38 additions & 0 deletions lib/usage-sort.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { compareUsage, nextUsageSort, type UsageSort } from "./usage-sort";

describe("usage sorting", () => {
const rows = [
{ id: "paid", cost: 10, tokens: 100 },
{ id: "unpriced", cost: 0, tokens: 900, unknown: true },
{ id: "free", cost: 0, tokens: 20 },
{ id: "partial", cost: 2, tokens: 400, unknown: true },
];
const ranked = (sort: UsageSort) => [...rows].sort((a, b) => compareUsage(a, b, sort)).map((row) => row.id);

it("includes unpriced usage in token rankings", () => {
expect(ranked({ metric: "tokens", direction: "descending" })).toEqual(["unpriced", "partial", "paid", "free"]);
expect(ranked({ metric: "tokens", direction: "ascending" })).toEqual(["free", "paid", "partial", "unpriced"]);
});

it("distinguishes free from unknown cost in both directions", () => {
expect(ranked({ metric: "cost", direction: "ascending" })).toEqual(["free", "partial", "paid", "unpriced"]);
expect(ranked({ metric: "cost", direction: "descending" })).toEqual(["paid", "partial", "free", "unpriced"]);
});

it("keeps token volume useful when all costs are unknown", () => {
const unpriced = rows.map((row) => ({ ...row, cost: 0, unknown: true }));
for (const direction of ["ascending", "descending"] as const) {
expect(unpriced.sort((a, b) => compareUsage(a, b, { metric: "cost", direction })).map((row) => row.id))
.toEqual(["unpriced", "partial", "paid", "free"]);
}
});

it("reverses the active column and starts a new column largest first", () => {
const initial: UsageSort = { metric: "tokens", direction: "descending" };
const ascending = nextUsageSort(initial, "tokens");
expect(ascending).toEqual({ metric: "tokens", direction: "ascending" });
expect(nextUsageSort(ascending, "tokens")).toEqual(initial);
expect(nextUsageSort(ascending, "cost")).toEqual({ metric: "cost", direction: "descending" });
});
});
25 changes: 25 additions & 0 deletions lib/usage-sort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export type MetricMode = "cost" | "tokens";
export type UsageSort = { metric: MetricMode; direction: "ascending" | "descending" };
type UsageValue = { cost: number; tokens: number; unknown?: boolean };

export function nextUsageSort(current: UsageSort, metric: MetricMode): UsageSort {
return {
metric,
direction: current.metric === metric && current.direction === "descending" ? "ascending" : "descending",
};
}

export function compareUsage(a: UsageValue, b: UsageValue, sort: UsageSort): number {
// An unknown cost is not a zero-dollar cost. Keep it after known values in
// either direction, while still allowing its tokens to sort normally.
if (sort.metric === "cost") {
const aUnknown = Boolean(a.unknown && a.cost === 0);
const bUnknown = Boolean(b.unknown && b.cost === 0);
if (aUnknown !== bUnknown) return aUnknown ? 1 : -1;
}
const otherMetric = sort.metric === "cost" ? "tokens" : "cost";
const difference = a[sort.metric] - b[sort.metric];
// Equal primary values keep the larger secondary value first in either
// direction, so reversing a sort does not shuffle ties unnecessarily.
return (sort.direction === "ascending" ? difference : -difference) || b[otherMetric] - a[otherMetric];
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "bb-plugin-usage",
"version": "0.3.13",
"version": "0.3.14",
"type": "module",
"scripts": {
"build": "bb plugin build",
Expand Down