Cache Indicator
A prompt-cache chip for one request — hit / partial / miss / write derived from the token counts, a tooltip breakdown of cached vs fresh tokens and money saved, and a self-correcting TTL ring that flips the chip to expired and fires onExpire exactly once.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/cache-indicator.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "CacheIndicator" component: a chip that
says what one request did with a provider's prompt cache, and how long the
cached prefix stays warm. Dependencies: lucide-react for the glyphs and a
shadcn/Radix Tooltip for the breakdown. No date library, no chart library.
Contract
- forwardRef<HTMLElement>, extends React.HTMLAttributes<HTMLElement> minus
children. It renders a <button> when there is something to reveal and a
<span> when there is not; the remaining props spread onto whichever it is.
- Usage numbers, all optional and coerced to non-negative integers:
cachedTokens — prompt tokens read back from the cache
freshTokens — prompt tokens the model had to process
writtenTokens — prompt tokens this request wrote into the cache
- state?: "hit" | "partial" | "miss" | "write" | "expired". Omit it and the chip
derives one: cached>0 && fresh>0 -> partial; cached>0 -> hit; written>0 ->
write; otherwise miss.
- Window: expiresAt?: Date | number (absolute deadline) and ttlMs?: number (the
window length, default 300000 = a five-minute provider window). ttlMs is the
ring's denominator; passed WITHOUT expiresAt it also anchors a window at
mount. warnAt?: number — remaining time below which the window is urgent,
default min(60s, window/4).
- countdown?: "auto" | "ring" | "text" | "none", default "auto".
- onExpire?: () => void. pricing?: { fresh: number; cached?: number } — price
per 1M tokens, enables the savings row. currency?: string, default "$" (a
prefix, not an ISO code).
- Presentation: label?, description? (null drops it), showRatio? (default true),
showLabel? (default true), size? "sm" | "md" | "lg", tone? "soft" | "outline",
color? (a theme token, also opting out of the urgency tint), icon?
(undefined = state glyph, null/false = none), details? "none" | "tooltip",
fields?: { label, value }[], side/align/delayDuration for the tooltip,
formatRemaining?(ms), formatTokens?(n).
- One editable table maps each state to { label, description, accent token,
glyph }: hit -> var(--chart-2)/DatabaseZap, partial -> var(--chart-4)/Layers,
miss -> var(--muted-foreground)/DatabaseX, write -> var(--chart-1)/
DatabaseBackup, expired -> var(--muted-foreground)/TimerOff. Adding a state is
one row, not a new branch.
Behavior — outcome vs window
- The token counts describe what THIS request did; the TTL describes whether the
entry is still worth anything to the NEXT one. Once the window elapses that is
the more useful headline, so "expired" SUPERSEDES the derived or explicit
state: the chip's label, glyph and accent all switch, while the breakdown
keeps reporting the original outcome and adds "Window: elapsed".
- Urgency (remaining <= warnAt) is a presentation state, not a semantic one: it
retints the accent to the expiring token and forces the numeric value onto the
chip. An explicit `color` pins the accent and opts out of the retint.
- Ratios round honestly. 99.6% cached must never print "100%" next to the word
"partial" and 0.4% must never print "0%" next to the word "hit": clamp to
99 while any fresh token exists, clamp to 1 while any cached token exists.
- Savings = cachedTokens / 1e6 * (pricing.fresh - (pricing.cached ?? 0)), shown
only when positive and finite. Money uses 4 decimals below one cent so a
sub-cent saving does not print as 0.00.
Behavior — the clock (the part that is easy to get wrong)
- Never read Date.now() during render. A deadline resolved in the render body
gives the server one answer and the client another, which is a hydration
mismatch on every badge on the page. Keep the remaining time in state that
ONLY a timer callback writes, and arm the timer from an effect with a zero
delay, so nothing writes state synchronously in the effect body either.
- Deadline = expiresAt when given, otherwise mount time + ttlMs (only when the
caller passed a ttlMs). Re-arm the whole thing when expiresAt or ttlMs change;
a caller re-creating an equal Date every render must NOT restart it, so
normalise the date to a number before it reaches the dependency array.
- Self-correcting schedule instead of setInterval(1000): after each tick, sleep
exactly ((remaining - 1) % 1000) + 1 ms (floored at ~50ms), recomputed from
the wall clock. A fixed interval drifts, and a throttled background tab makes
it skip whole seconds on return.
- Listen for visibilitychange and resync immediately when the tab becomes
visible again: a hidden tab throttles timers to roughly once a minute.
- One-shot expiry lock, scoped to the current deadline: two flags, "was the
window still open at some point while mounted" and "has the callback run".
onExpire fires only when both say yes, so mounting on an already-dead cache is
history, not an event — and it can never fire twice for the same window.
- Once remaining hits zero, stop scheduling entirely. Clear the timeout and
remove the listener in the effect cleanup, always.
- The chip's element type (button vs span) is decided from the PROPS, never from
the clock: a chip that promoted itself from span to button when the first tick
landed would swap DOM nodes under the pointer and drop focus.
Behavior — disclosure and accessibility
- Affordance follows content: with usage numbers, a window or extra fields there
is a tooltip, a pointer cursor and a focus stop; with nothing behind it the
chip is an inert <span>. details="none" | "tooltip" overrides that.
- The interactive chip carries the whole story as its aria-label ("Cache hit,
24,576 of 24,576 prompt tokens from cache, expires in about 4 minutes") —
a tooltip is not reachable by a screen reader user. The inert chip carries the
same sentence in an sr-only span.
- The accessible name states the remaining time at MINUTE granularity ("about 4
minutes", "under a minute"). A per-second aria-label makes a screen reader
re-announce the focused chip once a second.
- The ring is aria-hidden decoration: the exact remaining time is always
available as text, in the breakdown and on the chip itself once urgent.
- Default formatters are deterministic (manual thousands grouping, "4m 32s",
fixed decimals). Intl.NumberFormat with an implicit locale groups one way on
the server and another in a de-DE browser, and this chip renders during
hydration; a consumer who wants locale rules passes formatTokens /
formatRemaining.
Rendering & styling
- Semantic tokens only: var(--chart-1..5) and var(--muted-foreground) for the
state accents, bg/border tints via color-mix(in oklab, <token> 14% / 45%,
transparent), ring / ring-ring for focus and hover, currentColor for the ring
arc and the split bar. No hex, no rgb(), no oklch() literals.
- The capsule: inline-flex, w-fit, shrink-0, rounded-full, align-middle,
whitespace-nowrap, three size rows (h-5/h-6/h-7) each pairing a box class, a
square class for the icon-only shape, a glyph size and a type size. No
select-none — a cache annotation should travel with the text a reader copies.
- TTL ring: one 24x24 svg, two circles, -rotate-90, strokeDasharray = 2*PI*r and
strokeDashoffset = length * (1 - remaining/window), strokeLinecap round. The
1s linear transition on stroke-dashoffset matches the tick period exactly, so
the arc sweeps instead of stepping.
- prefers-reduced-motion: the ring is dropped and the numeric value takes its
place — the information survives, only the animation goes. Keep
motion-reduce:transition-none on the arc for the explicit countdown="ring"
case, and read the media query with useSyncExternalStore (server snapshot
false) so hydration stays clean.
- Breakdown panel (inside the tooltip, an inverted surface): title row with the
glyph, the state sentence at opacity-70, a two-segment cached/fresh bar built
from currentColor at two opacities, then a <dl> of rows — From cache (tokens +
%), Processed fresh, Written to cache, Saved, Expires / Window, then any
custom fields. Accents are dropped in here: on an inverted surface only
currentColor is contrast-safe.
- Merge every className through cn() and put the consumer's `style` after the
computed surface so an override wins.
Customization levers
- The state table is the main lever: change a label, sentence, glyph or accent
token per row, or add a state (e.g. "shared" for a cross-request cache) by
adding one row and one derivation clause.
- Window semantics: ttlMs for the ring denominator, warnAt for how early the
chip starts shouting. Providers with a one-hour window want ttlMs 3600000 and
warnAt around 300000.
- Countdown presentation: "ring" for the ambient dial, "text" for dense log
rows and dashboards, "none" when a neighbouring column already owns the time —
onExpire still fires in every mode.
- Density: size sm for message footers and table cells, md inline, lg for a
detail header; tone="outline" where a tinted pill would be too loud;
showLabel={false} for a glyph-only chip in a tight row; showRatio={false} to
drop the percentage.
- Breakdown content: description={null} removes the explanatory sentence, fields
adds rows (cache key, break point, provider, region), pricing/currency turn
the savings row on and off.
- Disclosure: details="none" turns even a data-carrying chip into inert text
(useful when the row already has an expander); raise delayDuration for a
calmer hover, or move side/align when the chip sits at the edge of a panel.
- Formatters: pass formatTokens for compact "24.6k" counts and formatRemaining
for "mm:ss" or a locale-aware relative time.Concepts
- Outcome vs window — the token counts answer "what did this request do", the TTL answers "is the entry still worth anything to the next one"; while the window is alive the outcome is the headline, and the moment it elapses
expiredtakes over the label, glyph and accent while the breakdown keeps the original numbers. - Derived state — nothing has to be classified by hand: cached and fresh both non-zero reads as partial, cached alone as hit, written alone as write, neither as miss — so a raw usage payload can be piped straight in, and an explicit
stateis only needed when the server already made the call. - Self-correcting tick — instead of a 1000 ms interval that drifts and skips, each tick sleeps exactly until the displayed second changes, recomputed from the wall clock, with a visibility resync so a tab that was throttled in the background never comes back showing a stale number.
- One-shot expiry lock — the callback is guarded by two flags scoped to the current deadline, "the window was open while I was mounted" and "I have already fired", so a badge mounted on a long-dead cache reports history silently and no window can fire twice.
- Hydration-safe clock — the deadline is resolved inside an effect and the remaining time is written only by the timer, because reading
Date.now()during render gives the server one answer and the browser another; until the first tick lands the chip simply has no countdown. - Numeric fallback — under
prefers-reduced-motionthe sweeping arc is replaced by the number it was encoding rather than merely frozen: the decoration disappears, the information does not.
Model Router
A routing policy panel — ordered first-match-wins rules with testable condition chips, per-rule switches with optimistic rollback, a health-aware fallback chain, and a prompt tester that names the rule which claims a request and the ones never asked.
Tokenizer Preview
A tokenizer playground for input text — alternating-tinted token chunks from a deterministic built-in splitter or your own, token/char/word counts, a live cost estimate at an editable per-Mtok rate, and markers for whitespace and unusually long tokens.