Scrim UI

Context Usage UI

How much of the context window is gone and to what — system prompt, files, history — plus what gets dropped first when the next message does not fit.

contexttokenswindowusage
npx shadcn@latest add https://scrimui.dev/r/context-usage.json
Agent promptClaude Code · Cursor · any agent
Add the Context Usage UI component from the Scrim UI registry to this project and use it in the configuration described below.

Context Usage UI — How much of the context window is gone and to what — system prompt, files, history — plus what gets dropped first when the next message does not fit.

## 1. Install

```bash
npx shadcn@latest add https://scrimui.dev/r/context-usage.json
```

This writes a single file to `components/ui/context-usage.tsx`. It is plain React + Tailwind with no runtime dependencies — no Radix, no CVA, nothing to add to package.json. If this project does not use the shadcn CLI, copy the source from https://scrimui.dev/components/context-usage to the same path by hand; nothing in the file depends on shadcn.

## 2. Use it

```tsx
import { ContextUsage } from "@/components/ui/context-usage";

// evictionRank: lower goes first. No rank means it cannot be dropped —
// which is what makes "pinned" a claim the component can check rather than
// a label somebody typed.
const SEGMENTS = [
  { label: "System prompt & tools", tokens: 3_400 },
  { label: "Retrieved passages",    tokens: 22_800, evictionRank: 1 },
  { label: "Conversation history",  tokens: 76_500, evictionRank: 2 },
  { label: "Pinned files (2)",      tokens: 11_200, evictionRank: 3 },
];

<ContextUsage
  window={128000}
  segments={SEGMENTS}
  reserve={8000}
/>
```

## 3. The configuration that matters

This is the component's "Comfortable" state — Nothing to warn about, so nothing warns. The breakdown is still there because the question 'what is taking up the room' has an answer before it becomes urgent.

These props were set deliberately and should be preserved as written:

- `load` = "healthy" — How full

Every other prop is at its default; leave those off the call site rather than writing the default value out.

Reference: https://scrimui.dev/components/context-usage

Follows the props below — change a control and the prompt changes with it, so an agent reproduces that configuration instead of the defaults.

38kof 120k usable · 8.0k held for the reply82k left
System prompt & tools
pinned
3.4k
Pinned files (2)
11k
Retrieved passages
8.9k
Conversation history
15k

Presets

Props

128000
8000

Component source

Single-file React + Tailwind component. No dependencies — drop it into any project with Tailwind configured.

context-usage.tsx
"use client";

import * as React from "react";

/**
 * How much of the context window is gone, and to what.
 *
 * A single "78% full" bar is the version everyone builds, and it answers the
 * wrong question. By the time a reader looks at this they are already in
 * trouble; what they need is *what to remove* and *what will be removed for
 * them*.
 *
 * **Reserve room for the reply.** The window is shared between what you send
 * and what comes back. A bar that reads 96% full with no reply reserved is
 * describing a request that cannot succeed, in the cheerful voice of one that
 * nearly can. So `reserve` is subtracted up front and drawn as its own
 * segment — the usable space is what is left after the answer has its room.
 *
 * **Eviction order is the actionable part.** Something gets dropped when the
 * next message does not fit, and the reader is entitled to know what before
 * it happens rather than after they notice the model forgot a file. The
 * segments are listed in the order they will be evicted, and the first one is
 * named in the warning.
 *
 * **Token counts are per-tokenizer.** The same text is a different number of
 * tokens on a different model, so a count carried over from another provider
 * is decoration. Count with the tokenizer of the model you are about to call,
 * or say the figure is an estimate.
 */

export type ContextSegment = {
  label: string;
  tokens: number;
  /**
   * Position in the eviction order — lower goes first. Segments that cannot
   * be evicted (the system prompt, usually) should be left undefined.
   */
  evictionRank?: number;
};

export type ContextUsageProps = {
  /** Total window for the model, in tokens. */
  window: number;
  segments: ContextSegment[];
  /** Tokens held back for the reply. */
  reserve?: number;
  /** True when the count came from a different tokenizer than the model's. */
  estimated?: boolean;
  className?: string;
};

const COLORS = [
  "bg-blue-500",
  "bg-violet-500",
  "bg-teal-500",
  "bg-amber-500",
  "bg-rose-500",
  "bg-lime-500",
];

function formatTokens(n: number): string {
  if (n < 1000) return String(n);
  if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
  return `${(n / 1_000_000).toFixed(1)}M`;
}

