diff --git a/README.md b/README.md index c4fba9b..92874d6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app.tsx b/app.tsx index 891c700..a1e42c2 100644 --- a/app.tsx +++ b/app.tsx @@ -11,6 +11,7 @@ import { useMediaQuery } from "@/components/ui/hooks/use-media-query"; import { ProviderLimitsSkeleton, UsageDashboardSkeleton } from "@/components/usage-dashboard-skeleton"; import { ProviderLogo, BRAND_COLORS, modelLogoId } from "@/components/provider-logo"; import { paginateItems } from "@/lib/pagination"; +import { compareUsage, nextUsageSort, type MetricMode, type UsageSort } from "@/lib/usage-sort"; import type { UsageSyncSnapshot } from "@/lib/sync-coordinator"; import { isUsageSyncInProgress, shouldPollUsage, shouldShowInitialUsageLoading, usageRefreshError } from "@/lib/usage-sync-state"; import { getEmptyUsageView, getSourceIssueMessage } from "@/lib/usage-view-state"; @@ -18,7 +19,6 @@ import { clampPercent, formatLimitReset, formatLimitValue, isLimitVisibleOnMachi import { formatLocalMoney, localCurrency, usdToLocalRate } from "@/lib/local-currency"; type Range = 7 | 30 | 90; -type ChartMode = "cost" | "tokens"; type BreakdownMode = "model" | "project" | "day"; type DimensionMode = "agent" | "provider"; @@ -176,6 +176,61 @@ function CostValue({ value, className }: { value: number; className?: string }) ); } +// A row whose priced cost is zero but has unpriced usage shows "Unknown"; a +// partially priced row gets a "+" marker since its share understates usage. +function PricedCost({ cost, unknown }: { cost: number; unknown?: boolean }) { + return ( + + {unknown && cost === 0 ? "Unknown" : <>{unknown ? "+" : ""}} + + ); +} + +function UsageSortButton({ metric, sort, onSort }: { + metric: MetricMode; + sort: UsageSort; + onSort: (metric: MetricMode) => void; +}) { + const active = sort.metric === metric; + const next = nextUsageSort(sort, metric); + return ( + + ); +} + +function BreakdownValue({ metric, row, totals }: { + metric: MetricMode; + row: { cost: number; tokens: number; unknown?: boolean }; + totals: { cost: number; processed: number; unknownTokens: number }; +}) { + const unknownCost = row.unknown && row.cost === 0; + return ( +
+
+ {metric === "tokens" ? compact(row.tokens) : } +
+ {(metric === "tokens" || !unknownCost) && ( +
0 ? "Share of priced cost; usage with unknown pricing is excluded." : undefined} + > + {metric === "tokens" ? `${percentage(row.tokens, totals.processed)} of tokens` : `${percentage(row.cost, totals.cost)} of cost`} +
+ )} +
+ ); +} + function parseDay(day: string) { return new Date(`${day}T00:00:00Z`); } @@ -351,7 +406,7 @@ function UsageChart({ records: UsageRecord[]; providers: Array<{ id: string; name: string }>; range: Range; - mode: ChartMode; + mode: MetricMode; groupBy: DimensionMode; compactView?: boolean; }) { @@ -578,14 +633,17 @@ function UsageChart({ ); } -function ProviderCostRow({ +function ProviderShareRow({ item, + mode, total, }: { - item: { id: string; name: string; cost: number; tokens: number }; + item: { id: string; name: string; cost: number; tokens: number; unknown?: boolean }; + mode: MetricMode; total: number; }) { - const share = total > 0 ? (item.cost / total) * 100 : 0; + const value = mode === "cost" ? item.cost : item.tokens; + const share = total > 0 ? (value / total) * 100 : 0; return (
@@ -593,19 +651,29 @@ function ProviderCostRow({ {item.name} - + + {mode === "cost" + ? + : <>{compact(item.tokens)} tokens} +
0 ? 3 : 0, + minWidth: value > 0 ? 3 : 0, backgroundColor: providerColor(item.id), }} />
-
{percentage(item.cost, total)} of cost · {compact(item.tokens)} tokens
+
+ {mode === "cost" + ? <>{percentage(item.cost, total)} of cost · {compact(item.tokens)} tokens + : item.unknown && item.cost === 0 + ? <>{percentage(item.tokens, total)} of tokens · cost unknown + : <>{percentage(item.tokens, total)} of tokens · } +
); } @@ -629,16 +697,18 @@ function ChartLegend({ providers }: { providers: Array<{ id: string; name: strin const CARD_CLASSES = "rounded-xl border border-border/70 bg-muted/[0.08]"; -// A project row names its highest-cost agent and folds the rest into `+N`, -// which lists them with their own cost on hover or focus. +// A project row names its dominant agent by the active metric and folds the +// rest into `+N`, which lists them with their own value on hover or focus. function AgentCell({ agentId, agent, others, + mode, }: { agentId: string; agent: string; - others?: Array<{ id: string; name: string; cost: number }>; + others?: Array<{ id: string; name: string; cost: number; tokens: number; unknown?: boolean }>; + mode: MetricMode; }) { return ( @@ -665,7 +735,7 @@ function AgentCell({ {item.name} - {money(item.cost)} + {mode === "cost" ? : `${compact(item.tokens)} tokens`}
))} @@ -677,14 +747,6 @@ function AgentCell({ ); } -function RowBadge({ children }: { children: ReactNode }) { - return ( - - {children} - - ); -} - function LimitBadge({ children, title }: { children: ReactNode; title?: string }) { return ( ("agent"); - const [chartMode, setChartMode] = useState("tokens"); + const [chartMode, setChartMode] = useState("tokens"); + const [breakdownSort, setBreakdownSort] = useState({ metric: "tokens", direction: "descending" }); const [breakdownMode, setBreakdownMode] = useState("model"); const [mobileSection, setMobileSection] = useState<"chart" | "breakdown">("chart"); const [breakdownPage, setBreakdownPage] = useState(1); @@ -1116,7 +1179,22 @@ function UsageDashboard() { cost: number; tokens: number; unknown?: boolean; // Only project rows fold several agents into one badge; the folded ones are // listed here so the `+N` suffix can name them on hover. - otherAgents?: Array<{ id: string; name: string; cost: number }>; + otherAgents?: Array<{ id: string; name: string; cost: number; tokens: number; unknown?: boolean }>; + }; + // Chart share rows follow the chart metric; table sorting is independent. + const byChartMetric = useCallback( + (a: { cost: number; tokens: number }, b: { cost: number; tokens: number }) => + chartMode === "cost" ? b.cost - a.cost || b.tokens - a.tokens : b.tokens - a.tokens || b.cost - a.cost, + [chartMode], + ); + const byBreakdownMetric = useCallback( + (a: { cost: number; tokens: number; unknown?: boolean }, b: { cost: number; tokens: number; unknown?: boolean }) => + compareUsage(a, b, { metric: breakdownSort.metric, direction: "descending" }), + [breakdownSort.metric], + ); + const sortBreakdown = (metric: MetricMode) => { + setBreakdownSort((current) => nextUsageSort(current, metric)); + setBreakdownPage(1); }; const modelBreakdown = useMemo(() => { const map = new Map(); @@ -1128,31 +1206,34 @@ function UsageDashboard() { current.tokens += row.processedTokens; map.set(key, current); } - return [...map.values()].sort((a, b) => b.cost - a.cost || b.tokens - a.tokens); + return [...map.values()]; }, [rows]); // Projects can be worked on from several agents and providers, so a row keeps - // the dominant one by cost for its badge instead of claiming a single owner. + // the dominant one by the active metric for its badge instead of claiming a + // single owner. const projectBreakdown = useMemo(() => { - const map = new Map }>(); + const map = new Map }>(); for (const row of rows) { - const current: BreakdownRow & { byAgent: Map } = map.get(row.project) ?? { + const current: BreakdownRow & { byAgent: Map } = map.get(row.project) ?? { key: row.project, label: row.project, agent: row.agentName, agentId: row.agentId, provider: row.modelProviderName, providerId: row.modelProviderId, cost: 0, tokens: 0, - byAgent: new Map(), + byAgent: new Map(), }; current.unknown = current.unknown || row.pricingStatus === "unknown"; current.cost += row.costUsd; current.tokens += row.processedTokens; - const agent = current.byAgent.get(row.agentId) ?? { name: row.agentName, cost: 0 }; + const agent = current.byAgent.get(row.agentId) ?? { name: row.agentName, cost: 0, tokens: 0, unknown: false }; agent.cost += row.costUsd; + agent.tokens += row.processedTokens; + agent.unknown = agent.unknown || row.pricingStatus === "unknown"; current.byAgent.set(row.agentId, agent); map.set(row.project, current); } return [...map.values()].map((item) => { const ranked = [...item.byAgent.entries()] - .map(([id, value]) => ({ id, name: value.name, cost: value.cost })) - .sort((a, b) => b.cost - a.cost); + .map(([id, value]) => ({ id, ...value })) + .sort(byBreakdownMetric); const [dominant, ...others] = ranked; return { ...item, @@ -1160,8 +1241,8 @@ function UsageDashboard() { agent: dominant?.name ?? item.agent, otherAgents: others, }; - }).sort((a, b) => b.cost - a.cost || b.tokens - a.tokens); - }, [rows]); + }); + }, [rows, byBreakdownMetric]); const dayBreakdown = useMemo(() => { const map = new Map(); @@ -1221,14 +1302,20 @@ function UsageDashboard() { const activeModelProviders = data.modelProviders.filter((item) => usedProviderIds.has(item.id)); const activeProviders = chartGroup === "agent" ? activeAgents : activeModelProviders; // A single dimension control (next to the chart) drives both the chart and - // the cost rows so the agent/provider tabs never repeat. - const costDimension = chartGroup; - const costDimensions = costDimension === "agent" ? activeAgents : activeModelProviders; - const providerTotals = costDimensions.map((item) => ({ - ...item, - cost: rows.filter((row) => (costDimension === "agent" ? row.agentId : row.modelProviderId) === item.id).reduce((sum, row) => sum + row.costUsd, 0), - tokens: rows.filter((row) => (costDimension === "agent" ? row.agentId : row.modelProviderId) === item.id).reduce((sum, row) => sum + row.processedTokens, 0), - })).sort((a, b) => b.cost - a.cost || b.tokens - a.tokens); + // the share rows so the agent/provider tabs never repeat; the shared + // cost/tokens metric sets their sort order and primary value the same way. + const shareDimension = chartGroup; + const shareDimensions = shareDimension === "agent" ? activeAgents : activeModelProviders; + const providerTotals = shareDimensions.map((item) => { + const dimensionRows = rows.filter((row) => (shareDimension === "agent" ? row.agentId : row.modelProviderId) === item.id); + return { + ...item, + cost: dimensionRows.reduce((sum, row) => sum + row.costUsd, 0), + tokens: dimensionRows.reduce((sum, row) => sum + row.processedTokens, 0), + unknown: dimensionRows.some((row) => row.pricingStatus === "unknown"), + }; + }).sort(byChartMetric); + const metricTotal = chartMode === "cost" ? totals.cost : totals.processed; const visibleMachines = data.machines.filter((item) => machine === "all" || item.id === machine); const visibleSources = data.sources.filter((source) => machine === "all" || source.machineId === machine); const sourceIssueMessage = getSourceIssueMessage(visibleMachines, visibleSources); @@ -1247,9 +1334,10 @@ function UsageDashboard() { sources: visibleSources, hasRecordsOutsideView: data.records.some((record) => machine === "all" || record.machineId === machine), }); - const breakdown = breakdownMode === "model" ? modelBreakdown + const breakdownRows = breakdownMode === "model" ? modelBreakdown : breakdownMode === "project" ? projectBreakdown : dayBreakdown; + const breakdown = [...breakdownRows].sort((a, b) => compareUsage(a, b, breakdownSort)); const paginatedBreakdown = paginateItems(breakdown, breakdownPage, BREAKDOWN_PAGE_SIZE); const activeDays = new Set(rows.map((row) => row.day)).size; const visibleProviderLimits = providerLimits.filter((limit) => isLimitVisibleOnMachine(limit, machine)); @@ -1352,7 +1440,7 @@ function UsageDashboard() { {!stackedView && (
{providerTotals.map((item) => ( - + ))}
)} @@ -1413,7 +1501,7 @@ function UsageDashboard() {
{providerTotals.map((item) => (
- +
))}
@@ -1440,7 +1528,7 @@ function UsageDashboard() { {(!stackedView || mobileSection === "breakdown") && (
-
+

Breakdown

+
+ Sort by +
+ + +
+
{paginatedBreakdown.items.map((row) => ( -
-
- - {breakdownMode === "model" && ( - - )} - {row.label} - - {row.unknown && row.cost === 0 ? "Unknown" : <>{row.unknown ? "+" : ""}} +
+
+ {breakdownMode === "model" && } + {row.label}
-
- {breakdownMode !== "day" && ( - - - {row.agent} - {row.otherAgents && row.otherAgents.length > 0 && ( - `${item.name} ${money(item.cost)}`).join(" · ")} - > - +{row.otherAgents.length} - - )} - - )} - - {compact(row.tokens)} - tokens - - {row.unknown && row.cost === 0 ? "—" : `${row.unknown ? "+" : ""}${percentage(row.cost, totals.cost)}`} + {breakdownMode !== "day" && ( +
+ +
+ )} +
+ +
+ +
))}
) : (
- +
- {breakdownMode !== "day" && ( - + )} - - - + {(["tokens", "cost"] as const).map((metric) => ( + + ))} @@ -1518,18 +1600,21 @@ function UsageDashboard() { {breakdownMode !== "day" && ( )} - - - + + ))} -
+ {breakdownMode === "model" ? "Model" : breakdownMode === "project" ? "Project" : "Day"} AgentAgentCostShareTokens + +
- + {row.unknown && row.cost === 0 ? "Unknown" : <>{row.unknown ? "+" : ""}}{row.unknown && row.cost === 0 ? "—" : `${row.unknown ? "+" : ""}${percentage(row.cost, totals.cost)}`}{compact(row.tokens)} + + + +
-
- )} + +
+ )} {breakdown.length > BREAKDOWN_PAGE_SIZE && (
diff --git a/components/usage-dashboard-skeleton.tsx b/components/usage-dashboard-skeleton.tsx index a5fc9bb..aea0b62 100644 --- a/components/usage-dashboard-skeleton.tsx +++ b/components/usage-dashboard-skeleton.tsx @@ -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 @@ -331,9 +332,9 @@ export function UsageDashboardSkeleton() {
- {/* breakdown: real heading, real toggle, real column headers */} + {/* Match the fixed metric columns and default token sort. */}
-
+
- {/* wide layouts: the real table header */} - - - - - - - - - - - + {compactView ? ( +
+
+ Sort by + +
{[0, 1, 2, 3, 4].map((row) => ( -
- - - - - - - ))} - -
ModelAgentCostShareTokens
-
- - -
-
-
- - -
-
- - - - - -
- - {/* narrow layouts: the stacked card rows */} -
- {[0, 1, 2, 3, 4].map((row) => ( -
-
-
+
+
- -
-
- - - + +
+ {[0, 1].map((metric) => ( +
+ + +
+ ))} +
-
- ))} -
+ ))} +
+ ) : ( + + + + + + + + + + + {[0, 1, 2, 3, 4].map((row) => ( + + + + {[0, 1].map((metric) => ( + + ))} + + ))} + +
ModelAgent + Tokens + + Cost +
+
+ + +
+
+
+ + +
+
+ + +
+ )}
diff --git a/lib/usage-sort.test.ts b/lib/usage-sort.test.ts new file mode 100644 index 0000000..92ea7cc --- /dev/null +++ b/lib/usage-sort.test.ts @@ -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" }); + }); +}); diff --git a/lib/usage-sort.ts b/lib/usage-sort.ts new file mode 100644 index 0000000..aea1c0f --- /dev/null +++ b/lib/usage-sort.ts @@ -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]; +} diff --git a/package-lock.json b/package-lock.json index bd8d5dc..ec91798 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bb-plugin-usage", - "version": "0.3.13", + "version": "0.3.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bb-plugin-usage", - "version": "0.3.13", + "version": "0.3.14", "dependencies": { "@hugeicons/core-free-icons": "^4.1.3", "@hugeicons/react": "^1.1.6", diff --git a/package.json b/package.json index e97021a..96846e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bb-plugin-usage", - "version": "0.3.13", + "version": "0.3.14", "type": "module", "scripts": { "build": "bb plugin build",