"use client"; import { Loader2, Minimize2 } from "lucide-react"; import { motion, useReducedMotion } from "motion/react"; import { type ReactNode, useEffect, useRef, useState } from "react"; import { Badge } from "@/components/motion/badge"; import { Button } from "@/components/motion/button"; import { MorphPopover, MorphPopoverContent, } from "@/components/motion/popover-morph"; import { EASE_OUT } from "@/lib/ease"; import { useHoverCapable } from "@/lib/hooks/use-hover-capable"; import { cn } from "@/lib/utils"; export interface ContextSegment { id: string; label: string; tokens: number; /** Any CSS color. Defaults to `--chart-1..5` by position. */ color?: string; } export type ContextMeterStatus = "ok" | "warning" | "critical"; export interface ContextMeterProps { /** Usage breakdown, e.g. system / conversation / files / tools. */ segments: ContextSegment[]; /** Context window size in tokens. */ limit: number; /** Fraction of the window at which the meter turns to a warning. Default 0.8. */ warnAt?: number; /** Fraction at which it turns critical. Default 0.95. */ criticalAt?: number; /** Shows a "Compact conversation" action. May return a promise. */ onCompact?: () => void | Promise; compactLabel?: string; /** Controlled panel state. */ open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; /** Open the breakdown on hover (hover-capable pointers only). Default true. */ openOnHover?: boolean; side?: "top" | "bottom"; align?: "start" | "end"; title?: ReactNode; className?: string; contentClassName?: string; } /** 84213 → "84.2k", 200000 → "200k", 1000000 → "1M". */ export function formatTokens(tokens: number) { const trim = (n: number) => n >= 100 || Number.isInteger(n) ? Math.round(n).toString() : n.toFixed(1); if (tokens >= 1_000_000) return `${trim(tokens / 1_000_000)}M`; if (tokens >= 1000) return `${trim(tokens / 1000)}k`; return Math.round(tokens).toString(); } const STATUS_COLOR: Record = { ok: "var(--foreground)", warning: "var(--warning)", critical: "var(--destructive)", }; const HOVER_OPEN_DELAY = 120; const HOVER_CLOSE_DELAY = 220; const RING_RADIUS = 7; export function ContextMeter({ segments, limit, warnAt = 0.8, criticalAt = 0.95, onCompact, compactLabel = "Compact conversation", open, defaultOpen = false, onOpenChange, openOnHover = true, side = "top", align = "start", title = "Context window", className, contentClassName, }: ContextMeterProps) { const reduce = useReducedMotion() ?? false; const canHover = useHoverCapable(); const triggerRef = useRef(null); const panelRef = useRef(null); const hoverTimer = useRef(undefined); // Hover-opened panels close on leave; a click pins the panel open. const hoverOpened = useRef(false); const [internalOpen, setInternalOpen] = useState(defaultOpen); const [compacting, setCompacting] = useState(false); const isOpen = open ?? internalOpen; const used = segments.reduce((sum, segment) => sum + segment.tokens, 0); const ratio = limit > 0 ? Math.min(used / limit, 1) : 0; const percent = Math.round(ratio * 100); const status: ContextMeterStatus = ratio >= criticalAt ? "critical" : ratio >= warnAt ? "warning" : "ok"; const free = Math.max(limit - used, 0); useEffect(() => () => window.clearTimeout(hoverTimer.current), []); const setOpen = (next: boolean) => { window.clearTimeout(hoverTimer.current); if (!next) { hoverOpened.current = false; // Focus would be stranded in the panel as it unmounts; return it. if (panelRef.current?.contains(document.activeElement)) { triggerRef.current?.focus({ preventScroll: true }); } } if (open === undefined) setInternalOpen(next); onOpenChange?.(next); }; const hoverEnabled = openOnHover && canHover; const scheduleOpen = () => { if (!hoverEnabled) return; window.clearTimeout(hoverTimer.current); if (isOpen) return; hoverTimer.current = window.setTimeout(() => { hoverOpened.current = true; setOpen(true); }, HOVER_OPEN_DELAY); }; const scheduleClose = () => { if (!hoverEnabled) return; window.clearTimeout(hoverTimer.current); if (!isOpen || !hoverOpened.current) return; hoverTimer.current = window.setTimeout( () => setOpen(false), HOVER_CLOSE_DELAY, ); }; const compact = async () => { if (!onCompact || compacting) return; setCompacting(true); try { await onCompact(); } finally { setCompacting(false); } }; // Each segment is a full-width bar scaled to its share and translated to // where the previous ones end: transform-only, so changes glide smoothly. let offset = 0; const bars = segments.map((segment, index) => { const share = limit > 0 ? segment.tokens / limit : 0; const start = Math.min(offset, 1); offset += share; return { ...segment, color: segment.color ?? `var(--chart-${(index % 5) + 1})`, share, start, width: Math.max(Math.min(share, 1 - start), 0), }; }); const label = `Context: ${formatTokens(used)} of ${formatTokens(limit)} tokens used (${percent}%)`; return (
{ if (hoverEnabled) window.clearTimeout(hoverTimer.current); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }} className="p-3.5" >

{title}

{status !== "ok" ? ( {status === "critical" ? "Almost full" : "Near limit"} ) : ( {percent}% )}

{used.toLocaleString("en-US")} {" "} of {limit.toLocaleString("en-US")} tokens {status !== "ok" ? ` · ${percent}%` : ""}

    {bars.map((bar) => (
  • ))}
{onCompact ? (
{status !== "ok" ? (

Older turns will soon be dropped. Compacting summarizes them to free space.

) : null}
) : null}
); }