* 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>
119 lines
3.2 KiB
TypeScript
119 lines
3.2 KiB
TypeScript
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,
|
|
},
|
|
}));
|