import { useCallback, useState } from "react"; import { Pressable, Text, View } from "react-native"; import Svg, { Circle } from "react-native-svg"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { ProviderUsageTooltipSection } from "@/provider-usage/tooltip-section"; import { useProviderUsage } from "@/provider-usage/use-provider-usage"; import { formatTokenCount } from "./context-window-meter.utils"; interface ContextWindowMeterProps { maxTokens: number | null; usedTokens: number | null; totalCostUsd?: number | null; showPercentage?: boolean; serverId?: string; /** The Paseo provider key, e.g. "claude", "gemini", "codex" */ provider?: string | null; /** Reserve the meter footprint and show a loading ring while usage is pending. */ pending?: boolean; } const SVG_SIZE = 14; const COMPACT_SVG_SIZE = 12; const CENTER = SVG_SIZE / 2; const COMPACT_CENTER = COMPACT_SVG_SIZE / 2; const RADIUS = 6; const COMPACT_RADIUS = 5; const STROKE_WIDTH = 2; const COMPACT_STROKE_WIDTH = 1.75; const CIRCUMFERENCE = 2 * Math.PI * RADIUS; const COMPACT_CIRCUMFERENCE = 2 * Math.PI * COMPACT_RADIUS; function isValidMaxTokens(value: number): boolean { return Number.isFinite(value) && value > 0; } function isValidUsedTokens(value: number): boolean { return Number.isFinite(value) && value >= 0; } function getUsagePercentage(maxTokens: number, usedTokens: number): number | null { if (!isValidMaxTokens(maxTokens) || !isValidUsedTokens(usedTokens)) { return null; } return (usedTokens / maxTokens) * 100; } function clampPercentage(value: number): number { return Math.max(0, Math.min(100, value)); } function formatSessionCost(value: number): string | null { if (!Number.isFinite(value) || value <= 0) { return null; } if (value < 0.01) { return `$${value.toFixed(4)}`; } return `$${value.toFixed(2)}`; } function getMeterColors( percentage: number, theme: ReturnType["theme"], ): { progress: string; track: string } { const track = theme.colors.surface3; if (percentage > 90) { return { progress: theme.colors.destructive, track }; } if (percentage >= 70) { return { progress: theme.colors.palette.amber[500], track }; } return { progress: theme.colors.foregroundMuted, track }; } function getMeterGeometry(showPercentage: boolean) { if (showPercentage) { return { svgSize: COMPACT_SVG_SIZE, center: COMPACT_CENTER, radius: COMPACT_RADIUS, strokeWidth: COMPACT_STROKE_WIDTH, circumference: COMPACT_CIRCUMFERENCE, containerStyle: styles.containerWithLabel, }; } return { svgSize: SVG_SIZE, center: CENTER, radius: RADIUS, strokeWidth: STROKE_WIDTH, circumference: CIRCUMFERENCE, containerStyle: styles.container, }; } export function ContextWindowMeter({ maxTokens, usedTokens, totalCostUsd, showPercentage = false, serverId, provider, pending = false, }: ContextWindowMeterProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); const [isTooltipOpen, setIsTooltipOpen] = useState(false); const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage( serverId ?? null, { enabled: isTooltipOpen }, ); const percentage = maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null; const handleTooltipOpenChange = useCallback( (nextOpen: boolean) => { setIsTooltipOpen(nextOpen); if (nextOpen) { void refreshProviderUsage().catch(() => {}); } }, [refreshProviderUsage], ); const geometry = getMeterGeometry(showPercentage); // No usage yet: reserve the footprint with a track-only ring while a session is // active so the real ring fades in without shifting siblings. Render nothing when // no usage is expected. if (percentage === null || maxTokens === null || usedTokens === null) { if (!pending) { return null; } return ( {showPercentage ? : null} ); } const clampedPercentage = clampPercentage(percentage); const roundedPercentage = Math.round(percentage); const { svgSize, center, radius, strokeWidth, circumference, containerStyle } = geometry; const dashOffset = circumference - (clampedPercentage / 100) * circumference; const colors = getMeterColors(clampedPercentage, theme); const formattedSessionCost = typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null; return ( {showPercentage ? ( {`${roundedPercentage}%`} ) : null} {t("contextWindow.title")} {t("contextWindow.used", { percentage: roundedPercentage })} {t("contextWindow.tokens", { used: formatTokenCount(usedTokens), max: formatTokenCount(maxTokens), })} {formattedSessionCost ? ( {t("contextWindow.sessionCost", { cost: formattedSessionCost })} ) : null} ); } const styles = StyleSheet.create((theme) => ({ container: { width: 28, height: 28, borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", }, containerWithLabel: { height: 28, flexDirection: "row", alignItems: "center", justifyContent: "center", gap: theme.spacing[1], borderRadius: theme.borderRadius.full, }, svg: { transform: [{ rotate: "-90deg" }], }, percentageLabel: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, }, skeletonLabel: { width: 22, height: theme.fontSize.sm, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.surface3, }, tooltipContent: { gap: theme.spacing[1.5], minWidth: 200, }, tooltipTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, }, tooltipText: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, lineHeight: theme.fontSize.sm * 1.4, }, tooltipDetail: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, lineHeight: theme.fontSize.xs * 1.4, }, }));