export function ContextUsage({
  window: windowSize,
  segments,
  reserve = 0,
  estimated = false,
  className = "",
}: ContextUsageProps) {
  const used = segments.reduce((sum, s) => sum + s.tokens, 0);
  const usable = Math.max(0, windowSize - reserve);
  const free = usable - used;
  const overflowing = free < 0;
  /* Tight at 85% of the USABLE window, not of the whole one — the difference
     is exactly the reply, which is the thing that breaks first. */
  const tight = !overflowing && used / usable > 0.85;

  /* Named here rather than in the warning string so the two cannot disagree:
     the first segment to go is the lowest eviction rank, and a segment with
     no rank is not evictable at all. */
  const evictable = segments
    .filter((s) => s.evictionRank !== undefined)
    .sort((a, b) => (a.evictionRank ?? 0) - (b.evictionRank ?? 0));
  const firstOut = evictable[0];

  return (
    <div className={`rounded-xl border border-zinc-200 bg-white p-3.5 dark:border-zinc-800 dark:bg-zinc-900 ${className}`}>
      <div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
        <span className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
          {estimated && "~"}
          {formatTokens(used)}
        </span>
        <span className="text-[11px] text-zinc-500 dark:text-zinc-400">
          of {formatTokens(usable)} usable
          {reserve > 0 && ` · ${formatTokens(reserve)} held for the reply`}
        </span>
        <span
          className={`ml-auto text-[11px] font-medium tabular-nums ${
            overflowing
              ? "text-red-600 dark:text-red-400"
              : tight
                ? "text-amber-600 dark:text-amber-500"
                : "text-zinc-400 dark:text-zinc-500"
          }`}
        >
          {overflowing ? `${formatTokens(-free)} over` : `${formatTokens(free)} left`}
        </span>
      </div>

      {/* One track, segmented. Separate bars per segment would let each one
          look full on its own scale, which is the opposite of the point. */}
      <div className="mt-2 flex h-2 gap-px overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
        {segments.map((s, i) => (
          <div
            key={s.label}
            className={`h-full ${COLORS[i % COLORS.length]}`}
            style={{ width: `${Math.min(100, (s.tokens / usable) * 100)}%` }}
            title={`${s.label} — ${formatTokens(s.tokens)}`}
          />
        ))}
        {reserve > 0 && !overflowing && (
          <div
            /* Striped rather than solid: it is not used, and it is not free
               either. A solid block reads as another consumer. */
            className="h-full bg-[repeating-linear-gradient(45deg,rgb(161_161_170/0.5)_0_3px,transparent_3px_6px)]"
            style={{ width: `${Math.max(0, (free / usable) * 100)}%` }}
            title={`${formatTokens(free)} free`}
          />
        )}
      </div>

      <dl className="mt-2.5 space-y-1">
        {segments.map((s, i) => (
          <div key={s.label} className="flex items-baseline gap-2 text-[11px]">
            <span className={`h-2 w-2 shrink-0 rounded-sm ${COLORS[i % COLORS.length]}`} />
            <dt className="text-zinc-600 dark:text-zinc-300">{s.label}</dt>
            {s.evictionRank === undefined && (
              <span className="rounded bg-zinc-100 px-1 text-[10px] text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
                pinned
              </span>
            )}
            <dd className="ml-auto shrink-0 tabular-nums text-zinc-500 dark:text-zinc-400">
              {formatTokens(s.tokens)}
            </dd>
          </div>
        ))}
      </dl>

      {(tight || overflowing) && (
        <p
          className={`mt-2.5 border-t pt-2 text-[11px] leading-4 ${
            overflowing
              ? "border-red-100 text-red-600 dark:border-red-900/40 dark:text-red-400"
              : "border-zinc-100 text-amber-600 dark:border-zinc-800 dark:text-amber-500"
          }`}
        >
          {firstOut
            ? `${overflowing ? "Does not fit" : "Running out"} — “${firstOut.label}” is dropped first.`
            : `${overflowing ? "Does not fit" : "Running out"}, and nothing here is evictable.`}
        </p>
      )}

      {estimated && (
        <p className="mt-1.5 text-[11px] leading-4 text-zinc-400 dark:text-zinc-500">
          Counted with a different tokenizer than the model uses — treat it as an estimate.
        </p>
      )}
    </div>
  );
}

When to use it

  • Reserve room for the reply and measure fullness against what is left. The window is shared between the request and the response, and the response is what breaks first.
  • Give each segment an eviction rank, and leave it undefined for anything that cannot be dropped. 'Pinned' then becomes a fact the component can check rather than a label somebody typed.
  • Name what goes first in the warning. 'Context is 92% full' tells the reader they are in trouble; naming the segment tells them what to do about it.
  • Count with the tokenizer of the model you are about to call. The same text is a different number on a different model, and a figure carried over from another provider is decoration.
  • Break the bar down by source. A single fullness percentage is the version everyone builds and it answers the wrong question.
  • Set the estimated flag rather than silently rounding. A number that is honest about being approximate is more useful than one that is quietly wrong.

What breaks in production

  • Measuring against the whole window with no reply reserve. At 96% the bar looks survivable and the request cannot succeed.
  • Showing one aggregate percentage. Nobody can act on it, and the segment that is actually eating the window stays invisible.
  • Dropping context silently. The model forgets a file, the user notices before the interface does, and there is nothing on screen that explains it.
  • Drawing the free space as a solid block in the same style as the segments. It reads as another consumer, and the reader thinks the window is fuller than it is.
  • Reusing a token count across models. It is off by enough to matter exactly when the number matters, which is near the limit.

Related Components