{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "context-meter",
  "type": "registry:ui",
  "title": "Context Meter",
  "description": "Ring or bar of context used, with a breakdown and a warning before full.",
  "categories": [
    "ai-chat"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/context-meter.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { Meter } from \"@base-ui/react/meter\";\nimport { Popover } from \"@base-ui/react/popover\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Loader, Warning } from \"@/lib/icons\";\nimport { ease } from \"@/lib/motion\";\n\n/* -------------------------------------------------------------------------------------------------\n * Helpers\n * -----------------------------------------------------------------------------------------------*/\n\nexport type ContextSegment = {\n  id: string;\n  /** \"System\", \"Messages\", \"Files\", \"Tools\"… */\n  label: string;\n  tokens: number;\n};\n\nexport type ContextLevel = \"ok\" | \"warn\" | \"full\";\n\n/** 68400 → \"68.4K\", 200000 → \"200K\", 1048576 → \"1M\". The same on server and client. */\nexport function formatTokens(n: number) {\n  const abs = Math.abs(n);\n  const fmt = (v: number, unit: string) => `${v >= 100 ? Math.round(v) : Math.round(v * 10) / 10}${unit}`;\n  if (abs >= 1e6) return fmt(n / 1e6, \"M\");\n  if (abs >= 1e3) return fmt(n / 1e3, \"K\");\n  return String(Math.round(n));\n}\n\n/** Where usage stands against the thresholds. */\nexport function contextLevel(used: number, limit: number, warnAt = 0.8, fullAt = 0.95): ContextLevel {\n  const r = limit > 0 ? used / limit : 0;\n  return r >= fullAt ? \"full\" : r >= warnAt ? \"warn\" : \"ok\";\n}\n\n// Segments stack from strongest to faintest, so the eye reads them in order.\nconst shades = [\"bg-fg\", \"bg-fg/60\", \"bg-fg/35\", \"bg-fg/20\"];\nconst compact = { notation: \"compact\", maximumFractionDigits: 1 } as const;\n\n/* -------------------------------------------------------------------------------------------------\n * Meter\n * -----------------------------------------------------------------------------------------------*/\n\nexport type ContextMeterProps = {\n  segments: ContextSegment[];\n  /** The model's context window, in tokens. */\n  limit: number;\n  variant?: \"ring\" | \"bar\";\n  /** Fraction of the window where it starts warning. */\n  warnAt?: number;\n  /** Fraction where it counts as full: older context starts being dropped or summarized. */\n  fullAt?: number;\n  /** Show the percentage beside the ring or bar. */\n  showLabel?: boolean;\n  /** Offer to summarize older messages once it warns. */\n  onCompact?: () => void | Promise<unknown>;\n  /** Controlled busy state for compacting, if it happens elsewhere. */\n  compacting?: boolean;\n  /** What happens at the limit, in one sentence. */\n  fullHint?: string;\n  side?: \"top\" | \"bottom\";\n  align?: \"start\" | \"center\" | \"end\";\n  container?: Popover.Portal.Props[\"container\"];\n  className?: string;\n};\n\nexport function ContextMeter({\n  segments,\n  limit,\n  variant = \"ring\",\n  warnAt = 0.8,\n  fullAt = 0.95,\n  showLabel = true,\n  onCompact,\n  compacting: compactingProp,\n  fullHint = \"Older messages will be summarized to make room.\",\n  side = \"top\",\n  align = \"end\",\n  container,\n  className,\n}: ContextMeterProps) {\n  const reduce = !!useReducedMotion();\n  const used = segments.reduce((sum, s) => sum + Math.max(0, s.tokens), 0);\n  const ratio = limit > 0 ? Math.min(1, used / limit) : 0;\n  const pct = Math.round(ratio * 100);\n  const level = contextLevel(used, limit, warnAt, fullAt);\n  const [pending, setPending] = useState(false);\n  const compacting = compactingProp ?? pending;\n\n  // Crossing into a new level plays one soft ring, once. Going back down plays nothing.\n  const rank = { ok: 0, warn: 1, full: 2 }[level];\n  const [seen, setSeen] = useState({ level, pulse: 0 });\n  if (seen.level !== level) setSeen({ level, pulse: rank > { ok: 0, warn: 1, full: 2 }[seen.level] ? seen.pulse + 1 : seen.pulse });\n\n  const summary = `${formatTokens(used)} of ${formatTokens(limit)} tokens, ${pct}% used`;\n\n  return (\n    <Popover.Root>\n      <Popover.Trigger\n        openOnHover\n        delay={200}\n        closeDelay={120}\n        aria-label={`Context window: ${summary}`}\n        data-level={level}\n        data-variant={variant}\n        className={cn(\n          \"group/meter relative inline-flex h-7 shrink-0 select-none items-center gap-1.5 rounded-md px-1.5 text-[12px] font-medium tabular text-fg-3 outline-none\",\n          \"touch-manipulation [-webkit-tap-highlight-color:transparent]\",\n          \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg-2 active:scale-[0.96] active:duration-75\",\n          \"data-popup-open:bg-hover data-popup-open:text-fg-2\",\n          \"data-[level=warn]:text-warning data-[level=full]:text-danger\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n          \"before:absolute before:-inset-y-2 before:inset-x-0 before:content-[''] pointer-fine:before:hidden motion-reduce:active:scale-100\",\n          className,\n        )}\n      >\n        {variant === \"ring\" ? <Ring ratio={ratio} level={level} pulse={seen.pulse} reduce={reduce} /> : <Bar ratio={ratio} />}\n        {showLabel && (\n          <span aria-hidden className=\"min-w-[3ch] text-right\">\n            <NumberFlow value={pct} suffix=\"%\" animated={!reduce} locales=\"en-US\" className=\"tabular\" />\n          </span>\n        )}\n      </Popover.Trigger>\n\n      <Popover.Portal container={container}>\n        <Popover.Positioner side={side} align={align} sideOffset={8} collisionPadding={12} className=\"z-(--z-popover)\">\n          <Popover.Popup\n            className={cn(\n              \"w-[min(18.5rem,calc(100vw-24px))] origin-(--transform-origin) rounded-xl border border-line-2 bg-raised p-3 text-fg shadow-pop outline-none\",\n              \"transition-[opacity,scale,translate] duration-160 ease-out-expo\",\n              \"data-starting-style:scale-96 data-starting-style:opacity-0\",\n              \"data-[side=top]:data-starting-style:translate-y-1 data-[side=bottom]:data-starting-style:-translate-y-1\",\n              \"data-ending-style:opacity-0 data-ending-style:duration-100 data-instant:transition-none\",\n              \"motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:translate-y-0\",\n            )}\n          >\n            <Meter.Root value={used} min={0} max={limit} getAriaValueText={() => summary} className=\"flex flex-col gap-2.5\">\n              <div className=\"flex items-baseline justify-between gap-3\">\n                <Meter.Label render={<Popover.Title />} className=\"text-[13px] font-medium tracking-[-0.01em]\">\n                  Context window\n                </Meter.Label>\n                <span className={cn(\"text-[12px] font-medium tabular\", level === \"ok\" ? \"text-fg-2\" : level === \"warn\" ? \"text-warning\" : \"text-danger\")}>\n                  <NumberFlow value={pct} suffix=\"%\" animated={!reduce} locales=\"en-US\" className=\"tabular\" />\n                </span>\n              </div>\n              <Popover.Description className=\"-mt-2 text-[12px] tabular text-fg-3\">\n                <NumberFlow value={used} format={compact} animated={!reduce} locales=\"en-US\" className=\"tabular\" /> of {formatTokens(limit)} tokens\n              </Popover.Description>\n\n              {/* One bar, a segment per kind of context, with the free space left as track. */}\n              <Meter.Track className=\"relative flex h-1.5 w-full gap-[2px] overflow-hidden rounded-full bg-fg/[0.08]\">\n                {segments.map((s, i) => (\n                  <span\n                    key={s.id}\n                    aria-hidden\n                    className={cn(\"h-full shrink-0 transition-[width] duration-500 ease-out-quart motion-reduce:transition-none\", shades[Math.min(i, shades.length - 1)])}\n                    style={{ width: `${(Math.max(0, s.tokens) / limit) * 100}%` }}\n                  />\n                ))}\n                {/* The threshold, marked on the track so \"near the limit\" has a place. */}\n                <span aria-hidden className=\"absolute inset-y-0 w-px bg-warning/70\" style={{ left: `${fullAt * 100}%` }} />\n              </Meter.Track>\n\n              <ul className=\"flex flex-col gap-1 text-[12px]\">\n                {segments.map((s, i) => (\n                  <li key={s.id} className={cn(\"flex items-center gap-2\", s.tokens <= 0 && \"opacity-50\")}>\n                    <span aria-hidden className={cn(\"size-2 shrink-0 rounded-[2px]\", shades[Math.min(i, shades.length - 1)])} />\n                    <span className=\"min-w-0 flex-1 truncate text-fg-2\">{s.label}</span>\n                    <span className=\"tabular text-fg\">{formatTokens(s.tokens)}</span>\n                    <span className=\"w-9 text-right tabular text-fg-4\">{limit ? Math.round((s.tokens / limit) * 100) : 0}%</span>\n                  </li>\n                ))}\n                <li className=\"flex items-center gap-2 border-t border-line pt-1.5\">\n                  <span aria-hidden className=\"size-2 shrink-0 rounded-[2px] border border-line-2\" />\n                  {used > limit ? (\n                    // Past the window, the honest number is how far over it is.\n                    <>\n                      <span className=\"min-w-0 flex-1 text-danger\">Over the limit by</span>\n                      <span className=\"tabular text-danger\">{formatTokens(used - limit)}</span>\n                      <span className=\"w-9\" />\n                    </>\n                  ) : (\n                    <>\n                      <span className=\"min-w-0 flex-1 text-fg-3\">Free</span>\n                      <span className=\"tabular text-fg-2\">{formatTokens(limit - used)}</span>\n                      <span className=\"w-9 text-right tabular text-fg-4\">{100 - pct}%</span>\n                    </>\n                  )}\n                </li>\n              </ul>\n            </Meter.Root>\n\n            <AnimatePresence initial={false}>\n              {level !== \"ok\" && (\n                <motion.div\n                  key=\"warning\"\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n                  animate={{ opacity: 1, height: \"auto\" }}\n                  exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0, transition: { duration: 0.16, ease: ease.in } }}\n                  transition={{ duration: 0.24, ease: ease.out }}\n                  className=\"overflow-hidden\"\n                >\n                  <div className={cn(\"mt-3 flex flex-col gap-2 rounded-lg p-2.5 text-[12px] leading-[1.45]\", level === \"warn\" ? \"bg-warning-soft\" : \"bg-danger-soft\")}>\n                    <p className=\"flex gap-2\">\n                      <Warning size={14} className={cn(\"mt-px shrink-0\", level === \"warn\" ? \"text-warning\" : \"text-danger\")} />\n                      <span className=\"text-fg-2\">\n                        <span className=\"font-medium text-fg\">{level === \"warn\" ? \"Nearing the limit.\" : \"Context is full.\"}</span> {fullHint}\n                      </span>\n                    </p>\n                    {onCompact && (\n                      <button\n                        type=\"button\"\n                        aria-busy={compacting || undefined}\n                        onClick={() => {\n                          if (compacting) return;\n                          const r = onCompact();\n                          if (r && typeof (r as Promise<unknown>).then === \"function\") {\n                            setPending(true);\n                            (r as Promise<unknown>).finally(() => setPending(false));\n                          }\n                        }}\n                        className={cn(\n                          \"relative ms-6 inline-grid h-7 w-fit place-items-center rounded-md border border-line-2 bg-raised px-2.5 text-[12px] font-medium text-fg shadow-[var(--shadow)] outline-none\",\n                          \"transition-[background-color,border-color,scale] duration-150 hover:border-fg-4 hover:bg-hover active:scale-[0.97]\",\n                          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 aria-busy:cursor-progress\",\n                        )}\n                      >\n                        {/* Both labels share one cell, so the button keeps its width while busy. */}\n                        <span className={cn(\"col-start-1 row-start-1 transition-opacity duration-150\", compacting && \"opacity-0\")}>Summarize older messages</span>\n                        <span aria-hidden={!compacting} className={cn(\"col-start-1 row-start-1 flex items-center gap-1.5 transition-opacity duration-150\", !compacting && \"opacity-0\")}>\n                          <Loader size={14} className=\"animate-spin-slow\" />\n                          Summarizing…\n                        </span>\n                      </button>\n                    )}\n                  </div>\n                </motion.div>\n              )}\n            </AnimatePresence>\n          </Popover.Popup>\n        </Popover.Positioner>\n      </Popover.Portal>\n    </Popover.Root>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Glyphs\n * -----------------------------------------------------------------------------------------------*/\n\nfunction Ring({ ratio, level, pulse, reduce }: { ratio: number; level: ContextLevel; pulse: number; reduce: boolean }) {\n  const r = 6;\n  const c = 2 * Math.PI * r;\n  return (\n    <span className=\"relative grid size-4 place-items-center\">\n      <svg width={16} height={16} viewBox=\"0 0 16 16\" aria-hidden className=\"-rotate-90\">\n        <circle cx=\"8\" cy=\"8\" r={r} fill=\"none\" strokeWidth={2} className=\"stroke-fg/15\" />\n        <circle\n          cx=\"8\"\n          cy=\"8\"\n          r={r}\n          fill=\"none\"\n          strokeWidth={2}\n          strokeLinecap=\"round\"\n          stroke=\"currentColor\"\n          strokeDasharray={c}\n          // A sliver always shows once anything is used, so a new chat still reads as a meter.\n          strokeDashoffset={c * (1 - Math.max(ratio, ratio > 0 ? 0.04 : 0))}\n          className={cn(\n            \"transition-[stroke-dashoffset,color] duration-600 ease-out-quart motion-reduce:transition-none\",\n            level === \"ok\" ? \"text-fg-2\" : level === \"warn\" ? \"text-warning\" : \"text-danger\",\n          )}\n        />\n      </svg>\n      {/* Crossing a threshold rings once, like a soft bell, then stays quiet. */}\n      <AnimatePresence>\n        {pulse > 0 && !reduce && (\n          <motion.span\n            key={pulse}\n            aria-hidden\n            className={cn(\"pointer-events-none absolute inset-0 rounded-full border-2\", level === \"full\" ? \"border-danger\" : \"border-warning\")}\n            initial={{ scale: 1, opacity: 0.6 }}\n            animate={{ scale: 2.1, opacity: 0 }}\n            transition={{ duration: 0.9, ease: ease.out }}\n          />\n        )}\n      </AnimatePresence>\n    </span>\n  );\n}\n\nfunction Bar({ ratio }: { ratio: number }) {\n  return (\n    <span className=\"relative h-1 w-12 overflow-hidden rounded-full bg-fg/15\">\n      <span\n        className=\"absolute inset-y-0 left-0 rounded-full bg-current transition-[width] duration-500 ease-out-quart motion-reduce:transition-none\"\n        style={{ width: `${Math.max(ratio, ratio > 0 ? 0.03 : 0) * 100}%` }}\n      />\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/context-meter"
}
