feat: live multi-provider quota panel (#1278)

* feat: live provider quota panel (Claude + Codex)

Adds Claude and Codex plan usage to the context window percentage circle tooltip, querying their respective APIs directly using existing CLI auth tokens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address review feedback - restore quota trigger, guard NaN date

- Re-add triggerFetch() on agent idle/error in agent status subscription
- Guard formatResetsAtLabel against NaN from malformed API date strings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: resolve composer conflict and stub subscribe in websocket tests

* feat(quota): add cursor, copilot, zai, grok, and kimi quota UI and improve type safety

* feat(quota): make Claude and Codex pluggable, and map additional quota/credits metrics

* security(quota): fix shell injection vulnerability by migrating exec to execFile

* revert: restore original scripts/dev.ps1 and packages/desktop/scripts/dev.ps1

* Fix i18n resource test expectation

* Fix quota tooltip empty-provider state

* Preserve zero values in Grok quota

* Fix empty quota fetch state

* Ignore quota frames in relay reconnect tests

* Persist refreshed Codex tokens to source auth file

* fix(quota): read Claude OAuth credentials from the macOS login Keychain

On macOS, Claude Code stores its OAuth credential in the login Keychain
(generic-password item, service "Claude Code-credentials"), not in
~/.claude/.credentials.json — that file usually does not exist there, so the
Claude provider's existsSync check failed and macOS users silently got no
Claude quota. Read it via the macOS `security` CLI (its ACL only trusts
/usr/bin/security to decrypt; a native Keychain read would prompt), and stay
read-only there since Claude Code owns the refresh/persist. Linux and Windows
keep using ~/.claude/.credentials.json unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix quota keychain timeout and local host reconciliation

* Fix terminal notification test quota stub

* Ignore quota frames in terminal notification tests

* fix(quota): bypass Claude token refresh on macOS Keychain-backed logins

* fix(dev): update local dev daemon port to 6768 on Windows scripts/dev.ps1

* fix(dev): bypass Unix dev-daemon.sh on Windows in dev.ps1

* fix(client): handle and dispatch provider_quota event in DaemonClient

* fix(desktop): fix PowerShell quote stripping in desktop dev.ps1 config seeding

* fix(desktop): execute config update via temp file to avoid PowerShell quoting bugs

* fix(desktop): fix concurrently command invocation on Windows in dev.ps1

* fix(desktop): resolve path escaping and environment setting syntax errors in dev.ps1 for Windows

* Add on-demand provider usage views

* Fix Cursor usage billing dates

* Move provider usage renderer coverage to e2e

* Use provider manifest for quota fetchers

* Add provider usage fetch timeouts

* Reshape provider usage fetchers

---------

Co-authored-by: ABorakati <ABorakati@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Co-authored-by: lumingjun <lumingjun@bytedance.com>
This commit is contained in:
Aditya Borakati
2026-06-20 12:19:50 +08:00
committed by GitHub
co-authored by ABorakati Claude Sonnet 4.6 Mohamed Boudra lumingjun
parent 2b0740ff84
commit acea7f3d24
59 changed files with 3944 additions and 132 deletions
@@ -0,0 +1,118 @@
import { useMemo } from "react";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { clampPct, formatAmount, formatResetLabel } from "./format";
import type { ProviderUsageBalance, ProviderUsageTone } from "./types";
interface ResolvedBalance {
amountText: string;
usedPct: number | null;
}
function resolveBalance(balance: ProviderUsageBalance): ResolvedBalance {
const { used, remaining, limit, unit } = balance;
if (limit != null && limit > 0) {
const usedAmount = used ?? (remaining != null ? limit - remaining : null);
const usedPct = usedAmount != null ? (usedAmount / limit) * 100 : null;
const usedText = usedAmount != null ? formatAmount(usedAmount, unit) : "—";
return { amountText: `${usedText} / ${formatAmount(limit, unit)}`, usedPct };
}
if (remaining != null) {
return { amountText: `${formatAmount(remaining, unit)} left`, usedPct: null };
}
if (used != null) {
return { amountText: formatAmount(used, unit), usedPct: null };
}
return { amountText: "—", usedPct: null };
}
function fillToneStyle(tone: ProviderUsageTone) {
switch (tone) {
case "ok":
return styles.fillOk;
case "warning":
return styles.fillWarning;
case "danger":
return styles.fillDanger;
default:
return styles.fillDefault;
}
}
export function ProviderUsageBalanceBar({ balance }: { balance: ProviderUsageBalance }) {
const { amountText, usedPct } = resolveBalance(balance);
const tone = balance.tone ?? "default";
const resetLabel = formatResetLabel(balance.resetsAt);
const fillStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }],
[usedPct, tone],
);
return (
<View style={styles.container}>
<View style={styles.labelRow}>
<Text style={styles.label} numberOfLines={1}>
{balance.label}
</Text>
<Text style={styles.value}>
{amountText}
{resetLabel ? <Text style={styles.reset}>{` · ${resetLabel}`}</Text> : null}
</Text>
</View>
{usedPct != null ? (
<View style={styles.track}>
<View style={fillStyle} />
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
gap: 3,
},
labelRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
gap: theme.spacing[2],
},
label: {
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
value: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
},
reset: {
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
},
track: {
height: 4,
borderRadius: 2,
backgroundColor: theme.colors.surface3,
overflow: "hidden",
},
fill: {
height: 4,
borderRadius: 2,
},
fillDefault: {
backgroundColor: theme.colors.foregroundMuted,
},
fillOk: {
backgroundColor: theme.colors.statusSuccess,
},
fillWarning: {
backgroundColor: theme.colors.statusWarning,
},
fillDanger: {
backgroundColor: theme.colors.statusDanger,
},
}));
+201
View File
@@ -0,0 +1,201 @@
import { useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { getProviderIcon } from "@/components/provider-icons";
import { StatusBadge } from "@/components/ui/status-badge";
import type { Theme } from "@/styles/theme";
import { ProviderUsageBalanceBar } from "./balance-bar";
import { formatAgo } from "./format";
import type { ProviderUsage } from "./types";
import { ProviderUsageWindowBar } from "./window-bar";
interface ProviderUsageIconProps {
iconKey: string;
size: number;
color?: string;
}
function ProviderUsageIcon({ iconKey, size, color = "" }: ProviderUsageIconProps) {
const Icon = getProviderIcon(iconKey);
return <Icon size={size} color={color} />;
}
const ThemedProviderUsageIcon = withUnistyles(ProviderUsageIcon);
const foregroundIconColor = (theme: Theme) => ({ color: theme.colors.foreground });
function statusText(usage: ProviderUsage): string | null {
if (usage.status === "available") return null;
return usage.status === "error" ? "Error" : "Unavailable";
}
function footerText(usage: ProviderUsage): string | null {
const updated = formatAgo(usage.fetchedAt);
const parts = [usage.sourceLabel, updated ? `Updated ${updated}` : null].filter(
(part): part is string => typeof part === "string" && part.length > 0,
);
return parts.length > 0 ? parts.join(" · ") : null;
}
export function ProviderUsageCard({
usage,
compact = false,
}: {
usage: ProviderUsage;
compact?: boolean;
}) {
const status = statusText(usage);
const footer = footerText(usage);
const balances = usage.balances ?? [];
const details = usage.details ?? [];
const containerStyle = useMemo(
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
[compact],
);
const dotStyle = useMemo(
() => [
styles.statusDot,
usage.status === "available" && styles.statusDotAvailable,
usage.status === "error" && styles.statusDotError,
],
[usage.status],
);
return (
<View style={containerStyle}>
<View style={styles.header}>
<ThemedProviderUsageIcon
iconKey={usage.providerId}
size={16}
uniProps={foregroundIconColor}
/>
<Text style={styles.name} numberOfLines={1}>
{usage.displayName}
</Text>
{usage.planLabel ? <StatusBadge label={usage.planLabel} variant="muted" /> : null}
<View style={styles.headerSpacer} />
{status ? (
<View style={styles.statusRow}>
<View style={dotStyle} />
<Text style={styles.statusLabel}>{status}</Text>
</View>
) : null}
</View>
{usage.error ? (
<Text style={styles.error} numberOfLines={3}>
{usage.error}
</Text>
) : null}
{usage.windows.length > 0 || balances.length > 0 ? (
<View style={styles.bars}>
{usage.windows.map((window) => (
<ProviderUsageWindowBar key={window.id} window={window} />
))}
{balances.map((balance) => (
<ProviderUsageBalanceBar key={balance.id} balance={balance} />
))}
</View>
) : null}
{details.length > 0 ? (
<View style={styles.details}>
{details.map((detail) => (
<View key={detail.id} style={styles.detailRow}>
<Text style={styles.detailLabel} numberOfLines={1}>
{detail.label}
</Text>
<Text style={styles.detailValue} numberOfLines={1}>
{detail.value}
</Text>
</View>
))}
</View>
) : null}
{footer ? (
<Text style={styles.footer} numberOfLines={1}>
{footer}
</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
gap: theme.spacing[3],
},
containerPadded: {
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
},
containerCompact: {
gap: theme.spacing[2],
},
header: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
name: {
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
headerSpacer: {
flex: 1,
},
statusRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: theme.colors.foregroundMuted,
},
statusDotAvailable: {
backgroundColor: theme.colors.statusSuccess,
},
statusDotError: {
backgroundColor: theme.colors.statusDanger,
},
statusLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
bars: {
gap: theme.spacing[2],
},
details: {
gap: theme.spacing[1],
},
detailRow: {
flexDirection: "row",
justifyContent: "space-between",
gap: theme.spacing[2],
},
detailLabel: {
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
detailValue: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
},
error: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
lineHeight: theme.fontSize.xs * 1.4,
},
footer: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
}));
+18
View File
@@ -0,0 +1,18 @@
// User-facing copy for the provider-usage surfaces, centralized so localization
// is a single-file change. INTEGRATION: move these into the i18n resources
// (a `providerUsage` namespace across all locales) and swap to `t(...)` at the
// call sites once the feature is wired to data. Kept inline for now because the
// surfaces are not yet mounted and the locale files are being edited elsewhere.
export const providerUsageCopy = {
title: "Plan usage",
refresh: "Refresh",
refreshing: "Refreshing...",
loading: "Loading usage...",
empty: "No usage data",
errorTitle: "Unable to load usage",
hostUnavailable: "Connect to this host to see provider usage",
hostUpgradeRequired: "Update the host to see provider usage",
clientUnavailable: "Host connection is not ready",
retry: "Try again",
tooltipLoading: "Loading plan usage…",
} as const;
+53
View File
@@ -0,0 +1,53 @@
import { formatTokenCount } from "@/components/context-window-meter.utils";
import type { ProviderUsageBalanceUnit } from "./types";
export function clampPct(value: number): number {
return Math.max(0, Math.min(100, value));
}
export function formatPct(value: number): string {
return `${Math.round(clampPct(value))}%`;
}
function relativeDuration(iso: string): string | null {
const diffMs = new Date(iso).getTime() - Date.now();
if (!Number.isFinite(diffMs)) return null;
if (diffMs <= 0) return "now";
const diffMinutes = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return `${diffDays}d`;
if (diffHours > 0) return `${diffHours}h`;
return `${diffMinutes}m`;
}
export function formatResetLabel(iso: string | null | undefined): string | null {
if (!iso) return null;
const rel = relativeDuration(iso);
if (!rel) return null;
return rel === "now" ? "resetting now" : `resets ${rel}`;
}
export function formatAgo(iso: string | null | undefined): string | null {
if (!iso) return null;
const diffMs = Date.now() - new Date(iso).getTime();
if (!Number.isFinite(diffMs)) return null;
if (diffMs < 60_000) return "just now";
const diffMinutes = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return `${diffDays}d ago`;
if (diffHours > 0) return `${diffHours}h ago`;
return `${diffMinutes}m ago`;
}
export function formatAmount(value: number, unit: ProviderUsageBalanceUnit): string {
switch (unit) {
case "usd":
return `$${value.toFixed(2)}`;
case "tokens":
return formatTokenCount(value);
default:
return value.toLocaleString();
}
}
+26
View File
@@ -0,0 +1,26 @@
import { Fragment } from "react";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
import { ProviderUsageCard } from "./card";
import type { ProviderUsage } from "./types";
export function ProviderUsageList({ providers }: { providers: ProviderUsage[] }) {
return (
<View style={settingsStyles.card}>
{providers.map((usage, index) => (
<Fragment key={usage.providerId}>
{index > 0 ? <View style={styles.divider} /> : null}
<ProviderUsageCard usage={usage} />
</Fragment>
))}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
divider: {
height: 1,
backgroundColor: theme.colors.border,
},
}));
@@ -0,0 +1,96 @@
import { RefreshCw } from "lucide-react-native";
import { useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section";
import { providerUsageCopy } from "./copy";
import { ProviderUsageList } from "./list";
import type { ProviderUsageView } from "./types";
export function ProviderUsageSettingsSection({
view,
onRefresh,
}: {
view: ProviderUsageView;
onRefresh: () => void;
}) {
const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing);
const refreshButton = useMemo(
() => (
<Button
variant="ghost"
size="sm"
leftIcon={RefreshCw}
loading={busy}
onPress={onRefresh}
accessibilityLabel={providerUsageCopy.refresh}
>
{busy ? providerUsageCopy.refreshing : providerUsageCopy.refresh}
</Button>
),
[busy, onRefresh],
);
return (
<SettingsSection
title={providerUsageCopy.title}
testID="provider-usage-card"
trailing={refreshButton}
>
<ProviderUsageBody view={view} onRefresh={onRefresh} />
</SettingsSection>
);
}
function ProviderUsageBody({
view,
onRefresh,
}: {
view: ProviderUsageView;
onRefresh: () => void;
}) {
if (view.kind === "loading") {
return (
<View style={EMPTY_CARD_STYLE}>
<Text style={styles.emptyText}>{providerUsageCopy.loading}</Text>
</View>
);
}
if (view.kind === "error") {
return (
<Alert variant="error" title={providerUsageCopy.errorTitle} description={view.message}>
<Button variant="outline" size="sm" onPress={onRefresh}>
{providerUsageCopy.retry}
</Button>
</Alert>
);
}
if (view.payload.providers.length === 0) {
return (
<View style={EMPTY_CARD_STYLE}>
<Text style={styles.emptyText}>{providerUsageCopy.empty}</Text>
</View>
);
}
return <ProviderUsageList providers={view.payload.providers} />;
}
const styles = StyleSheet.create((theme) => ({
emptyCard: {
padding: theme.spacing[4],
alignItems: "center",
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));
const EMPTY_CARD_STYLE = [settingsStyles.card, styles.emptyCard];
+8
View File
@@ -0,0 +1,8 @@
import type { ProviderUsageTone } from "./types";
export function deriveTone(usedPct: number | null | undefined): ProviderUsageTone {
if (usedPct == null) return "default";
if (usedPct > 90) return "danger";
if (usedPct >= 70) return "warning";
return "default";
}
@@ -0,0 +1,71 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ProviderUsageCard } from "./card";
import { providerUsageCopy } from "./copy";
import type { ProviderUsage, ProviderUsageView } from "./types";
function matchProvider(
providers: ProviderUsage[],
activeProviderId: string | null | undefined,
): ProviderUsage | null {
if (!activeProviderId) return null;
const target = activeProviderId.toLowerCase();
return providers.find((usage) => usage.providerId.toLowerCase() === target) ?? null;
}
// Renders the active agent's provider usage inside the context-meter tooltip.
// Returns nothing when the active provider has no usage entry, so the meter's
// own context section stays the whole tooltip.
export function ProviderUsageTooltipSection({
view,
activeProviderId,
}: {
view: ProviderUsageView;
activeProviderId: string | null | undefined;
}) {
if (view.kind === "loading") {
return (
<>
<View style={styles.divider} />
<Text style={styles.detail}>{providerUsageCopy.tooltipLoading}</Text>
</>
);
}
if (view.kind === "error") {
return (
<>
<View style={styles.divider} />
<Text style={styles.error}>{view.message}</Text>
</>
);
}
const usage = matchProvider(view.payload.providers, activeProviderId);
if (!usage) return null;
return (
<>
<View style={styles.divider} />
<ProviderUsageCard usage={usage} compact />
</>
);
}
const styles = StyleSheet.create((theme) => ({
divider: {
height: StyleSheet.hairlineWidth,
backgroundColor: theme.colors.border,
marginVertical: theme.spacing[1] + 1,
},
detail: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: theme.fontSize.xs * 1.4,
},
error: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
lineHeight: theme.fontSize.xs * 1.4,
},
}));
+26
View File
@@ -0,0 +1,26 @@
import type {
ProviderUsage,
ProviderUsageBalance,
ProviderUsageDetail,
ProviderUsageListResponseMessage,
ProviderUsageStatus,
ProviderUsageTone,
ProviderUsageWindow,
} from "@getpaseo/protocol/messages";
export type {
ProviderUsage,
ProviderUsageBalance,
ProviderUsageDetail,
ProviderUsageStatus,
ProviderUsageTone,
ProviderUsageWindow,
};
export type ProviderUsageBalanceUnit = ProviderUsageBalance["unit"];
export type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"];
export type ProviderUsageView =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; payload: ProviderUsageListPayload; isRefreshing: boolean };
@@ -0,0 +1,105 @@
import { useCallback, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { providerUsageCopy } from "./copy";
import type { ProviderUsageListPayload, ProviderUsageView } from "./types";
export const PROVIDER_USAGE_STALE_TIME_MS = 5 * 60 * 1000;
type ProviderUsageClient = Pick<DaemonClient, "listProviderUsage">;
export function providerUsageQueryKey(serverId: string | null | undefined) {
return ["providerUsage", serverId ?? ""] as const;
}
async function fetchProviderUsage(client: ProviderUsageClient): Promise<ProviderUsageListPayload> {
return client.listProviderUsage();
}
interface UseProviderUsageOptions {
enabled?: boolean;
}
export function useProviderUsage(
serverId: string | null | undefined,
options: UseProviderUsageOptions = {},
): {
view: ProviderUsageView;
refresh: () => Promise<void>;
canFetch: boolean;
} {
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const supportsProviderUsage = useSessionStore(
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providerUsageList === true,
);
const queryKey = useMemo(() => providerUsageQueryKey(serverId), [serverId]);
const canFetch = Boolean(serverId && client && isConnected && supportsProviderUsage);
const enabled = Boolean((options.enabled ?? true) && canFetch);
const queryFn = useCallback(async () => {
if (!client) {
throw new Error(providerUsageCopy.clientUnavailable);
}
return fetchProviderUsage(client);
}, [client]);
const query = useQuery({
queryKey,
queryFn,
enabled,
staleTime: PROVIDER_USAGE_STALE_TIME_MS,
refetchOnMount: true,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
const refresh = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey });
if (!canFetch) {
return;
}
await queryClient.fetchQuery({
queryKey,
queryFn,
staleTime: PROVIDER_USAGE_STALE_TIME_MS,
});
}, [canFetch, queryClient, queryFn, queryKey]);
const view = useMemo<ProviderUsageView>(() => {
if (!serverId || !client || !isConnected) {
return { kind: "error", message: providerUsageCopy.hostUnavailable };
}
if (!supportsProviderUsage) {
return { kind: "error", message: providerUsageCopy.hostUpgradeRequired };
}
if (query.data) {
return {
kind: "ready",
payload: query.data,
isRefreshing: query.isFetching,
};
}
if (query.isError) {
return {
kind: "error",
message: query.error instanceof Error ? query.error.message : String(query.error),
};
}
return { kind: "loading" };
}, [
client,
isConnected,
query.data,
query.error,
query.isError,
query.isFetching,
serverId,
supportsProviderUsage,
]);
return { view, refresh, canFetch };
}
@@ -0,0 +1,112 @@
import { useMemo } from "react";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { clampPct, formatPct, formatResetLabel } from "./format";
import { deriveTone } from "./tone";
import type { ProviderUsageTone, ProviderUsageWindow } from "./types";
function resolveUsedPct(window: ProviderUsageWindow): number | null {
if (window.usedPct != null) return window.usedPct;
if (window.remainingPct != null) return 100 - window.remainingPct;
return null;
}
function fillToneStyle(tone: ProviderUsageTone) {
switch (tone) {
case "ok":
return styles.fillOk;
case "warning":
return styles.fillWarning;
case "danger":
return styles.fillDanger;
default:
return styles.fillDefault;
}
}
export function ProviderUsageWindowBar({ window }: { window: ProviderUsageWindow }) {
const usedPct = resolveUsedPct(window);
const tone = window.tone ?? deriveTone(usedPct);
const fillWidth = clampPct(usedPct ?? 0);
const fillStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.fill, fillToneStyle(tone), { width: `${fillWidth}%` }],
[fillWidth, tone],
);
const isAtRisk = window.runsOutAt != null && window.shortfallPct != null;
const trailing = isAtRisk
? `runs out ${formatResetLabel(window.runsOutAt)?.replace("resets ", "") ?? ""}`.trim()
: formatResetLabel(window.resetsAt);
return (
<View style={styles.container}>
<View style={styles.labelRow}>
<Text style={styles.label} numberOfLines={1}>
{window.label}
</Text>
<Text style={styles.value}>
{usedPct != null ? formatPct(usedPct) : "—"}
{trailing ? (
<Text style={isAtRisk ? styles.atRisk : styles.reset}>{` · ${trailing}`}</Text>
) : null}
</Text>
</View>
<View style={styles.track}>
<View style={fillStyle} />
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
gap: 3,
},
labelRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
gap: theme.spacing[2],
},
label: {
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
value: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
},
reset: {
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
},
atRisk: {
color: theme.colors.statusDanger,
fontWeight: theme.fontWeight.normal,
},
track: {
height: 4,
borderRadius: 2,
backgroundColor: theme.colors.surface3,
overflow: "hidden",
},
fill: {
height: 4,
borderRadius: 2,
},
fillDefault: {
backgroundColor: theme.colors.foregroundMuted,
},
fillOk: {
backgroundColor: theme.colors.statusSuccess,
},
fillWarning: {
backgroundColor: theme.colors.statusWarning,
},
fillDanger: {
backgroundColor: theme.colors.statusDanger,
},
}));