{
  "name": "ai-context-meter",
  "type": "registry:component",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @kokonutui\n * @description: AI Context Meter\n * @version: 1.0.0\n * @date: 2026-10-15\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport { AnimatePresence, MotionConfig, motion } from \"motion/react\";\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ContextSegment {\n  id: string;\n  label: string;\n  tokens: number;\n  /** A Tailwind background class for the segment, such as `bg-sky-500`. */\n  color?: string;\n}\n\ninterface AIContextMeterProps {\n  /**\n   * What fills the context window, in the order to draw it. When omitted, a\n   * demo conversation grows on its own.\n   */\n  segments?: ContextSegment[];\n  /** The model's context window, in tokens. */\n  limit?: number;\n  /** Share of the window at which the conversation compacts, 0 to 1. */\n  compactAt?: number;\n  /** Shows a Compact button in the panel. The demo compacts by itself. */\n  onCompact?: () => void;\n  /** Shown beside the meter, like the footer of a prompt box. */\n  model?: string;\n  defaultOpen?: boolean;\n  className?: string;\n}\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst FALLBACK_COLORS = [\n  \"bg-zinc-300 dark:bg-zinc-600\",\n  \"bg-zinc-400 dark:bg-zinc-500\",\n  \"bg-zinc-600 dark:bg-zinc-400\",\n  \"bg-zinc-900 dark:bg-zinc-100\",\n];\n\nconst DEMO_BASE: ContextSegment[] = [\n  { id: \"system\", label: \"System prompt\", tokens: 3100 },\n  { id: \"tools\", label: \"Tools\", tokens: 14_200 },\n  { id: \"files\", label: \"Files\", tokens: 38_600 },\n];\nconst DEMO_START_MESSAGES = 61_000;\nconst DEMO_TICK_MS = 1100;\nconst AUTO_COMPACT_DELAY_MS = 1400;\nconst SUMMARY_TOKENS = 5800;\n\n/**\n * Grows the demo conversation a turn at a time, pausing offscreen. At the\n * compact threshold it swaps the messages for a short summary, like an agent\n * compacting its context, and carries on.\n */\nfunction useDemoContext(\n  enabled: boolean,\n  limit: number,\n  compactAt: number,\n  rootRef: React.RefObject<HTMLElement | null>\n) {\n  const [messages, setMessages] = useState(DEMO_START_MESSAGES);\n  const [summary, setSummary] = useState(0);\n  const [visible, setVisible] = useState(true);\n  const turnRef = useRef(0);\n\n  useEffect(() => {\n    const element = rootRef.current;\n    if (!(enabled && element)) {\n      return;\n    }\n    const observer = new IntersectionObserver(([entry]) =>\n      setVisible(entry.isIntersecting)\n    );\n    observer.observe(element);\n    return () => observer.disconnect();\n  }, [enabled, rootRef]);\n\n  const base = DEMO_BASE.reduce((sum, segment) => sum + segment.tokens, 0);\n  const total = base + summary + messages;\n  const full = total >= limit * compactAt;\n\n  useEffect(() => {\n    if (!(enabled && visible) || full) {\n      return;\n    }\n    const id = setInterval(() => {\n      // Turns alternate between short replies and longer tool results.\n      turnRef.current += 1;\n      const size =\n        turnRef.current % 3 === 0 ? 9400 : 2600 + (turnRef.current % 4) * 900;\n      setMessages((value) => value + size);\n    }, DEMO_TICK_MS);\n    return () => clearInterval(id);\n  }, [enabled, visible, full]);\n\n  // Summary is always listed so it grows in from nothing when compacting.\n  const segments: ContextSegment[] = [\n    ...DEMO_BASE,\n    { id: \"summary\", label: \"Summary\", tokens: summary },\n    { id: \"messages\", label: \"Messages\", tokens: messages },\n  ];\n\n  const compact = useCallback(() => {\n    // The new summary replaces the old one along with the messages.\n    setSummary(SUMMARY_TOKENS);\n    setMessages(0);\n  }, []);\n\n  useEffect(() => {\n    if (!(enabled && visible && full)) {\n      return;\n    }\n    const id = setTimeout(compact, AUTO_COMPACT_DELAY_MS);\n    return () => clearTimeout(id);\n  }, [enabled, visible, full, compact]);\n\n  return { segments, compact };\n}\n\nconst DEMO_COLORS: Record<string, string> = {\n  system: \"bg-zinc-300 dark:bg-zinc-600\",\n  tools: \"bg-zinc-400 dark:bg-zinc-500\",\n  files: \"bg-zinc-600 dark:bg-zinc-400\",\n  summary: \"bg-sky-300 dark:bg-sky-700\",\n  messages: \"bg-sky-500 dark:bg-sky-400\",\n};\n\n/** Eases a number toward its target, so counts roll instead of jumping. */\nfunction useTweened(target: number, duration = 450) {\n  const [value, setValue] = useState(target);\n  const fromRef = useRef(target);\n  const valueRef = useRef(target);\n\n  useEffect(() => {\n    const reduce = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n    if (reduce) {\n      valueRef.current = target;\n      setValue(target);\n      return;\n    }\n    fromRef.current = valueRef.current;\n    const start = performance.now();\n    let frame = 0;\n    const step = (now: number) => {\n      const t = Math.min(1, (now - start) / duration);\n      const eased = 1 - (1 - t) ** 3;\n      valueRef.current = fromRef.current + (target - fromRef.current) * eased;\n      setValue(valueRef.current);\n      if (t < 1) {\n        frame = requestAnimationFrame(step);\n      }\n    };\n    frame = requestAnimationFrame(step);\n    return () => cancelAnimationFrame(frame);\n  }, [target, duration]);\n\n  return value;\n}\n\nconst formatTokens = (tokens: number) =>\n  tokens >= 1000\n    ? `${(tokens / 1000).toFixed(tokens >= 10_000 ? 0 : 1)}k`\n    : `${Math.round(tokens)}`;\n\nfunction levelColor(share: number, compactAt: number) {\n  // Dark enough to read at 12px on the light bar (amber-500 was 2:1).\n  if (share >= compactAt + (1 - compactAt) / 2) {\n    return \"text-red-600 dark:text-red-400\";\n  }\n  if (share >= compactAt - 0.1) {\n    return \"text-orange-700 dark:text-orange-400\";\n  }\n  return \"text-zinc-900 dark:text-zinc-100\";\n}\n\nfunction Ring({ share }: { share: number }) {\n  const radius = 6;\n  const circumference = 2 * Math.PI * radius;\n  return (\n    <svg\n      aria-hidden=\"true\"\n      className=\"size-4 shrink-0 -rotate-90\"\n      fill=\"none\"\n      viewBox=\"0 0 16 16\"\n    >\n      <circle\n        className=\"stroke-zinc-200 dark:stroke-zinc-700\"\n        cx=\"8\"\n        cy=\"8\"\n        r={radius}\n        strokeWidth=\"2\"\n      />\n      <circle\n        // No CSS transition: `share` is already tweened, and easing it twice\n        // made the ring trail the percentage beside it.\n        className=\"stroke-current\"\n        cx=\"8\"\n        cy=\"8\"\n        r={radius}\n        strokeDasharray={circumference}\n        strokeDashoffset={circumference * (1 - Math.min(1, share))}\n        strokeLinecap=\"round\"\n        strokeWidth=\"2\"\n      />\n    </svg>\n  );\n}\n\nfunction Breakdown({\n  segments,\n  limit,\n  compactAt,\n  colors,\n  onCompact,\n}: {\n  segments: ContextSegment[];\n  limit: number;\n  compactAt: number;\n  colors: string[];\n  onCompact?: () => void;\n}) {\n  const [active, setActive] = useState<string | null>(null);\n  const used = segments.reduce((sum, segment) => sum + segment.tokens, 0);\n  const free = Math.max(0, limit - used);\n  const shownUsed = useTweened(used);\n\n  return (\n    <div className=\"w-72 p-3\">\n      <div className=\"mb-2.5 flex items-baseline justify-between\">\n        <p className=\"font-medium text-sm text-zinc-900 dark:text-zinc-100\">\n          Context\n        </p>\n        <p className=\"text-xs text-zinc-500 tabular-nums dark:text-zinc-400\">\n          {formatTokens(shownUsed)} of {formatTokens(limit)}\n        </p>\n      </div>\n\n      {/* One bar for the whole window, a segment per source. The tick marks\n          where the conversation compacts. */}\n      <div className=\"relative\" onPointerLeave={() => setActive(null)}>\n        <div className=\"flex h-2 overflow-hidden rounded-full bg-zinc-100 dark:bg-white/10\">\n          {segments.map((segment, index) => (\n            <span\n              className={cn(\n                \"h-full transition-[width,opacity] duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\",\n                index > 0 && segment.tokens > 0 && \"ml-px\",\n                colors[index],\n                active && active !== segment.id && \"opacity-30\"\n              )}\n              key={segment.id}\n              onPointerEnter={() => setActive(segment.id)}\n              style={{ width: `${(segment.tokens / limit) * 100}%` }}\n            />\n          ))}\n        </div>\n        <span\n          aria-hidden=\"true\"\n          className=\"absolute -top-1 -bottom-1 w-px bg-zinc-400 dark:bg-zinc-500\"\n          style={{ left: `${compactAt * 100}%` }}\n        />\n      </div>\n      <p className=\"mt-1.5 text-right text-[11px] text-zinc-400 dark:text-zinc-500\">\n        Compacts at {Math.round(compactAt * 100)}%\n      </p>\n\n      <ul className=\"mt-2 space-y-0.5\">\n        {segments.map((segment, index) =>\n          segment.tokens > 0 ? (\n            <li\n              className={cn(\n                \"flex items-center gap-2 rounded-md px-1.5 py-1 text-xs transition-colors duration-150\",\n                active === segment.id && \"bg-zinc-100 dark:bg-white/5\"\n              )}\n              key={segment.id}\n              onPointerEnter={() => setActive(segment.id)}\n              onPointerLeave={() => setActive(null)}\n            >\n              <span className={cn(\"size-2 rounded-[3px]\", colors[index])} />\n              <span className=\"text-zinc-700 dark:text-zinc-300\">\n                {segment.label}\n              </span>\n              <span className=\"ml-auto text-zinc-500 tabular-nums dark:text-zinc-400\">\n                {formatTokens(segment.tokens)}\n              </span>\n              <span className=\"w-9 text-right text-zinc-400 tabular-nums dark:text-zinc-500\">\n                {((segment.tokens / limit) * 100).toFixed(1)}%\n              </span>\n            </li>\n          ) : null\n        )}\n        <li className=\"flex items-center gap-2 px-1.5 py-1 text-xs\">\n          <span className=\"size-2 rounded-[3px] ring-1 ring-zinc-300 ring-inset dark:ring-zinc-600\" />\n          <span className=\"text-zinc-500 dark:text-zinc-400\">Free</span>\n          <span className=\"ml-auto text-zinc-500 tabular-nums dark:text-zinc-400\">\n            {formatTokens(free)}\n          </span>\n          <span className=\"w-9 text-right text-zinc-400 tabular-nums dark:text-zinc-500\">\n            {((free / limit) * 100).toFixed(1)}%\n          </span>\n        </li>\n      </ul>\n\n      {onCompact && (\n        <div className=\"mt-3 flex items-center justify-between border-zinc-100 border-t pt-3 dark:border-white/10\">\n          <p className=\"text-xs text-zinc-500 dark:text-zinc-400\">\n            Summarize older turns\n          </p>\n          <button\n            className=\"h-7 rounded-md bg-zinc-900 px-2.5 font-medium text-white text-xs outline-hidden transition-colors duration-150 hover:bg-zinc-700 focus-visible:ring-2 focus-visible:ring-zinc-400/60 disabled:bg-zinc-200 disabled:text-zinc-400 dark:bg-white dark:text-zinc-900 dark:disabled:bg-white/10 dark:disabled:text-zinc-500 dark:hover:bg-zinc-200\"\n            disabled={used / limit < 0.4}\n            onClick={onCompact}\n            type=\"button\"\n          >\n            Compact now\n          </button>\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport default function AIContextMeter({\n  segments: segmentsProp,\n  limit = 200_000,\n  compactAt = 0.8,\n  onCompact,\n  model = \"Claude Opus\",\n  defaultOpen = false,\n  className,\n}: AIContextMeterProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const demo = useDemoContext(\n    segmentsProp === undefined,\n    limit,\n    compactAt,\n    rootRef\n  );\n  const isDemo = segmentsProp === undefined;\n  const segments = segmentsProp ?? demo.segments;\n  const colors = segments.map(\n    (segment, index) =>\n      segment.color ??\n      (isDemo\n        ? DEMO_COLORS[segment.id]\n        : FALLBACK_COLORS[\n            Math.max(0, FALLBACK_COLORS.length - segments.length + index)\n          ])\n  );\n\n  const used = segments.reduce((sum, segment) => sum + segment.tokens, 0);\n  const share = used / limit;\n  const shownShare = useTweened(share);\n  const [open, setOpen] = useState(defaultOpen);\n  const panelId = useId();\n  const triggerRef = useRef<HTMLButtonElement>(null);\n\n  // Closes on Escape or a press outside, like any popover.\n  useEffect(() => {\n    if (!open) {\n      return;\n    }\n    const onPointer = (event: PointerEvent) => {\n      if (!rootRef.current?.contains(event.target as Node)) {\n        setOpen(false);\n      }\n    };\n    const onKey = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        // Focus inside the panel would be lost with it; hand it back.\n        if (rootRef.current?.contains(document.activeElement)) {\n          triggerRef.current?.focus();\n        }\n        setOpen(false);\n      }\n    };\n    document.addEventListener(\"pointerdown\", onPointer);\n    document.addEventListener(\"keydown\", onKey);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onPointer);\n      document.removeEventListener(\"keydown\", onKey);\n    };\n  }, [open]);\n\n  const handleCompact = isDemo ? demo.compact : onCompact;\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <div className={cn(\"w-full max-w-sm\", className)} ref={rootRef}>\n        <div className=\"relative flex h-11 items-center justify-between rounded-xl bg-black/5 pr-1.5 pl-3 dark:bg-white/5\">\n          <span className=\"text-xs text-zinc-600 dark:text-zinc-300\">\n            {model}\n          </span>\n          <button\n            aria-controls={panelId}\n            aria-expanded={open}\n            aria-label={`Context ${Math.round(share * 100)}% full. Show breakdown`}\n            className={cn(\n              \"flex h-8 items-center gap-1.5 rounded-lg px-2 text-xs outline-hidden transition-colors duration-150 hover:bg-black/5 focus-visible:ring-2 focus-visible:ring-zinc-400/50 dark:hover:bg-white/10\",\n              open && \"bg-black/5 dark:bg-white/10\",\n              levelColor(share, compactAt)\n            )}\n            onClick={() => setOpen((value) => !value)}\n            ref={triggerRef}\n            type=\"button\"\n          >\n            <Ring share={shownShare} />\n            <span className=\"font-medium tabular-nums\">\n              {Math.round(shownShare * 100)}%\n            </span>\n          </button>\n          {/* After the trigger in the DOM, so Tab moves from it into the\n            panel, which still opens above. */}\n          <AnimatePresence>\n            {open && (\n              <motion.div\n                animate={{ opacity: 1, scale: 1, y: 0 }}\n                className=\"absolute right-0 bottom-full z-10 mb-2 origin-bottom-right rounded-xl bg-white shadow-[0_8px_24px_-8px_rgb(0_0_0/0.18)] ring-1 ring-black/10 dark:bg-zinc-900 dark:ring-white/10\"\n                exit={{ opacity: 0, scale: 0.97, y: 4 }}\n                id={panelId}\n                initial={{ opacity: 0, scale: 0.97, y: 4 }}\n                transition={{ duration: 0.16, ease: EASE_OUT }}\n              >\n                <Breakdown\n                  colors={colors}\n                  compactAt={compactAt}\n                  limit={limit}\n                  onCompact={handleCompact}\n                  segments={segments}\n                />\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </div>\n      </div>\n    </MotionConfig>\n  );\n}\n",
      "path": "/components/kokonutui/ai-context-meter.tsx",
      "target": "components/kokonutui/ai-context-meter.tsx"
    }
  ]
}