Files
boocode/apps/web/src/components/ContextMeter.tsx

142 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react';
import type { Message } from '@/api/types';
import { cn } from '@/lib/utils';
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<HTMLDivElement>(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 7090%, 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 (
<div ref={ref} className="relative shrink-0">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-label={`Context window ${rounded ?? 0}% used`}
title={rounded === null ? 'Model context unknown' : `Context window ${rounded}% used`}
className="inline-flex items-center gap-1 rounded-full px-1 text-muted-foreground hover:text-foreground max-md:min-h-[36px]"
>
<svg width={SIZE} height={SIZE} viewBox={`0 0 ${SIZE} ${SIZE}`} className="-rotate-90 shrink-0">
<circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="none"
className="stroke-muted"
strokeWidth={STROKE}
/>
<circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="none"
className={cn('transition-all duration-200 motion-reduce:transition-none', progressClass)}
strokeWidth={STROKE}
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
strokeDashoffset={offset}
/>
</svg>
<span className={cn('text-[11px] font-mono tabular-nums', labelClass)}>
{rounded === null ? '—' : `${rounded}%`}
</span>
</button>
{open && (
<div className="absolute bottom-full right-0 z-50 mb-2 w-max rounded-lg border border-border bg-popover px-3 py-2 text-left shadow-md">
<p className="text-sm text-foreground">Context window</p>
<p className="text-sm text-foreground">{rounded === null ? 'Unknown' : `${rounded}% used`}</p>
{max !== null && (
<p className="text-xs text-muted-foreground">
{formatTokens(used)} / {formatTokens(max)} tokens
</p>
)}
{cost && <p className="text-xs text-muted-foreground">Session cost {cost}</p>}
</div>
)}
</div>
);
}