1import { ChatGptUsageSummary } from "./ChatGptUsageSummary";
2import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
3import { EnvironmentId, USAGE_CONTRACT_VERSION, type UsageProviderKind } from "@t3tools/contracts";
4import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native";
5import { cursorKeychainAccessEnvironments } from "@t3tools/client-runtime/state/usage";
6import {
7 updatingProvidersLabel,
8 usageEnvironmentProgress,
9 usageProgress,
10} from "@t3tools/client-runtime/state/usage-progress";
11import {
12 isCompatibleUsageContractVersion,
13 isModelCostUnknown,
14 type DailyTotals,
15 type MergedUsage,
16} from "@t3tools/shared/usageMerge";
17import {
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";
29import { useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
30import { ActivityIndicator, Platform, Pressable, RefreshControl, View } from "react-native";
31import Animated, {
32 FadeIn,
33 ReduceMotion,
34 useAnimatedStyle,
35 useSharedValue,
36 withDelay,
37 withTiming,
38} from "react-native-reanimated";
39import { useSafeAreaInsets } from "react-native-safe-area-context";
40
41import { SegmentedControl } from "../../components/SegmentedControl";
42import { AppText as Text } from "../../components/AppText";
43import { ProviderIcon } from "../../components/ProviderIcon";
44import { cn } from "../../lib/cn";
45import { SettingsScreen } from "../settings/components/SettingsScreen";
46import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
47import { serverEnvironment } from "../../state/server";
48import { useAtomCommand } from "../../state/use-atom-command";
49import { SettingsSection } from "../settings/components/SettingsSection";
50import { UsageDailyChart } from "./UsageDailyChart";
51import { toggleUsageEnvironment } from "./usageEnvironmentSelection";
52import { useRefreshLimits } from "./UsageLimitsSection";
53import { UsageLimitsSection } from "./UsageLimitsPooled";
54import { ControlPillMenu } from "../../components/ControlPill";
55import { SymbolView } from "../../components/AppSymbol";
56import type { UsageChartMetric } from "./usageChartData";
57import { PROVIDER_LABEL, useProviderColors, useUsageMixColors } from "./usageProviders";
58
59type UsageTab = "usage" | "limits";
60const 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.
67const 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
74const METRIC_OPTIONS = [
75 { value: "cost", label: "Cost" },
76 { value: "tokens", label: "Tokens" },
77] as const satisfies readonly { value: UsageChartMetric; label: string }[];
78
79const CHART_HEIGHT = 180;
80const providerLabel = (provider: UsageProviderKind) => PROVIDER_LABEL[provider];
81const 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 */
88export 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 */
401function 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
443function 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
484function 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
516function 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. */
548function 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
620function 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
709function 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
760function 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. */
800function 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
838function 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
852function 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 */
912function 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}