| 1 | import { Button } from "../ui/button"; |
| 2 | import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/contextWindow"; |
| 3 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 4 | import { |
| 5 | formatContextWindowCompactionMessage, |
| 6 | formatContextWindowCost, |
| 7 | } from "./ContextWindowMeter.logic"; |
| 8 | import { Minimize2Icon } from "lucide-react"; |
| 9 | import { composerFloatingLayerProps } from "./composerEventScope"; |
| 10 | |
| 11 | function 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 | |
| 21 | export 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. */ |
| 178 | export function ContextWindowMeterPlaceholder() { |
| 179 | return <span aria-hidden="true" className="size-7 shrink-0" />; |
| 180 | } |