Context UsageNew
A context-window meter: tokens used against the limit, a segmented bar of what fills it, a legend to inspect each source and a Compact action to free space.
Context
175.6Kof 200K
"use client";
import * as React from "react";
import { motion, useReducedMotion, useSpring, useTransform } from "motion/react";
import { cn } from "@/lib/utils";
export interface ContextSegment {
/** Stable id, also used as the React key. */
id: string;
label: string;
tokens: number;
/** Tailwind background class for the segment and its legend dot. */
color: string;
}
export interface ContextUsageProps {
/** What fills the context window, in the order it's drawn. */
segments: ContextSegment[];
/** Size of the context window, in tokens. */
limit: number;
/** Share of the window (0–1) at which the meter warns that it's nearly full. */
warnAt?: number;
title?: string;
compactLabel?: string;
/** Summarise older content to free space. Return a promise to show a pending state. */
onCompact?: () => void | Promise<void>;
className?: string;
}
export function formatTokens(n: number) {
if (n >= 1_000_000) return `${trim(n / 1_000_000)}M`;
if (n >= 1_000) return `${trim(n / 1_000)}K`;
return `${Math.round(n)}`;
}
function trim(v: number) {
return v.toFixed(1).replace(/\.0$/, "");
}
const spring = { type: "spring", stiffness: 200, damping: 28 } as const;
export function ContextUsage({
segments,
limit,
warnAt = 0.9,
title = "Context",
compactLabel = "Compact",
onCompact,
className,
}: ContextUsageProps) {
const reduceMotion = !!useReducedMotion();
const id = React.useId();
const [active, setActive] = React.useState<string | null>(null);
const [compacting, setCompacting] = React.useState(false);
const used = segments.reduce((sum, s) => sum + s.tokens, 0);
const share = Math.min(1, used / Math.max(limit, 1));
const free = Math.max(0, limit - used);
const warn = share >= warnAt;
const focused = segments.find((s) => s.id === active);
const transition = reduceMotion ? { duration: 0 } : spring;
async function compact() {
if (!onCompact || compacting) return;
setCompacting(true);
try {
await onCompact();
} finally {
setCompacting(false);
}
}
return (
<div className={cn("flex w-full flex-col gap-4 rounded-3xl border bg-card p-5", className)}>
<div className="flex items-center justify-between gap-3">
<h3 className="text-base font-medium">{title}</h3>
{onCompact && (
<button
type="button"
onClick={compact}
disabled={compacting}
aria-describedby={`${id}-status`}
className={cn(
"rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors disabled:cursor-progress disabled:opacity-60",
warn ? "bg-foreground text-background hover:opacity-90" : "bg-muted hover:bg-muted-foreground/15"
)}
>
{compacting ? "Compacting…" : compactLabel}
</button>
)}
</div>
<div id={`${id}-status`} aria-live="polite" className="flex flex-col gap-1">
<p className="flex flex-wrap items-baseline gap-x-2">
<span className="text-4xl font-medium tracking-tight tabular-nums">
<AnimatedTokens value={focused ? focused.tokens : used} instant={reduceMotion} />
</span>
<span className="text-sm text-muted-foreground">
{focused ? `${focused.label.toLowerCase()} · ${Math.round((focused.tokens / limit) * 100)}%` : `of ${formatTokens(limit)}`}
</span>
</p>
{warn && !focused && (
<p className="text-xs font-medium text-amber-700 dark:text-amber-400">
{formatTokens(free)} left — compact to keep earlier details from being dropped.
</p>
)}
</div>
<div
role="meter"
aria-label={`${title} window used`}
aria-valuemin={0}
aria-valuemax={limit}
aria-valuenow={Math.min(used, limit)}
aria-valuetext={`${formatTokens(used)} of ${formatTokens(limit)} tokens used`}
className="flex h-11 w-full gap-1"
onMouseLeave={() => setActive(null)}
>
{segments.map((s) => (
<motion.div
key={s.id}
title={`${s.label}: ${formatTokens(s.tokens)}`}
onMouseEnter={() => setActive(s.id)}
className={cn("h-full min-w-0 rounded-md transition-opacity", s.color, active && active !== s.id && "opacity-30")}
initial={false}
animate={{ flexGrow: s.tokens, flexBasis: 0 }}
style={{ flexShrink: 1, minWidth: s.tokens > 0 ? 4 : 0 }}
transition={transition}
/>
))}
<motion.div
aria-hidden
className="h-full rounded-md bg-muted"
initial={false}
animate={{ flexGrow: free, flexBasis: 0 }}
transition={transition}
/>
</div>
<ul className="flex flex-wrap gap-2" aria-label="What's in context">
{segments.map((s) => (
<li key={s.id}>
<button
type="button"
aria-pressed={active === s.id}
onMouseEnter={() => setActive(s.id)}
onMouseLeave={() => setActive(null)}
onFocus={() => setActive(s.id)}
onBlur={() => setActive(null)}
onClick={() => setActive((a) => (a === s.id ? null : s.id))}
className={cn(
"flex items-center gap-2 rounded-full bg-muted px-3 py-1.5 text-sm transition-colors outline-none",
"hover:bg-muted-foreground/15 focus-visible:ring-2 focus-visible:ring-ring",
active === s.id && "bg-muted-foreground/15"
)}
>
<span className={cn("size-2.5 rounded-[3px]", s.color)} aria-hidden />
{s.label}
<span className="sr-only">: {formatTokens(s.tokens)} tokens</span>
</button>
</li>
))}
</ul>
</div>
);
}
function AnimatedTokens({ value, instant }: { value: number; instant: boolean }) {
const motionValue = useSpring(value, { stiffness: 220, damping: 30 });
const text = useTransform(motionValue, (v) => formatTokens(v));
React.useEffect(() => {
if (instant) motionValue.jump(value);
else motionValue.set(value);
}, [motionValue, value, instant]);
return <motion.span>{text}</motion.span>;
}
A UX spec for this pattern — written for agents implementing or reusing it, not the code.
# Context Usage
## Summary
A card that shows how full the model's context window is: a token count against the limit, a segmented bar of what's taking the space (chat, files, tools, system prompt, memory), a legend that picks out each part, and a Compact action that summarises older content to free room before the window runs out.
## When to use
- Long-running chats, coding agents and research sessions where the context fills up and the model starts forgetting or the product starts truncating.
- Products that let people add large inputs (files, repos, tool output) and need them to see the cost of each.
- Anywhere you offer compaction or summarisation, so people can choose when it happens instead of being surprised by it.
## When not to use
- Short, casual chats that never come close to the limit. A meter there is noise.
- To show what a run will cost in money or credits — use Cost Estimate.
- To show usage caps over time (messages per hour) — use Rate Limit.
## Anatomy
- Header: a title ("Context") and the Compact action.
- Total: the tokens used in large type ("175.6K"), with the limit beside it ("of 200K").
- Segmented bar: one block per source, sized by its share of the window, followed by a neutral block for free space. The whole bar is the window.
- Legend: one chip per source with its colour swatch and name.
- Low-space note: appears near the limit, saying how much is left and what compacting will do.
## Behavior
- Segments resize as content is added or removed, and the total animates to its new value.
- Hovering or focusing a legend chip (or hovering a segment) dims the other segments and swaps the total for that source's tokens and share ("39.2K · files · 20%").
- Past a threshold (90% by default) the low-space note appears and Compact becomes the primary action.
- Compact shows a pending state ("Compacting…"), then the bar shrinks as older turns are replaced by a summary and stale tool output is dropped. System prompt and memory are kept.
- Always offer compaction before the product does it on its own; when it does auto-compact, say so in the note.
## Content guidelines
- Format token counts compactly with one decimal ("175.6K", "1.2M"); never show raw counts like 175,612.
- Name sources in the user's terms (Chat, Files, Tools), not internal ones (messages array, tool_results).
- Keep the source list short — five or so. Fold rare sources into "Other".
- The low-space note says what's at stake, not just a number: "24.4K left — compact to keep earlier details from being dropped."
## Accessibility
- The bar is `role="meter"` with `aria-valuetext` ("175.6K of 200K tokens used").
- Legend chips are buttons with `aria-pressed` and a visually hidden token count, so each source can be inspected from the keyboard.
- The total and low-space note are in an `aria-live="polite"` region that also describes the Compact button.
- Colour is never the only cue: every segment has a labelled chip, and the warning is text.
- Animations are skipped when the user prefers reduced motion.
## Related patterns
- **Cost Estimate** — Use it instead when the constraint is credits or money for a single run, not space in the window.
- **Rate Limit** — Use it instead for caps on how much people can use over time.
- **Memory Panel** — Use it as well to let people see and edit what's kept across sessions, which also takes up context.
Do’s and don’ts
Do use it for
- Long-running chats, coding agents and research sessions where the context fills up and the model starts forgetting or the product starts truncating.
- Products that let people add large inputs (files, repos, tool output) and need them to see the cost of each.
- Anywhere you offer compaction or summarisation, so people can choose when it happens instead of being surprised by it.
Don’t use it for
- Short, casual chats that never come close to the limit. A meter there is noise.
- To show what a run will cost in money or credits — use Cost Estimate.
- To show usage caps over time (messages per hour) — use Rate Limit.