| 1 | import { ChatGptUsageSummary } from "./ChatGptUsageSummary"; |
| 2 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 3 | import { EnvironmentId, USAGE_CONTRACT_VERSION, type UsageProviderKind } from "@t3tools/contracts"; |
| 4 | import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native"; |
| 5 | import { cursorKeychainAccessEnvironments } from "@t3tools/client-runtime/state/usage"; |
| 6 | import { |
| 7 | updatingProvidersLabel, |
| 8 | usageEnvironmentProgress, |
| 9 | usageProgress, |
| 10 | } from "@t3tools/client-runtime/state/usage-progress"; |
| 11 | import { |
| 12 | isCompatibleUsageContractVersion, |
| 13 | isModelCostUnknown, |
| 14 | type DailyTotals, |
| 15 | type MergedUsage, |
| 16 | } from "@t3tools/shared/usageMerge"; |
| 17 | import { |
| 18 | enumerateDays, |
| 19 | enumerateHourStarts, |
| 20 | formatCount, |
| 21 | formatDayShort, |
| 22 | formatHourShort, |
| 23 | formatPercent, |
| 24 | formatTokens, |
| 25 | formatUsageContractMismatch, |
| 26 | formatUsd, |
| 27 | makeWindow, |
| 28 | } from "@t3tools/shared/usageFormat"; |
| 29 | import { useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 30 | import { ActivityIndicator, Platform, Pressable, RefreshControl, View } from "react-native"; |
| 31 | import Animated, { |
| 32 | FadeIn, |
| 33 | ReduceMotion, |
| 34 | useAnimatedStyle, |
| 35 | useSharedValue, |
| 36 | withDelay, |
| 37 | withTiming, |
| 38 | } from "react-native-reanimated"; |
| 39 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 40 | |
| 41 | import { SegmentedControl } from "../../components/SegmentedControl"; |
| 42 | import { AppText as Text } from "../../components/AppText"; |
| 43 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 44 | import { cn } from "../../lib/cn"; |
| 45 | import { SettingsScreen } from "../settings/components/SettingsScreen"; |
| 46 | import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; |
| 47 | import { serverEnvironment } from "../../state/server"; |
| 48 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 49 | import { SettingsSection } from "../settings/components/SettingsSection"; |
| 50 | import { UsageDailyChart } from "./UsageDailyChart"; |
| 51 | import { toggleUsageEnvironment } from "./usageEnvironmentSelection"; |
| 52 | import { useRefreshLimits } from "./UsageLimitsSection"; |
| 53 | import { UsageLimitsSection } from "./UsageLimitsPooled"; |
| 54 | import { ControlPillMenu } from "../../components/ControlPill"; |
| 55 | import { SymbolView } from "../../components/AppSymbol"; |
| 56 | import type { UsageChartMetric } from "./usageChartData"; |
| 57 | import { PROVIDER_LABEL, useProviderColors, useUsageMixColors } from "./usageProviders"; |
| 58 | |
| 59 | type UsageTab = "usage" | "limits"; |
| 60 | const TAB_OPTIONS = [ |
| 61 | { value: "usage", label: "Usage" }, |
| 62 | { value: "limits", label: "Limits" }, |
| 63 | ] as const satisfies readonly { value: UsageTab; label: string }[]; |
| 64 | |
| 65 | // Labels are abbreviated to share a row with the metric toggle; screen |
| 66 | // readers get the full phrase. |
| 67 | const WINDOW_OPTIONS = [ |
| 68 | { value: 1, label: "24h", accessibilityLabel: "Past 24 hours" }, |
| 69 | { value: 7, label: "7d", accessibilityLabel: "Past 7 days" }, |
| 70 | { value: 30, label: "30d", accessibilityLabel: "Past 30 days" }, |
| 71 | { value: 90, label: "90d", accessibilityLabel: "Past 90 days" }, |
| 72 | ] as const; |
| 73 | |
| 74 | const METRIC_OPTIONS = [ |
| 75 | { value: "cost", label: "Cost" }, |
| 76 | { value: "tokens", label: "Tokens" }, |
| 77 | ] as const satisfies readonly { value: UsageChartMetric; label: string }[]; |
| 78 | |
| 79 | const CHART_HEIGHT = 180; |
| 80 | const providerLabel = (provider: UsageProviderKind) => PROVIDER_LABEL[provider]; |
| 81 | const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain."; |
| 82 | |
| 83 | /** |
| 84 | * Two tabs over one screen. Usage is the transcript-derived spend for a |
| 85 | * period; Limits is the live subscription quota, which has no period. Both |
| 86 | * pull to refresh, each refreshing its own data. |
| 87 | */ |
| 88 | export function UsageRouteScreen() { |
| 89 | const route = useRoute<RouteProp<{ Usage: { tab?: string } | undefined }, "Usage">>(); |
| 90 | const navigation = useNavigation(); |
| 91 | const insets = useSafeAreaInsets(); |
| 92 | // Preserve the Limits default while honoring explicit widget/navigation links. |
| 93 | const [selection, setSelection] = useState(() => ({ |
| 94 | params: route.params, |
| 95 | tab: (route.params?.tab === "usage" ? "usage" : "limits") as UsageTab, |
| 96 | })); |
| 97 | if (selection.params !== route.params) { |
| 98 | setSelection({ |
| 99 | params: route.params, |
| 100 | tab: route.params?.tab === "usage" ? "usage" : "limits", |
| 101 | }); |
| 102 | } |
| 103 | const { tab } = selection; |
| 104 | const setTab = (tab: UsageTab) => setSelection({ params: route.params, tab }); |
| 105 | const [windowSelection, setWindowSelection] = useState(() => ({ |
| 106 | days: 30, |
| 107 | window: makeWindow(30), |
| 108 | })); |
| 109 | const [metric, setMetric] = useState<UsageChartMetric>("cost"); |
| 110 | const { days: windowDays, window } = windowSelection; |
| 111 | const isPast24Hours = windowDays === 1; |
| 112 | const [selectedEnvironmentIds, setSelectedEnvironmentIds] = |
| 113 | useState<ReadonlySet<EnvironmentId> | null>(null); |
| 114 | const { merged, environments, selectedEnvironments, isPending, refresh } = useUsage( |
| 115 | window, |
| 116 | selectedEnvironmentIds, |
| 117 | ); |
| 118 | const isFocused = useIsFocused(); |
| 119 | const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits"); |
| 120 | const cursorAccessEnvironments = cursorKeychainAccessEnvironments(selectedEnvironments); |
| 121 | const refreshAfterCursorEnable = () => { |
| 122 | void refresh(); |
| 123 | void limits.refreshAfterEnable(); |
| 124 | }; |
| 125 | const sourceMessages = [ |
| 126 | ...new Set( |
| 127 | selectedEnvironments.flatMap( |
| 128 | (environment) => |
| 129 | environment.summary?.sources.flatMap((source) => |
| 130 | source.message && |
| 131 | !source.action && |
| 132 | (source.status === "partial" || |
| 133 | source.status === "failed" || |
| 134 | source.fingerprint.provider === "cursor") |
| 135 | ? [source.message] |
| 136 | : [], |
| 137 | ) ?? [], |
| 138 | ), |
| 139 | ), |
| 140 | ]; |
| 141 | const canReadDiagnostics = selectedEnvironments.some( |
| 142 | (environment) => environment.canReadDiagnostics, |
| 143 | ); |
| 144 | |
| 145 | const days = useMemo( |
| 146 | () => enumerateDays(window.sinceDay, window.untilDay), |
| 147 | [window.sinceDay, window.untilDay], |
| 148 | ); |
| 149 | const chartDays = useMemo( |
| 150 | () => |
| 151 | isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined |
| 152 | ? enumerateHourStarts(window.sinceTime, window.untilTime) |
| 153 | : days, |
| 154 | [days, isPast24Hours, window.sinceTime, window.untilTime], |
| 155 | ); |
| 156 | const chartTotals = useMemo( |
| 157 | (): readonly DailyTotals[] => |
| 158 | isPast24Hours |
| 159 | ? merged.hourly.map((hour) => ({ |
| 160 | day: hour.hourStart, |
| 161 | costUsd: hour.costUsd, |
| 162 | totalTokens: hour.totalTokens, |
| 163 | byProvider: hour.byProvider, |
| 164 | })) |
| 165 | : merged.daily, |
| 166 | [isPast24Hours, merged.daily, merged.hourly], |
| 167 | ); |
| 168 | |
| 169 | const [refreshingUsage, setRefreshingUsage] = useState(false); |
| 170 | const refreshingRef = useRef(false); |
| 171 | const showingLimits = tab === "limits"; |
| 172 | const progress = usageProgress(selectedEnvironments, { |
| 173 | refreshing: refreshingUsage, |
| 174 | providerLabel, |
| 175 | }); |
| 176 | const selectWindow = (days: number) => { |
| 177 | setWindowSelection({ |
| 178 | days, |
| 179 | window: makeWindow(days, undefined, days === 1 ? "hour" : "day"), |
| 180 | }); |
| 181 | }; |
| 182 | const refreshWindow = () => { |
| 183 | if (refreshingRef.current) return; |
| 184 | const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); |
| 185 | if ( |
| 186 | nextWindow.sinceDay !== window.sinceDay || |
| 187 | nextWindow.untilDay !== window.untilDay || |
| 188 | nextWindow.sinceTime !== window.sinceTime || |
| 189 | nextWindow.untilTime !== window.untilTime |
| 190 | ) { |
| 191 | setWindowSelection({ days: windowDays, window: nextWindow }); |
| 192 | } |
| 193 | refreshingRef.current = true; |
| 194 | setRefreshingUsage(true); |
| 195 | void refresh(nextWindow).finally(() => { |
| 196 | refreshingRef.current = false; |
| 197 | setRefreshingUsage(false); |
| 198 | }); |
| 199 | }; |
| 200 | |
| 201 | const showEnvironmentFilter = environments.length > 0 || selectedEnvironmentIds !== null; |
| 202 | const filterIcon = |
| 203 | selectedEnvironmentIds === null |
| 204 | ? "line.3.horizontal.decrease" |
| 205 | : "line.3.horizontal.decrease.circle.fill"; |
| 206 | const environmentActions = useMemo( |
| 207 | () => [ |
| 208 | { |
| 209 | id: "all", |
| 210 | title: "All environments", |
| 211 | subtitle: undefined, |
| 212 | state: selectedEnvironmentIds === null ? ("on" as const) : ("off" as const), |
| 213 | }, |
| 214 | ...environments.map((environment) => ({ |
| 215 | id: environment.environmentId, |
| 216 | title: environment.label, |
| 217 | subtitle: usageEnvironmentStatus(environment, refreshingUsage), |
| 218 | state: |
| 219 | selectedEnvironmentIds === null || selectedEnvironmentIds.has(environment.environmentId) |
| 220 | ? ("on" as const) |
| 221 | : ("off" as const), |
| 222 | })), |
| 223 | ], |
| 224 | [environments, refreshingUsage, selectedEnvironmentIds], |
| 225 | ); |
| 226 | const selectEnvironment = useCallback( |
| 227 | (value: string) => { |
| 228 | if (value === "all") { |
| 229 | setSelectedEnvironmentIds(null); |
| 230 | return; |
| 231 | } |
| 232 | const id = EnvironmentId.make(value); |
| 233 | setSelectedEnvironmentIds((selected) => toggleUsageEnvironment(selected, environments, id)); |
| 234 | }, |
| 235 | [environments], |
| 236 | ); |
| 237 | const environmentFilter = useMemo( |
| 238 | () => |
| 239 | showEnvironmentFilter ? ( |
| 240 | <ControlPillMenu |
| 241 | accessible |
| 242 | accessibilityRole="button" |
| 243 | accessibilityLabel="Filter usage environments" |
| 244 | title="Environments" |
| 245 | actions={environmentActions} |
| 246 | onPressAction={({ nativeEvent }) => selectEnvironment(nativeEvent.event)} |
| 247 | > |
| 248 | <Pressable |
| 249 | accessibilityRole="button" |
| 250 | accessibilityLabel="Filter usage environments" |
| 251 | className={cn( |
| 252 | "items-center justify-center rounded-full", |
| 253 | Platform.OS === "ios" ? "size-[28px]" : "size-[44px]", |
| 254 | )} |
| 255 | > |
| 256 | <SymbolView name={filterIcon} size={22} tintColorClassName="accent-icon" /> |
| 257 | </Pressable> |
| 258 | </ControlPillMenu> |
| 259 | ) : null, |
| 260 | [showEnvironmentFilter, environmentActions, selectEnvironment, filterIcon], |
| 261 | ); |
| 262 | |
| 263 | useLayoutEffect(() => { |
| 264 | if (Platform.OS === "ios") { |
| 265 | navigation.setOptions({ headerRight: () => environmentFilter }); |
| 266 | } |
| 267 | }, [navigation, environmentFilter]); |
| 268 | |
| 269 | return ( |
| 270 | <SettingsScreen title="Usage" trailing={environmentFilter}> |
| 271 | <ScrollView |
| 272 | contentInsetAdjustmentBehavior="automatic" |
| 273 | showsVerticalScrollIndicator={false} |
| 274 | className="flex-1" |
| 275 | contentContainerClassName="gap-6 px-5 pt-4" |
| 276 | contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} |
| 277 | refreshControl={ |
| 278 | showingLimits || canReadDiagnostics ? ( |
| 279 | <RefreshControl |
| 280 | refreshing={showingLimits ? limits.refreshing : refreshingUsage} |
| 281 | onRefresh={showingLimits ? () => void limits.refresh() : refreshWindow} |
| 282 | /> |
| 283 | ) : undefined |
| 284 | } |
| 285 | > |
| 286 | <SegmentedControl options={TAB_OPTIONS} selected={tab} onSelect={setTab} role="tab" /> |
| 287 | <Animated.View |
| 288 | key={tab} |
| 289 | entering={FadeIn.duration(160).reduceMotion(ReduceMotion.System)} |
| 290 | className="gap-6" |
| 291 | > |
| 292 | {showingLimits ? ( |
| 293 | <UsageLimitsSection |
| 294 | now={limits.now} |
| 295 | failedLabels={limits.failedLabels} |
| 296 | selectedEnvironmentIds={selectedEnvironmentIds} |
| 297 | cursorPrompt={ |
| 298 | cursorAccessEnvironments.length > 0 ? ( |
| 299 | <CursorEnableLimits |
| 300 | environments={cursorAccessEnvironments} |
| 301 | onEnabled={refreshAfterCursorEnable} |
| 302 | /> |
| 303 | ) : null |
| 304 | } |
| 305 | /> |
| 306 | ) : ( |
| 307 | <> |
| 308 | {/* Period and metric together: neither applies to Limits, and |
| 309 | both change every number below, so they share one bar. */} |
| 310 | <View className="gap-3 ios:flex-row ios:items-center"> |
| 311 | <SegmentedControl |
| 312 | options={WINDOW_OPTIONS} |
| 313 | selected={windowDays} |
| 314 | onSelect={selectWindow} |
| 315 | size="compact" |
| 316 | className="w-full ios:flex-1" |
| 317 | /> |
| 318 | <SegmentedControl |
| 319 | options={METRIC_OPTIONS} |
| 320 | selected={metric} |
| 321 | onSelect={setMetric} |
| 322 | size="compact" |
| 323 | className="w-full ios:w-36" |
| 324 | /> |
| 325 | </View> |
| 326 | <ChatGptUsageSummary selectedEnvironmentIds={selectedEnvironmentIds} /> |
| 327 | {merged.duplicateSources.length > 0 ? ( |
| 328 | <Text className="text-sm text-foreground-muted"> |
| 329 | Counted once across environments sharing a transcript directory:{" "} |
| 330 | {merged.duplicateSources.join(", ")} |
| 331 | </Text> |
| 332 | ) : null} |
| 333 | {isPending ? ( |
| 334 | <Text className="py-16 text-center text-base text-foreground-muted"> |
| 335 | Scanning provider transcripts… |
| 336 | </Text> |
| 337 | ) : selectedEnvironments.length === 0 ? ( |
| 338 | <Text className="py-16 text-center text-base text-foreground-muted"> |
| 339 | {environments.length === 0 |
| 340 | ? "Connect an environment to see usage." |
| 341 | : "Select an environment to see usage."} |
| 342 | </Text> |
| 343 | ) : !canReadDiagnostics ? ( |
| 344 | // Each environment explains itself: a denied grant and a failed |
| 345 | // access check are different problems. |
| 346 | <View className="gap-2 py-16"> |
| 347 | {selectedEnvironments.map((environment) => ( |
| 348 | <Text |
| 349 | key={environment.environmentId} |
| 350 | className="text-center text-base text-foreground-muted" |
| 351 | > |
| 352 | {selectedEnvironments.length > 1 ? `${environment.label}: ` : null} |
| 353 | {environment.error} |
| 354 | </Text> |
| 355 | ))} |
| 356 | </View> |
| 357 | ) : ( |
| 358 | <> |
| 359 | {sourceMessages.map((message) => ( |
| 360 | <Text key={message} className="text-sm text-foreground-muted"> |
| 361 | {message} |
| 362 | </Text> |
| 363 | ))} |
| 364 | <UsageUpdating dimmed={progress.dimmed} label={progress.label}> |
| 365 | <ChartCard |
| 366 | merged={merged} |
| 367 | days={chartDays} |
| 368 | daily={chartTotals} |
| 369 | metric={metric} |
| 370 | sinceDay={window.sinceDay} |
| 371 | untilDay={window.untilDay} |
| 372 | isPast24Hours={isPast24Hours} |
| 373 | timeZone={window.timeZone} |
| 374 | /> |
| 375 | <ProviderSection |
| 376 | merged={merged} |
| 377 | metric={metric} |
| 378 | cursorAccessEnvironments={cursorAccessEnvironments} |
| 379 | showCursorEnvironment={selectedEnvironments.length > 1} |
| 380 | onCursorEnabled={refreshAfterCursorEnable} |
| 381 | /> |
| 382 | <TotalsSection merged={merged} isPast24Hours={isPast24Hours} /> |
| 383 | <CostSection merged={merged} /> |
| 384 | <ModelsSection merged={merged} metric={metric} /> |
| 385 | </UsageUpdating> |
| 386 | </> |
| 387 | )} |
| 388 | </> |
| 389 | )} |
| 390 | </Animated.View> |
| 391 | </ScrollView> |
| 392 | </SettingsScreen> |
| 393 | ); |
| 394 | } |
| 395 | |
| 396 | /** |
| 397 | * Dims totals that are about to change and says what is still updating. The |
| 398 | * status overlays the first child's top-right corner, the chart card's label |
| 399 | * row, so appearing never moves anything. |
| 400 | */ |
| 401 | function UsageUpdating({ |
| 402 | dimmed, |
| 403 | label, |
| 404 | children, |
| 405 | }: { |
| 406 | readonly dimmed: boolean; |
| 407 | readonly label: string | null; |
| 408 | readonly children: ReactNode; |
| 409 | }) { |
| 410 | const opacity = useSharedValue(1); |
| 411 | useLayoutEffect(() => { |
| 412 | // The delay keeps a quick cached answer from flashing the dim. |
| 413 | opacity.set( |
| 414 | dimmed |
| 415 | ? withDelay(150, withTiming(0.5, { duration: 150, reduceMotion: ReduceMotion.System })) |
| 416 | : withTiming(1, { duration: 150, reduceMotion: ReduceMotion.System }), |
| 417 | ); |
| 418 | }, [opacity, dimmed]); |
| 419 | const dimStyle = useAnimatedStyle(() => ({ opacity: opacity.get() })); |
| 420 | |
| 421 | return ( |
| 422 | <View> |
| 423 | <Animated.View style={dimStyle} className="gap-6" accessibilityState={{ busy: dimmed }}> |
| 424 | {children} |
| 425 | </Animated.View> |
| 426 | {label !== null ? ( |
| 427 | <Animated.View |
| 428 | entering={FadeIn.delay(150).duration(150).reduceMotion(ReduceMotion.System)} |
| 429 | accessibilityLiveRegion="polite" |
| 430 | pointerEvents="none" |
| 431 | className="absolute right-4 top-4 h-5 max-w-[55%] flex-row items-center gap-1.5" |
| 432 | > |
| 433 | <ActivityIndicator size="small" colorClassName="accent-adaptive-sky-600-400" /> |
| 434 | <Text className="shrink text-sm text-adaptive-sky-600-400" numberOfLines={1}> |
| 435 | {label} |
| 436 | </Text> |
| 437 | </Animated.View> |
| 438 | ) : null} |
| 439 | </View> |
| 440 | ); |
| 441 | } |
| 442 | |
| 443 | function CursorEnableAction({ |
| 444 | environmentId, |
| 445 | label, |
| 446 | onEnabled, |
| 447 | buttonText = "Enable", |
| 448 | }: { |
| 449 | readonly environmentId: EnvironmentId; |
| 450 | readonly label: string; |
| 451 | readonly onEnabled: () => void; |
| 452 | readonly buttonText?: string; |
| 453 | }) { |
| 454 | const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { |
| 455 | label: "enable Cursor account usage", |
| 456 | }); |
| 457 | const [pending, setPending] = useState(false); |
| 458 | const enable = async () => { |
| 459 | setPending(true); |
| 460 | try { |
| 461 | const result = await updateSettings({ |
| 462 | environmentId, |
| 463 | input: { patch: { cursorKeychainUsageEnabled: true } }, |
| 464 | }); |
| 465 | if (result._tag === "Success") onEnabled(); |
| 466 | } finally { |
| 467 | setPending(false); |
| 468 | } |
| 469 | }; |
| 470 | return ( |
| 471 | <Pressable |
| 472 | accessibilityRole="button" |
| 473 | accessibilityLabel={`Enable Cursor usage from ${label}`} |
| 474 | accessibilityHint={CURSOR_KEYCHAIN_COPY} |
| 475 | disabled={pending} |
| 476 | onPress={() => void enable()} |
| 477 | className="rounded-full bg-primary px-4 py-2" |
| 478 | > |
| 479 | <Text className="text-sm font-medium text-primary-foreground">{buttonText}</Text> |
| 480 | </Pressable> |
| 481 | ); |
| 482 | } |
| 483 | |
| 484 | function CursorEnableRow({ |
| 485 | environmentId, |
| 486 | label, |
| 487 | showEnvironment, |
| 488 | bordered, |
| 489 | onEnabled, |
| 490 | }: { |
| 491 | readonly environmentId: EnvironmentId; |
| 492 | readonly label: string; |
| 493 | readonly showEnvironment: boolean; |
| 494 | readonly bordered: boolean; |
| 495 | readonly onEnabled: () => void; |
| 496 | }) { |
| 497 | const colors = useProviderColors(); |
| 498 | return ( |
| 499 | <View |
| 500 | className={cn( |
| 501 | "flex-row items-center justify-between gap-3 p-4", |
| 502 | bordered && "border-t border-border-subtle", |
| 503 | )} |
| 504 | > |
| 505 | <View className="min-w-0 flex-1 flex-row items-center gap-2"> |
| 506 | <View className="size-2.5 rounded-full" style={{ backgroundColor: colors.cursor }} /> |
| 507 | <Text className="shrink text-lg text-foreground"> |
| 508 | Cursor{showEnvironment ? ` · ${label}` : ""} |
| 509 | </Text> |
| 510 | </View> |
| 511 | <CursorEnableAction environmentId={environmentId} label={label} onEnabled={onEnabled} /> |
| 512 | </View> |
| 513 | ); |
| 514 | } |
| 515 | |
| 516 | function CursorEnableLimits({ |
| 517 | environments, |
| 518 | onEnabled, |
| 519 | }: { |
| 520 | readonly environments: readonly EnvironmentUsageStatus[]; |
| 521 | readonly onEnabled: () => void; |
| 522 | }) { |
| 523 | return ( |
| 524 | <View className="gap-3"> |
| 525 | <View className="flex-row items-center gap-2 px-1"> |
| 526 | <ProviderIcon provider="cursor" size={18} /> |
| 527 | <Text className="text-base font-t3-medium text-foreground">Cursor</Text> |
| 528 | </View> |
| 529 | <View className="items-start gap-3 rounded-[24px] border-continuous bg-grouped-card p-4"> |
| 530 | <Text className="text-xs text-foreground-muted">{CURSOR_KEYCHAIN_COPY}</Text> |
| 531 | <View className="flex-row flex-wrap gap-2"> |
| 532 | {environments.map((environment) => ( |
| 533 | <CursorEnableAction |
| 534 | key={environment.environmentId} |
| 535 | environmentId={environment.environmentId} |
| 536 | label={environment.label} |
| 537 | buttonText={environments.length > 1 ? `Enable on ${environment.label}` : "Enable"} |
| 538 | onEnabled={onEnabled} |
| 539 | /> |
| 540 | ))} |
| 541 | </View> |
| 542 | </View> |
| 543 | </View> |
| 544 | ); |
| 545 | } |
| 546 | |
| 547 | /** Headline figure, the animated daily chart, and its legend, in one card. */ |
| 548 | function ChartCard(props: { |
| 549 | readonly merged: MergedUsage; |
| 550 | readonly days: readonly string[]; |
| 551 | readonly daily: readonly DailyTotals[]; |
| 552 | readonly metric: UsageChartMetric; |
| 553 | readonly sinceDay: string; |
| 554 | readonly untilDay: string; |
| 555 | readonly isPast24Hours: boolean; |
| 556 | readonly timeZone: string; |
| 557 | }) { |
| 558 | const { merged, metric } = props; |
| 559 | const colors = useProviderColors(); |
| 560 | const hasActivity = props.daily.some((period) => period.totalTokens > 0); |
| 561 | |
| 562 | return ( |
| 563 | <View className="gap-4 rounded-[24px] border-continuous bg-grouped-card p-4"> |
| 564 | <View className="gap-0.5"> |
| 565 | <Text className="text-sm text-foreground-muted"> |
| 566 | {metric === "cost" ? "Raw token cost" : "Processed tokens"} |
| 567 | </Text> |
| 568 | <Text className="text-4xl font-t3-bold tabular-nums text-foreground"> |
| 569 | {metric === "cost" ? `${formatUsd(merged.costUsd)}*` : formatTokens(merged.totalTokens)} |
| 570 | </Text> |
| 571 | <Text className="text-sm text-foreground-muted"> |
| 572 | {metric === "cost" |
| 573 | ? "* if billed at full API rate" |
| 574 | : `Across ${formatCount(merged.sessions)} sessions`} |
| 575 | </Text> |
| 576 | </View> |
| 577 | |
| 578 | {hasActivity ? ( |
| 579 | <UsageDailyChart |
| 580 | days={props.days} |
| 581 | daily={props.daily} |
| 582 | metric={metric} |
| 583 | height={CHART_HEIGHT} |
| 584 | /> |
| 585 | ) : ( |
| 586 | <View style={{ height: CHART_HEIGHT }} className="items-center justify-center"> |
| 587 | <Text className="text-base text-foreground-muted">No activity in this window.</Text> |
| 588 | </View> |
| 589 | )} |
| 590 | |
| 591 | <View className="flex-row items-center justify-between"> |
| 592 | <Text className="text-xs text-foreground-tertiary"> |
| 593 | {props.isPast24Hours |
| 594 | ? formatHourShort(props.days[0] ?? "", props.timeZone) |
| 595 | : formatDayShort(props.sinceDay)} |
| 596 | </Text> |
| 597 | <View className="flex-row items-center gap-4"> |
| 598 | {merged.providers.map((provider) => ( |
| 599 | <View key={provider.provider} className="flex-row items-center gap-1.5"> |
| 600 | <View |
| 601 | className="size-2 rounded-full" |
| 602 | style={{ backgroundColor: colors[provider.provider] }} |
| 603 | /> |
| 604 | <Text className="text-xs text-foreground-muted"> |
| 605 | {PROVIDER_LABEL[provider.provider]} |
| 606 | </Text> |
| 607 | </View> |
| 608 | ))} |
| 609 | </View> |
| 610 | <Text className="text-xs text-foreground-tertiary"> |
| 611 | {props.isPast24Hours |
| 612 | ? formatHourShort(props.days[props.days.length - 1] ?? "", props.timeZone) |
| 613 | : formatDayShort(props.untilDay)} |
| 614 | </Text> |
| 615 | </View> |
| 616 | </View> |
| 617 | ); |
| 618 | } |
| 619 | |
| 620 | function ProviderSection(props: { |
| 621 | readonly merged: MergedUsage; |
| 622 | readonly metric: UsageChartMetric; |
| 623 | readonly cursorAccessEnvironments: readonly EnvironmentUsageStatus[]; |
| 624 | readonly showCursorEnvironment: boolean; |
| 625 | readonly onCursorEnabled: () => void; |
| 626 | }) { |
| 627 | const { merged, metric } = props; |
| 628 | const colors = useProviderColors(); |
| 629 | if (merged.providers.length === 0 && props.cursorAccessEnvironments.length === 0) return null; |
| 630 | |
| 631 | // Ranked by whatever the toggle is showing, so the rows always descend. |
| 632 | // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 method. |
| 633 | const ordered = [...merged.providers].sort((a, b) => |
| 634 | metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens, |
| 635 | ); |
| 636 | const rows: Array< |
| 637 | | { readonly kind: "usage"; readonly provider: (typeof ordered)[number] } |
| 638 | | { readonly kind: "enable"; readonly environment: EnvironmentUsageStatus } |
| 639 | > = ordered.map((provider) => ({ kind: "usage", provider })); |
| 640 | const cursorInsertAt = |
| 641 | Math.max( |
| 642 | ordered.findIndex((provider) => provider.provider === "codex"), |
| 643 | ordered.findIndex((provider) => provider.provider === "claude"), |
| 644 | ) + 1; |
| 645 | rows.splice( |
| 646 | cursorInsertAt, |
| 647 | 0, |
| 648 | ...props.cursorAccessEnvironments.map((environment) => ({ |
| 649 | kind: "enable" as const, |
| 650 | environment, |
| 651 | })), |
| 652 | ); |
| 653 | |
| 654 | return ( |
| 655 | <SettingsSection title="Providers"> |
| 656 | {rows.map((row, index) => { |
| 657 | if (row.kind === "enable") { |
| 658 | return ( |
| 659 | <CursorEnableRow |
| 660 | key={`enable:${row.environment.environmentId}`} |
| 661 | environmentId={row.environment.environmentId} |
| 662 | label={row.environment.label} |
| 663 | showEnvironment={props.showCursorEnvironment} |
| 664 | bordered={index > 0} |
| 665 | onEnabled={props.onCursorEnabled} |
| 666 | /> |
| 667 | ); |
| 668 | } |
| 669 | const provider = row.provider; |
| 670 | const share = metric === "cost" ? provider.costShare : provider.tokenShare; |
| 671 | return ( |
| 672 | <View |
| 673 | key={provider.provider} |
| 674 | className={index === 0 ? "gap-2 p-4" : "gap-2 border-t border-border-subtle p-4"} |
| 675 | > |
| 676 | <View className="flex-row items-baseline justify-between gap-3"> |
| 677 | <View className="flex-row items-center gap-2"> |
| 678 | <View |
| 679 | className="size-2.5 rounded-full" |
| 680 | style={{ backgroundColor: colors[provider.provider] }} |
| 681 | /> |
| 682 | <Text className="text-lg text-foreground">{PROVIDER_LABEL[provider.provider]}</Text> |
| 683 | </View> |
| 684 | <Text className="text-lg tabular-nums text-foreground"> |
| 685 | {metric === "cost" |
| 686 | ? formatUsd(provider.costUsd) |
| 687 | : formatTokens(provider.totalTokens)} |
| 688 | </Text> |
| 689 | </View> |
| 690 | <View className="h-1 flex-row overflow-hidden rounded-full bg-subtle"> |
| 691 | <View |
| 692 | className="h-full rounded-full" |
| 693 | style={{ flex: share, backgroundColor: colors[provider.provider] }} |
| 694 | /> |
| 695 | <View style={{ flex: 1 - share }} /> |
| 696 | </View> |
| 697 | <Text className="text-sm text-foreground-muted"> |
| 698 | {metric === "cost" |
| 699 | ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` |
| 700 | : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} |
| 701 | </Text> |
| 702 | </View> |
| 703 | ); |
| 704 | })} |
| 705 | </SettingsSection> |
| 706 | ); |
| 707 | } |
| 708 | |
| 709 | function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24Hours: boolean }) { |
| 710 | const { merged } = props; |
| 711 | const activePeriods = (props.isPast24Hours ? merged.hourly : merged.daily).filter( |
| 712 | (period) => period.totalTokens > 0, |
| 713 | ).length; |
| 714 | const periodAverage = activePeriods === 0 ? 0 : merged.totalTokens / activePeriods; |
| 715 | const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens; |
| 716 | const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput; |
| 717 | |
| 718 | return ( |
| 719 | <SettingsSection title="Totals"> |
| 720 | <View className="flex-row flex-wrap"> |
| 721 | <MetricCell |
| 722 | label="Processed tokens" |
| 723 | value={formatTokens(merged.totalTokens)} |
| 724 | detail={`${formatTokens(periodAverage)} per active ${props.isPast24Hours ? "hour" : "day"}`} |
| 725 | /> |
| 726 | <MetricCell |
| 727 | label="Cache savings" |
| 728 | value={formatUsd(merged.costQuality.cacheSavingsUsd)} |
| 729 | detail={ |
| 730 | merged.costUsd > 0 |
| 731 | ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw cost` |
| 732 | : "vs full input rates" |
| 733 | } |
| 734 | /> |
| 735 | <MetricCell |
| 736 | label="Cached input" |
| 737 | value={formatTokens(merged.cachedInputTokens)} |
| 738 | detail={`${formatPercent(cachedShare)} of observed input`} |
| 739 | /> |
| 740 | <MetricCell |
| 741 | label="Uncached input" |
| 742 | value={formatTokens(merged.uncachedInputTokens)} |
| 743 | detail={`${formatTokens(merged.cacheCreationTokens)} cache writes`} |
| 744 | /> |
| 745 | <MetricCell |
| 746 | label="Output" |
| 747 | value={formatTokens(merged.outputTokens)} |
| 748 | detail={`incl. ${formatTokens(merged.reasoningTokens)} reasoning`} |
| 749 | /> |
| 750 | <MetricCell |
| 751 | label="Unpriced" |
| 752 | value={formatPercent(merged.costQuality.unpricedShare)} |
| 753 | detail="of records, excluded from cost" |
| 754 | /> |
| 755 | </View> |
| 756 | </SettingsSection> |
| 757 | ); |
| 758 | } |
| 759 | |
| 760 | function CostSection(props: { readonly merged: MergedUsage }) { |
| 761 | const { categoryCost, speedCost } = props.merged; |
| 762 | const colors = useUsageMixColors(); |
| 763 | const byType = [ |
| 764 | { label: "Input", value: categoryCost.input, color: colors.input }, |
| 765 | { label: "Cache read", value: categoryCost.cacheRead, color: colors.cacheRead }, |
| 766 | { label: "Cache write", value: categoryCost.cacheWrite, color: colors.cacheWrite }, |
| 767 | { label: "Output", value: categoryCost.output, color: colors.output }, |
| 768 | // Reported cost with no rates to split it, or from older servers. Below a |
| 769 | // cent it is rounding, not usage. |
| 770 | { |
| 771 | label: "Other", |
| 772 | value: categoryCost.unsplit >= 0.005 ? categoryCost.unsplit : 0, |
| 773 | color: colors.other, |
| 774 | }, |
| 775 | ]; |
| 776 | const bySpeed = [ |
| 777 | { label: "Standard", value: speedCost.standard, color: colors.standard }, |
| 778 | { label: "Fast", value: speedCost.fast, color: colors.fast }, |
| 779 | { label: "Ultrafast", value: speedCost.ultrafast, color: colors.ultrafast }, |
| 780 | ]; |
| 781 | if (props.merged.costUsd <= 0) return null; |
| 782 | |
| 783 | return ( |
| 784 | <SettingsSection title="Cost"> |
| 785 | <ShareBar label="By type" segments={byType} /> |
| 786 | {speedCost.fast + speedCost.ultrafast > 0 ? ( |
| 787 | <View className="border-t border-border-subtle"> |
| 788 | <ShareBar |
| 789 | label="By speed" |
| 790 | segments={bySpeed} |
| 791 | aside={`${formatUsd(speedCost.premium)} premium`} |
| 792 | /> |
| 793 | </View> |
| 794 | ) : null} |
| 795 | </SettingsSection> |
| 796 | ); |
| 797 | } |
| 798 | |
| 799 | /** One part-to-whole cost bar with its legend. Empty segments are left out. */ |
| 800 | function ShareBar(props: { |
| 801 | readonly label: string; |
| 802 | readonly segments: readonly { label: string; value: number; color: string }[]; |
| 803 | readonly aside?: string; |
| 804 | }) { |
| 805 | const visible = props.segments.filter((segment) => segment.value > 0); |
| 806 | if (visible.length === 0) return null; |
| 807 | |
| 808 | return ( |
| 809 | <View className="gap-3 p-4"> |
| 810 | <View className="flex-row items-baseline justify-between gap-3"> |
| 811 | <Text className="text-sm text-foreground-muted">{props.label}</Text> |
| 812 | {props.aside ? ( |
| 813 | <Text className="text-sm tabular-nums text-foreground-muted">{props.aside}</Text> |
| 814 | ) : null} |
| 815 | </View> |
| 816 | <View className="h-2 flex-row gap-0.5"> |
| 817 | {visible.map((segment) => ( |
| 818 | <View |
| 819 | key={segment.label} |
| 820 | className="h-full rounded-sm" |
| 821 | style={{ flex: segment.value, backgroundColor: segment.color }} |
| 822 | /> |
| 823 | ))} |
| 824 | </View> |
| 825 | <View className="flex-row flex-wrap gap-x-4 gap-y-1.5"> |
| 826 | {visible.map((segment) => ( |
| 827 | <View key={segment.label} className="flex-row items-center gap-1.5"> |
| 828 | <View className="size-2 rounded-sm" style={{ backgroundColor: segment.color }} /> |
| 829 | <Text className="text-sm text-foreground-muted">{segment.label}</Text> |
| 830 | <Text className="text-sm tabular-nums text-foreground">{formatUsd(segment.value)}</Text> |
| 831 | </View> |
| 832 | ))} |
| 833 | </View> |
| 834 | </View> |
| 835 | ); |
| 836 | } |
| 837 | |
| 838 | function MetricCell(props: { |
| 839 | readonly label: string; |
| 840 | readonly value: string; |
| 841 | readonly detail: string; |
| 842 | }) { |
| 843 | return ( |
| 844 | <View className="w-1/2 gap-0.5 p-4"> |
| 845 | <Text className="text-sm text-foreground-muted">{props.label}</Text> |
| 846 | <Text className="text-xl font-t3-medium tabular-nums text-foreground">{props.value}</Text> |
| 847 | <Text className="text-xs text-foreground-tertiary">{props.detail}</Text> |
| 848 | </View> |
| 849 | ); |
| 850 | } |
| 851 | |
| 852 | function ModelsSection(props: { readonly merged: MergedUsage; readonly metric: UsageChartMetric }) { |
| 853 | const { merged, metric } = props; |
| 854 | const colors = useProviderColors(); |
| 855 | if (merged.models.length === 0) return null; |
| 856 | |
| 857 | // Ranked like the provider rows. .sort() on a copy, not .toSorted(): Hermes |
| 858 | // doesn't ship the ES2023 method. |
| 859 | const ordered = [...merged.models].sort((a, b) => |
| 860 | metric === "cost" |
| 861 | ? b.costUsd - a.costUsd || b.totalTokens - a.totalTokens |
| 862 | : b.totalTokens - a.totalTokens || b.costUsd - a.costUsd, |
| 863 | ); |
| 864 | |
| 865 | return ( |
| 866 | <SettingsSection title="By model"> |
| 867 | {ordered.map((model, index) => ( |
| 868 | <View |
| 869 | key={`${model.provider}:${model.model}`} |
| 870 | className={ |
| 871 | index === 0 |
| 872 | ? "flex-row items-center gap-3 p-4" |
| 873 | : "flex-row items-center gap-3 border-t border-border-subtle p-4" |
| 874 | } |
| 875 | > |
| 876 | <View |
| 877 | className="size-2.5 shrink-0 rounded-full" |
| 878 | style={{ backgroundColor: colors[model.provider] }} |
| 879 | /> |
| 880 | <View className="min-w-0 flex-1 gap-0.5"> |
| 881 | <Text className="text-base text-foreground" numberOfLines={1}> |
| 882 | {model.model} |
| 883 | </Text> |
| 884 | <Text className="text-sm text-foreground-muted"> |
| 885 | {metric === "tokens" |
| 886 | ? `${formatPercent(model.tokenShare)} of tokens · ${ |
| 887 | isModelCostUnknown(model) ? "no known rates" : formatUsd(model.costUsd) |
| 888 | }` |
| 889 | : isModelCostUnknown(model) |
| 890 | ? `no known rates · ${formatTokens(model.totalTokens)} tokens` |
| 891 | : `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`} |
| 892 | </Text> |
| 893 | </View> |
| 894 | <Text className="text-base tabular-nums text-foreground"> |
| 895 | {metric === "tokens" |
| 896 | ? formatTokens(model.totalTokens) |
| 897 | : isModelCostUnknown(model) |
| 898 | ? "Unpriced" |
| 899 | : formatUsd(model.costUsd)} |
| 900 | </Text> |
| 901 | </View> |
| 902 | ))} |
| 903 | </SettingsSection> |
| 904 | ); |
| 905 | } |
| 906 | |
| 907 | /** |
| 908 | * Says plainly when the totals are incomplete: an environment still answering, |
| 909 | * one that failed, or one whose transcripts another environment already |
| 910 | * reported. |
| 911 | */ |
| 912 | function usageEnvironmentStatus(environment: EnvironmentUsageStatus, refreshing: boolean): string { |
| 913 | if ( |
| 914 | environment.summary && |
| 915 | !isCompatibleUsageContractVersion(environment.summary.contractVersion, USAGE_CONTRACT_VERSION) |
| 916 | ) { |
| 917 | return formatUsageContractMismatch(environment.label, { |
| 918 | direction: |
| 919 | environment.summary.contractVersion < USAGE_CONTRACT_VERSION |
| 920 | ? "serverBehind" |
| 921 | : "clientBehind", |
| 922 | }); |
| 923 | } |
| 924 | // The reason matters: a denied grant and a failed scan need different fixes. |
| 925 | if (environment.error) |
| 926 | return environment.summary ? `${environment.error} Showing saved totals.` : environment.error; |
| 927 | if (!environment.isConnected) |
| 928 | return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…"; |
| 929 | const progress = usageEnvironmentProgress(environment, refreshing); |
| 930 | if (progress.phase === "loading") return "Loading usage…"; |
| 931 | if (progress.phase === "stale") return "Updating usage…"; |
| 932 | if (progress.phase === "partway") |
| 933 | return updatingProvidersLabel(progress.providers, providerLabel); |
| 934 | return "Usage up to date"; |
| 935 | } |