Real-time token, throughput, prompt-cache hit rate and USD cost telemetry for a multi-step agent run, opening into a per-step model breakdown.
Set your access key from API keys as NEXTUI_TOKEN in .env.local and add the @nextui namespace below to components.json once. Then every item installs by name. Each install counts as a copy on a free account.
$pnpm dlx shadcn@latest add @nextui/cost-tracker
1{2"registries": {3"@nextui": {4"url": "https://nextui.dev/r/{name}.json",5"headers": { "Authorization": "Bearer ${NEXTUI_TOKEN}" }6}7}8}
1"use client";23import { CostTracker } from "@/components/nextui/cost-tracker";45export function RunTelemetry() {6return <CostTracker tokensPerSec={94} cacheHitPct={86} />;7}
| Prop | Type | Default | Description |
|---|---|---|---|
| steps | CostStep[] | – | Per-step model, input/output tokens, USD cost and prompt cache hit flag |
| tokensPerSec | number | 92 | Throughput in tokens per second |
| cacheHitPct | number | 88 | Prompt cache hit percentage |
| defaultOpen | boolean | true | Start with the per-step breakdown open |
| ttftMs | number | "280" | Time to first token in ms |
| autoPlay | boolean | false | Loop a scripted token and cost accumulation demo |
| className | string | – | Extra classes on the wrapper |
"use client";
import { useEffect, useRef, useState } from "react";
import { ChevronDown, Coins } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion, useSpring, useTransform } from "motion/react";
import { cn } from "@/lib/utils";
export type CostStep = {
id: string;
label: string;
model: string;
inputTokens: number;
outputTokens: number;
costUsd: number;
cached?: boolean;
/** Share of input tokens served from the prompt cache, 0–100. */
cachePct?: number;
latencyMs?: number;
};
export type CostTrackerProps = {
steps?: CostStep[];
tokensPerSec?: number;
/** Time to first token, in ms. */
ttftMs?: number;
cacheHitPct?: number;
defaultOpen?: boolean;
/** Loop a scripted run where cost, speed and steps accrue live. */
autoPlay?: boolean;
className?: string;
};
const DEFAULT_STEPS: CostStep[] = [
{ id: "s1", label: "Planner", model: "kite-large", inputTokens: 14200, outputTokens: 640, costUsd: 0.009, cachePct: 92, latencyMs: 1100 },
{ id: "s2", label: "Code edit · sub-agent", model: "kite-large", inputTokens: 18400, outputTokens: 1920, costUsd: 0.021, cachePct: 88, latencyMs: 3400 },
{ id: "s3", label: "Test runner", model: "kite-mini", inputTokens: 6100, outputTokens: 310, costUsd: 0.003, cachePct: 61, latencyMs: 900 },
{ id: "s4", label: "Reviewer", model: "kite-mini", inputTokens: 9800, outputTokens: 520, costUsd: 0.005, cachePct: 94, latencyMs: 1200 },
];
const SHADES = ["bg-foreground", "bg-foreground/60", "bg-foreground/35", "bg-foreground/15"];
const SPAN: [number, number][] = [
[300, 1500],
[1500, 4200],
[4200, 5100],
[5100, 6400],
];
const EASE = [0.22, 1, 0.36, 1] as const;
const TICK = 100;
const LOOP = 9400;
function useDemoClock(enabled: boolean, loop: number) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el || !enabled) return;
const io = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));
io.observe(el);
return () => io.disconnect();
}, [enabled]);
useEffect(() => {
if (!enabled || !visible) return;
const id = setInterval(() => setElapsed((e) => e + TICK), TICK);
return () => clearInterval(id);
}, [enabled, visible]);
return { ref, t: elapsed % loop };
}
/** Tokens per second at demo time t: smooth but lively, settling on `base`. */
function speedAt(t: number, base: number) {
if (t < 300 || t > 6400) return base;
return Math.round(base + 9 * Math.sin(t / 260) + 5 * Math.sin(t / 97));
}
function AnimatedNumber({ value, format }: { value: number; format: (v: number) => string }) {
const spring = useSpring(value, { stiffness: 120, damping: 22 });
useEffect(() => {
spring.set(value);
}, [value, spring]);
const text = useTransform(spring, format);
return <motion.span>{text}</motion.span>;
}
/** Live FinOps readout for an agent run: total cost, tok/s with sparkline, TTFT, prompt-cache hit rate, and a per-step / sub-agent breakdown. */
export function CostTracker({
steps = DEFAULT_STEPS,
tokensPerSec = 92,
ttftMs = 280,
cacheHitPct = 88,
defaultOpen = true,
autoPlay = false,
className,
}: CostTrackerProps) {
const reduce = useReducedMotion() ?? false;
const [touched, setTouched] = useState(false);
const [openState, setOpen] = useState(defaultOpen);
const demo = autoPlay && !reduce && !touched;
const { ref, t } = useDemoClock(demo, LOOP);
const progress = steps.map((_, i) => {
if (!demo) return 1;
const [a, b] = SPAN[i] ?? [0, 0];
return Math.max(0, Math.min(1, (t - a) / (b - a)));
});
const live = demo && t >= 300 && t < 6400;
const cost = steps.reduce((n, s, i) => n + s.costUsd * progress[i], 0);
const tokens = steps.reduce((n, s, i) => n + (s.inputTokens + s.outputTokens) * progress[i], 0);
const speed = demo ? speedAt(t, tokensPerSec) : tokensPerSec;
const history = Array.from({ length: 24 }, (_, k) => (demo ? speedAt(t - (23 - k) * 260, tokensPerSec) : speedAt(1000 + k * 260, tokensPerSec)));
const ttftShown = demo && t < 600 ? 0 : ttftMs;
const cache = demo ? Math.round(cacheHitPct * Math.min(1, t / 1500)) : cacheHitPct;
const open = openState;
const min = Math.min(...history) - 4;
const max = Math.max(...history) + 4;
const path = history.map((v, k) => `${k === 0 ? "M" : "L"}${(k / 23) * 100},${28 - ((v - min) / (max - min)) * 26}`).join(" ");
return (
<div ref={ref} className={cn("w-full max-w-md text-sm", className)}>
<div className="overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-18px_rgb(0_0_0/0.25)]">
{/* Total */}
<div className="flex items-center gap-3 bg-gradient-to-b from-muted/50 to-transparent px-4 pt-3.5 pb-3">
<span className="grid size-9 shrink-0 place-items-center rounded-xl border bg-background shadow-xs">
<Coins className="size-4" aria-hidden />
</span>
<div className="min-w-0 flex-1">
<p className="text-[11px] text-muted-foreground">Run cost</p>
<p className="font-mono text-xl leading-tight font-semibold tracking-tight tabular-nums">
<AnimatedNumber value={cost} format={(v) => `$${v.toFixed(3)}`} />
</p>
</div>
<div className="text-right">
<span
className={cn(
"inline-flex h-5 items-center gap-1 rounded-full border px-1.5 text-[10px] font-medium",
live ? "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300" : "text-muted-foreground",
)}
>
<span className={cn("size-1.5 rounded-full", live ? "bg-sky-500" : "bg-muted-foreground/50", live && !reduce && "animate-pulse")} />
{live ? "Live" : "Done"}
</span>
<p className="mt-1 font-mono text-[10px] text-muted-foreground tabular-nums">
<AnimatedNumber value={tokens} format={(v) => `${(v / 1000).toFixed(1)}k tok`} />
</p>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-2 px-3">
<Stat label="Speed">
<div className="flex items-end justify-between gap-1">
<span className="font-mono text-sm font-semibold tabular-nums">
{speed}
<span className="ml-0.5 text-[10px] font-normal text-muted-foreground">tok/s</span>
</span>
</div>
<svg viewBox="0 0 100 30" preserveAspectRatio="none" className="mt-1 h-5 w-full overflow-visible" aria-hidden>
<path d={`${path} L100,30 L0,30 Z`} className="fill-foreground/[0.07]" />
<path d={path} fill="none" className="stroke-foreground" strokeWidth={1.5} vectorEffect="non-scaling-stroke" strokeLinejoin="round" />
</svg>
</Stat>
<Stat label="TTFT">
<span className="font-mono text-sm font-semibold tabular-nums">
{ttftShown ? <AnimatedNumber value={ttftShown} format={(v) => `${Math.round(v)}`} /> : "—"}
<span className="ml-0.5 text-[10px] font-normal text-muted-foreground">ms</span>
</span>
<span className="mt-2.5 block h-1.5 overflow-hidden rounded-full bg-muted" title="Against a 500ms target">
<motion.span
className={cn("block h-full rounded-full", ttftMs <= 500 ? "bg-emerald-500" : "bg-amber-500")}
initial={false}
animate={{ width: `${Math.min(100, (ttftShown / 500) * 100)}%` }}
transition={{ duration: 0.6, ease: EASE }}
/>
</span>
</Stat>
<Stat label="Prompt cache">
<div className="flex items-center gap-2">
<svg viewBox="0 0 36 36" className="size-8 shrink-0 -rotate-90" aria-hidden>
<circle cx="18" cy="18" r="14" fill="none" className="stroke-muted" strokeWidth="4" />
<motion.circle
cx="18"
cy="18"
r="14"
fill="none"
className="stroke-emerald-500"
strokeWidth="4"
strokeLinecap="round"
initial={false}
animate={{ pathLength: cache / 100 }}
transition={{ duration: 0.5, ease: EASE }}
/>
</svg>
<span className="font-mono text-sm leading-tight font-semibold tabular-nums">
{cache}%<span className="block text-[10px] font-normal text-muted-foreground">cached</span>
</span>
</div>
</Stat>
</div>
{/* Split bar */}
<div className="px-4 pt-3">
<div className="flex h-2 gap-px overflow-hidden rounded-full bg-muted" aria-hidden>
{steps.map((s, i) => (
<motion.span
key={s.id}
className={cn("h-full", SHADES[i % SHADES.length])}
initial={false}
animate={{ width: `${(s.costUsd * progress[i] * 100) / Math.max(0.0001, steps.reduce((n, x) => n + x.costUsd, 0))}%` }}
transition={{ duration: 0.15, ease: "linear" }}
/>
))}
</div>
</div>
{/* Breakdown */}
<button
type="button"
aria-expanded={open}
onClick={() => {
setTouched(true);
setOpen(!open);
}}
className="flex w-full cursor-pointer items-center justify-between px-4 pt-2.5 pb-2 text-[11px] text-muted-foreground outline-none hover:text-foreground focus-visible:text-foreground"
>
Per step · {steps.length} calls
<ChevronDown className={cn("size-3.5 transition-transform duration-300", open && "rotate-180")} aria-hidden />
</button>
<AnimatePresence initial={false}>
{open && (
<motion.ul
initial={reduce ? false : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={reduce ? undefined : { height: 0, opacity: 0 }}
transition={{ duration: 0.35, ease: EASE }}
className="overflow-hidden border-t pb-1.5"
>
{steps.map((s, i) => {
const p = progress[i];
const state = p >= 1 ? "done" : p > 0 ? "live" : "queued";
return (
<li key={s.id} className={cn("flex items-center gap-2.5 px-4 py-1.5 text-xs transition-opacity duration-300", state === "queued" && "opacity-40")}>
<span className={cn("size-2 shrink-0 rounded-sm", SHADES[i % SHADES.length], state === "live" && !reduce && "animate-pulse")} />
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{s.label}</span>
<span className="block truncate font-mono text-[10px] text-muted-foreground">
{s.model} · {(s.inputTokens / 1000).toFixed(1)}k in / {s.outputTokens} out
{s.cachePct !== undefined && ` · ${s.cachePct}% cache`}
{s.latencyMs !== undefined && ` · ${(s.latencyMs / 1000).toFixed(1)}s`}
</span>
</span>
<span className="shrink-0 font-mono text-[11px] font-medium tabular-nums">${(s.costUsd * p).toFixed(3)}</span>
</li>
);
})}
</motion.ul>
)}
</AnimatePresence>
</div>
</div>
);
}
function Stat({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="min-w-0 rounded-xl border bg-background px-2.5 py-2">
<p className="mb-1 truncate font-mono text-[9px] tracking-wider text-muted-foreground uppercase">{label}</p>
{children}
</div>
);
}
export default function CostTrackerDemo() {
return (
<div className="flex h-[420px] w-full items-start justify-center pt-2">
<CostTracker autoPlay />
</div>
);
}