Throughput Meter
A live tokens-per-second readout — rolling sparkline, time to first token, current-vs-average delta, a stall flag raised by the meter's own clock, and a run summary when the stream ends.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/throughput-meter.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ThroughputMeter" component (lucide-react
for icons; no chart library — the sparkline is hand-drawn SVG). It reports how
fast a model is generating right now, and what the run added up to when it ends.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>
minus children. Merge className with cn().
- tokens: number — tokens received SO FAR (a running count, not a delta).
- startedAt?: number — epoch ms the request was sent (drives TTFT + duration).
- firstTokenAt?: number — epoch ms the first token landed. Derived internally
from the first non-zero `tokens` when omitted.
- endedAt?: number — epoch ms the run ended. Required for a run that finished
before the component mounted; without it there is no honest end.
- status?: "idle" | "streaming" | "complete" | "error" (default "streaming").
- runId?: string — run identity; defaults to String(startedAt). Changing it
clears samples, trend and peak.
- variant?: "panel" | "compact" (default "panel").
- windowMs?: number (3000) — trailing window the current rate is measured over.
- sampleMs?: number (250) — sampler beat, also one sparkline bucket.
- historyLength?: number (48) — buckets kept, so ~12 s of trend by default.
- stallAfterMs?: number (2500) — silence that raises the stall flag.
- stalled?: boolean — override; undefined means "use my own detection".
- averageFrom?: "first-token" | "request-start" (default "first-token").
- unit?: string ("tok/s"), unitLabel?: string ("tokens per second"),
label?: string (overrides the phase caption).
- showSparkline / showStats / showComparison?: boolean (all true).
- announce?: "milestones" | "off" (default "milestones").
- onStallChange?: (stalled: boolean) => void — fires on the edge only.
- Take NO pre-computed rate and NO array of samples. The caller's stream
handler must stay `setTokens(n => n + chunk.length)`; every derived number
below belongs to the component.
Behavior — sampling (the core)
- Keep one piece of state ("track"): a trailing array of {at, tokens} samples,
an array of bucket rates, the newest observation clock, the token count at
that clock, firstTokenAt, lastChangeAt and the peak rate.
- TWO writers, deliberately separate:
* arrival bookkeeping — runs when the `tokens` prop changes; records the
clock, firstTokenAt and lastChangeAt. It must NOT push a bucket.
* the sampler — one setInterval at sampleMs, alive only while
status === "streaming"; pushes a time-uniform bucket and prunes the window.
Keeping arrivals out of the buckets is what makes the sparkline's x axis
uniform in TIME rather than in tokens.
- Do the arrival bookkeeping DURING RENDER with the prev-props pattern
(`const [prev, setPrev] = useState(tokens); if (prev !== tokens) {…}`), not in
an effect: the gap it records is what the stall detector reads, and an effect
would mark it one paint late.
- Bucket rate = (headTokens - anchorTokens) / (headAt - anchorAt), where the
anchor is the oldest sample still inside `now - windowMs` (or the oldest one
there is while the window warms up). Never rate = 1 / lastGap: a single
inter-token gap is not a throughput.
- The live figure is the NEWEST BUCKET, not a reading recomputed at render
time. The beat owns the repaint — a burst landing between two beats cannot
make the number twitch — and the figure always equals the dot at the end of
the sparkline, so the two never disagree.
- Clamp the anchor so the window never reaches back across firstTokenAt. A 2 s
wait followed by four fast tokens is not "2 tok/s"; folding the queue wait
into the divisor makes every model look slow for the first windowMs of its
answer. Before the first token there is no reading at all — a run waiting on
its first byte has not started, so it is an em dash, not a zero.
- If the time base is under ~250 ms, return NO reading. Print an em dash rather
than flashing "480 tok/s" for one frame, and skip the bucket instead of
pushing a 0 — a false dip at the start of every run reads as a stutter.
- Average = tokens / (clock - averageBase) where averageBase is firstTokenAt
("generation speed") or startedAt ("wall-clock speed"), per averageFrom.
- Prune samples to the window plus one anchor, cap the array hard (240), and
cap the bucket ring at historyLength. Memory must not grow with run length.
Behavior — the clock, purity and run identity
- Render must never call Date.now(). The clock is the newest OBSERVATION, so
two renders with the same state produce the same text; time only advances
because the sampler produced a new state.
- status "complete" / "error" freezes the clock at endedAt (falling back to the
last observed change) and switches the headline from the window rate to the
RUN AVERAGE — the question changes from "how fast now" to "how fast was it".
- A run change (runId / startedAt) wipes the track during render, before paint,
so no frame mixes the old sparkline with the new numbers. A `tokens` value
lower than the last one is a retry that reused the identity: reset the same
way instead of drawing a negative rate.
Behavior — stall detection
- A stall is the ABSENCE of an event: while the stream is silent no prop
changes, so the sampler interval is the only thing that can notice it. Raise
the flag when clock - lastChangeAt >= stallAfterMs while streaming.
- Distinguish the two silences: before the first token the flag reads "No first
token" (the request may still be queued); after it, "Stalled" with the live
gap next to it.
- `stalled` as a prop overrides detection in both directions, for transports
that already know (a dead socket, a lapsed heartbeat). Fire onStallChange on
the edge only, through a ref, so a changing inline callback cannot re-fire it.
Behavior — comparison, sparkline, summary
- Delta = (current - average) / average, shown while streaming with a ±5% dead
band: throughput jitters, and an arrow that flips every beat is noise.
- The sparkline is bucket rates as a polyline plus a translucent area, a dashed
line at the run average, and a dot on the newest bucket. Scale y to
max(visible peak, average) * 1.12 so the average line is always in frame.
Fewer than two buckets renders a dashed empty baseline — a flat line at zero
would say "the model stopped", which is a different fact.
- The stats row is TTFT / Tokens / Elapsed, where TTFT counts UP while the
first token is outstanding ("1.2 s…") and freezes when it lands.
- On completion print one summary line: tokens · duration · average · peak.
"error" keeps every number it earned and only changes the verb ("Stopped
early"), because a stopped run is still a run that happened.
- Format numbers by hand (grouping, 2/1/0 decimals as the rate grows). Do NOT
use Intl.NumberFormat: it resolves against the runtime locale, and a
"1,284" / "1 284" split between server and client breaks hydration.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground / border for the panel,
text-muted-foreground for captions, tabular-nums for every figure, chart-1
for the live trend, chart-2 for a finished one, chart-3 for the stall state
(border-chart-3/40 + bg-chart-3/10 pill), chart-4 for a below-average delta,
destructive for a stopped run. No hex, no rgb(), no oklch().
- panel: header (status dot + phase caption + stall pill), a 3xl figure with
the unit and the delta line, the sparkline, then the stats row and summary.
compact: one inline row — dot, figure, mini sparkline, avg, TTFT, stall icon.
- The pulsing status dot is the only decorative animation: ship its keyframes
in a hoisted <style href precedence> and disable it with
motion-reduce:[animation:none]. Sampling never stops under reduced motion —
the numbers are the function, the pulse is the decoration.
- a11y: root role="group" with an aria-label so the reading has one name; the
sparkline is role="img" with a spoken summary of its range; the stall pill
carries visible text (compact keeps an sr-only copy next to the icon).
- Announcements: a separate sr-only role="status" aria-live="polite" node whose
content is DERIVED from props — first token, stall, final summary, nothing
else. Quote the stall THRESHOLD ("no new tokens for 3 seconds"), never the
growing gap, or a polite region re-fires four times a second. Never announce
the rate itself.
- Clean up on unmount: the interval is the only timer, and it is also torn down
whenever status leaves "streaming" or the beat changes.
Customization levers
- Cadence: sampleMs is the display beat (raise to 500 ms for a calmer number,
lower to 100 ms for a benchmark harness); windowMs is the smoothing (1000 ms
hugs the real arrival rate, 5000 ms gives a stable average-like reading);
historyLength × sampleMs is how much history the trend shows.
- Stall policy: stallAfterMs by transport (2.5 s for HTTP SSE, 10 s for a
queued batch); or bypass it entirely with the `stalled` prop and pair
onStallChange with a retry button.
- Average basis: averageFrom="request-start" when the queue wait is the
product's problem, "first-token" when raw decode speed is.
- Density: showSparkline / showStats / showComparison strip the panel down to
a figure; variant="compact" is the one-line form for a composer footer.
- Units: a token-billing product can pass unit="chars/s" or unit="words/s" and
feed the matching counter — nothing in the maths assumes tokens.
- Palette: the four tones are one Record each (stroke / fill / dot); remap them
to your own chart tokens if chart-1 is not your "live" colour.
- Layout: the stats row is a 3-column grid — add a "Peak" cell from the same
track, or drop the summary line if a parent already reports the run.Concepts
- Trailing-window rate — speed is measured between the newest observation and the oldest one still inside
windowMs, never between the last two tokens; one late chunk should move the number a little, not spike it to three digits. - Beat vs burst — tokens arrive in bursts a few milliseconds apart, but the readout repaints on a fixed beat; decoupling the two is what makes a large number readable, and it is why the sparkline's x axis is uniform in time rather than in tokens.
- Absence as an event — a stall produces no prop change, so nothing would ever re-render to notice it; the sampler interval exists precisely to watch a gap grow, and it distinguishes "no first token yet" (still queued) from "stopped mid-answer".
- Dead band — the current-vs-average arrow only flips outside ±5%, because a throughput reading that jitters by a token per beat would otherwise render an arrow that flickers instead of informing.
- No reading without a time base — under a quarter second of elapsed time the divisor is noise, so the meter prints an em dash and skips the bucket instead of drawing a spike at the start of every run.
- The run closes itself — when the status turns terminal the clock freezes at the end, the headline switches from "how fast now" to "how fast was it", and the same numbers are announced once to a polite live region that stayed quiet all the way through.
Prompt Diff
A word-level diff of two prompt versions — split or unified columns, version chips with author, date and run count, side swap, per-version copy and a one-shot restore.
Context Inspector
A stacked map of the assembled prompt — system, tools, files, memory and history as proportional slices with per-entry token costs, an expandable breakdown that never hides what it didn't itemise, a reply-headroom band that warns before the answer stops fitting, and a what-if preview of every evictable entry.