1import { Button } from "../ui/button";
2import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/contextWindow";
3import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
4import {
5 formatContextWindowCompactionMessage,
6 formatContextWindowCost,
7} from "./ContextWindowMeter.logic";
8import { Minimize2Icon } from "lucide-react";
9import { composerFloatingLayerProps } from "./composerEventScope";
10
11function formatPercentage(value: number | null): string | null {
12 if (value === null || !Number.isFinite(value)) {
13 return null;
14 }
15 if (value < 10) {
16 return `${value.toFixed(1).replace(/\.0$/, "")}%`;
17 }
18 return `${Math.round(value)}%`;
19}
20
21export function ContextWindowMeter(props: {
22 usage: ContextWindowSnapshot;
23 modelDisplayName?: string | null;
24 onCompact?: (() => void) | undefined;
25 compactDisabled?: boolean | undefined;
26 compactDisabledReason?: string | null | undefined;
27}) {
28 const { usage, modelDisplayName, onCompact, compactDisabled, compactDisabledReason } = props;
29 const usedPercentage = formatPercentage(usage.usedPercentage);
30 const normalizedPercentage = Math.max(0, Math.min(100, usage.usedPercentage ?? 0));
31 const radius = 9.75;
32 const circumference = 2 * Math.PI * radius;
33 const dashOffset = circumference * (1 - normalizedPercentage / 100);
34 const totalProcessedTokens = usage.totalProcessedTokens ?? null;
35 const showTotalProcessed = totalProcessedTokens !== null && totalProcessedTokens > 0;
36 const isOverloaded = normalizedPercentage > 90;
37 const usageColor = isOverloaded
38 ? "var(--color-error)"
39 : "color-mix(in oklab, var(--color-muted-foreground) 72%, transparent)";
40
41 return (
42 <Popover>
43 <PopoverTrigger
44 openOnHover
45 delay={150}
46 closeDelay={onCompact ? 150 : 0}
47 render={
48 <Button
49 size="icon-sm"
50 variant="ghost-muted"
51 className="size-7"
52 aria-label={
53 usage.maxTokens !== null && usedPercentage
54 ? `Context window ${usedPercentage} used`
55 : `Context window ${formatContextWindowTokens(usage.usedTokens)} tokens used`
56 }
57 >
58 <span className="relative flex size-5 items-center justify-center">
59 <svg
60 viewBox="0 0 24 24"
61 className="-rotate-90 absolute inset-0 size-full transform-gpu mx-0!"
62 aria-hidden="true"
63 >
64 <circle
65 cx="12"
66 cy="12"
67 r={radius}
68 fill="none"
69 className="stroke-muted-foreground/24"
70 strokeWidth="3"
71 />
72 <circle
73 cx="12"
74 cy="12"
75 r={radius}
76 fill="none"
77 stroke={usageColor}
78 strokeWidth="3"
79 strokeLinecap="round"
80 strokeDasharray={circumference}
81 strokeDashoffset={dashOffset}
82 className="transition-[stroke-dashoffset,stroke] duration-500 ease-out motion-reduce:transition-none"
83 />
84 </svg>
85 </span>
86 </Button>
87 }
88 />
89 <PopoverPopup
90 {...composerFloatingLayerProps}
91 tooltipStyle
92 side="top"
93 align="end"
94 padding="none"
95 width="sm"
96 className="text-left whitespace-normal"
97 >
98 <div className="flex flex-col gap-2 p-(--floating-content-inset)">
99 <div className="flex items-center justify-between gap-3">
100 <div className="font-medium text-muted-foreground text-xs">Context Window</div>
101 {usage.maxTokens !== null && usedPercentage ? (
102 <div className="text-secondary-label text-2xs tabular-nums">
103 <span>{usedPercentage}</span>
104 <span className="mx-1">·</span>
105 <span>
106 {formatContextWindowTokens(usage.usedTokens)}/
107 {formatContextWindowTokens(usage.maxTokens ?? null)}
108 </span>
109 </div>
110 ) : (
111 <div className="text-secondary-label text-2xs tabular-nums">
112 {formatContextWindowTokens(usage.usedTokens)}
113 </div>
114 )}
115 </div>
116 {usage.maxTokens !== null ? (
117 <div
118 className="h-1.5 w-full overflow-hidden rounded-full bg-muted/60"
119 role="progressbar"
120 aria-valuemin={0}
121 aria-valuemax={100}
122 aria-valuenow={Math.round(normalizedPercentage)}
123 aria-label="Context window usage"
124 >
125 <div
126 className="h-full rounded-full transition-[width,background-color] duration-500 ease-out motion-reduce:transition-none"
127 style={{ width: `${normalizedPercentage}%`, backgroundColor: usageColor }}
128 />
129 </div>
130 ) : null}
131 {showTotalProcessed ? (
132 <div className="flex items-center justify-between gap-3 text-2xs leading-4">
133 <span className="text-secondary-label">Total processed</span>
134 <span className="font-medium tabular-nums text-secondary-label">
135 {formatContextWindowTokens(totalProcessedTokens)}
136 </span>
137 </div>
138 ) : null}
139 {usage.cost != null ? (
140 <div className="flex items-center justify-between gap-3 text-2xs leading-4">
141 <span className="text-secondary-label">Cost</span>
142 <span className="font-medium tabular-nums text-secondary-label">
143 {formatContextWindowCost(usage.cost)}
144 </span>
145 </div>
146 ) : null}
147 {usage.compactsAutomatically ? (
148 <div className="mt-1 text-pretty text-secondary-label text-2xs font-medium">
149 {formatContextWindowCompactionMessage(modelDisplayName, usage.autoCompactThreshold)}
150 </div>
151 ) : null}
152 {onCompact ? (
153 <>
154 <Button
155 size="xs"
156 variant="outline"
157 className="mt-1 w-full justify-center"
158 disabled={compactDisabled}
159 onClick={onCompact}
160 >
161 <Minimize2Icon aria-hidden="true" />
162 Compact context
163 </Button>
164 {compactDisabled && compactDisabledReason ? (
165 <div className="text-pretty text-secondary-label text-2xs">
166 {compactDisabledReason}
167 </div>
168 ) : null}
169 </>
170 ) : null}
171 </div>
172 </PopoverPopup>
173 </Popover>
174 );
175}
176
177/** Holds the meter's footprint while a thread's activities are still loading. */
178export function ContextWindowMeterPlaceholder() {
179 return <span aria-hidden="true" className="size-7 shrink-0" />;
180}