import { useEffect, useRef, useState } from 'react'; import type { Message } from '@/api/types'; import { cn } from '@/lib/utils'; // Circular context-window meter — a small SVG ring (Paseo-style) that lives in // the composer footer beside the send button. Tap/click toggles a popover with // the full detail (% used, used/max tokens, optional session cost). Replaces the // old inline ContextBar (a horizontal bar in the toolbar row above the box). interface Props { messages: Message[]; // Zero-state fallback: the model's full context window from // chat.model_context_limit (server getModelContext lookup). Lets the ring // render a meaningful 0% before any assistant turn has reported usage. modelContextLimit?: number | null; // Optional session cost (USD). Omitted today (local llama-swap is free); the // popover line only shows when a positive number is passed. sessionCostUsd?: number | null; } const SIZE = 18; const CENTER = SIZE / 2; const RADIUS = 7; const STROKE = 2.25; const CIRCUMFERENCE = 2 * Math.PI * RADIUS; // Take the latest ctx_used and ctx_max INDEPENDENTLY (newest-first) — they need // not be on the same message (some agents report the window only intermittently // while reporting usage every turn). Mirrors the old ContextBar logic. function latestPair(messages: Message[]): { used: number; max: number } | null { let used: number | null = null; let max: number | null = null; for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i]!; if (used === null && m.ctx_used != null) used = m.ctx_used; if (max === null && m.ctx_max != null && m.ctx_max > 0) max = m.ctx_max; if (used !== null && max !== null) break; } return used !== null && max !== null ? { used, max } : null; } function formatTokens(v: number): string { if (v >= 1_000_000) return `${Math.round(v / 1_000_000)}m`; if (v >= 1_000) return `${Math.round(v / 1_000)}k`; return `${Math.round(v)}`; } function formatCost(v: number): string { return v < 0.01 ? `$${v.toFixed(4)}` : `$${v.toFixed(2)}`; } export function ContextMeter({ messages, modelContextLimit, sessionCostUsd }: Props) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { if (!open) return; const onDown = (e: MouseEvent | TouchEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener('mousedown', onDown); document.addEventListener('touchstart', onDown); return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('touchstart', onDown); }; }, [open]); const pair = latestPair(messages); const max = pair?.max ?? (modelContextLimit && modelContextLimit > 0 ? modelContextLimit : null); const used = pair?.used ?? 0; const ratio = max ? Math.max(0, Math.min(1, used / max)) : 0; const rounded = max ? Math.round((used / max) * 100) : null; const offset = CIRCUMFERENCE - ratio * CIRCUMFERENCE; // Paseo thresholds on raw usage: muted < 70%, amber 70–90%, red > 90%. const progressClass = rounded === null ? 'stroke-muted-foreground/40' : rounded > 90 ? 'stroke-red-500' : rounded >= 70 ? 'stroke-amber-500' : 'stroke-muted-foreground'; const labelClass = rounded === null ? 'text-muted-foreground' : rounded > 90 ? 'text-red-600 dark:text-red-400' : rounded >= 70 ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground'; const cost = typeof sessionCostUsd === 'number' && sessionCostUsd > 0 ? formatCost(sessionCostUsd) : null; return (
{open && (

Context window

{rounded === null ? 'Unknown' : `${rounded}% used`}

{max !== null && (

{formatTokens(used)} / {formatTokens(max)} tokens

)} {cost &&

Session cost {cost}

}
)}
); }