import { useCallback, useMemo, useState } from "react";
import { Pressable, Text, View, useWindowDimensions } from "react-native";
import Svg, { Circle } from "react-native-svg";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { useHostReportsUsage } from "@/usage";
import type { Theme } from "@/styles/theme";
import { ContextWindowDetails } from "./context-window-details";
import { ContextWindowSheet } from "./context-window-sheet";
import { formatContextPercentage } from "./context-window-meter.utils";
interface ContextWindowMeterProps {
serverId: string;
agentId: string;
maxTokens: number | null;
usedTokens: number | null;
totalCostUsd?: number | null;
showPercentage?: boolean;
/** Optional glyph envelope for icon-toolbar alignment. */
glyphSize?: number;
}
const SVG_SIZE = 14;
const USAGE_POPOVER_WIDTH = 300;
const COMPACT_SVG_SIZE = 12;
const COMPACT_RADIUS = 5;
const STROKE_WIDTH = 2;
const COMPACT_STROKE_WIDTH = 1.75;
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 getProgressColor(percentage: number, theme: Theme): string {
if (percentage > 90) {
return theme.colors.destructive;
}
if (percentage >= 70) {
return theme.colors.palette.amber[500];
}
return theme.colors.foregroundMuted;
}
function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
if (showPercentage) {
return {
svgSize: COMPACT_SVG_SIZE,
radius: COMPACT_RADIUS,
strokeWidth: COMPACT_STROKE_WIDTH,
containerStyle: styles.containerWithLabel,
};
}
const resolvedSize = glyphSize ?? SVG_SIZE;
const resolvedStrokeWidth = glyphSize ? 2 : STROKE_WIDTH;
return {
svgSize: resolvedSize,
radius: (resolvedSize - resolvedStrokeWidth) / 2,
strokeWidth: resolvedStrokeWidth,
containerStyle: styles.container,
};
}
// Wrap the whole SVG: withUnistyles adds a div on web, which cannot sit inside an SVG.
const ContextWindowRing = withUnistyles(function ContextWindowRing({
size,
radius,
strokeWidth,
percentage,
trackColor,
progressColor,
}: {
size: number;
radius: number;
strokeWidth: number;
percentage: number | null;
trackColor: string;
progressColor: string;
}) {
const center = size / 2;
const circumference = 2 * Math.PI * radius;
return (
);
});
export function ContextWindowMeter({
serverId,
agentId,
maxTokens,
usedTokens,
totalCostUsd,
showPercentage = false,
glyphSize,
}: ContextWindowMeterProps) {
const { t, i18n } = useTranslation();
const { width } = useWindowDimensions();
// Usage cards need a wider popover; without them it keeps the plain tooltip shape.
const showsUsage = useHostReportsUsage(serverId);
const popoverWidth = Math.min(USAGE_POPOVER_WIDTH, width - 24);
// Compact screens open the details in a sheet, which can hold a pressable Refresh.
const isCompact = useIsCompactFormFactor();
const [isSheetOpen, setIsSheetOpen] = useState(false);
const openSheet = useCallback(() => setIsSheetOpen(true), []);
const closeSheet = useCallback(() => setIsSheetOpen(false), []);
const percentage =
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
const geometry = getMeterGeometry(showPercentage, glyphSize);
const context = useMemo(
() =>
percentage !== null && maxTokens !== null && usedTokens !== null
? { percentage: Math.round(percentage), maxTokens, usedTokens }
: null,
[percentage, maxTokens, usedTokens],
);
const meterColors = useCallback(
(theme: Theme) => ({
progressColor: getProgressColor(percentage ?? 0, theme),
trackColor: theme.colors.surface3,
}),
[percentage],
);
const formattedSessionCost =
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
const containerStyle = geometry.containerStyle;
const ring = (
);
const percentageLabel =
showPercentage && context ? (
{formatContextPercentage(context.percentage, i18n.resolvedLanguage)}
) : null;
const accessibilityLabel = context
? t("contextWindow.accessibility", { percentage: context.percentage })
: t("contextWindow.accessibilityNoData");
if (isCompact) {
return (
<>
{ring}
{percentageLabel}
>
);
}
const popoverStyle = showsUsage
? [styles.usagePopover, { width: popoverWidth }]
: styles.plainPopover;
// Native wide screens have no hover, so the details open in a tooltip on press. The tooltip
// takes no presses, so its usage cards have no Refresh.
if (isNative) {
return (
{ring}
{percentageLabel}
);
}
return (
{ring}
{percentageLabel}
);
}
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,
},
percentageLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
// Plain details use a small inset; account usage cards have their own content density.
plainPopover: { paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[2] },
usagePopover: { padding: theme.spacing[3], gap: theme.spacing[3] },
}));