Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc7e497388 | |||
| 5a00822690 | |||
| 2c31be11c4 | |||
| 9dd17cf565 | |||
| 6992e58b9c |
@@ -1,35 +1,75 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "codexbar-mobile",
|
||||
"name": "Codexbar",
|
||||
"slug": "codexbar-mobile",
|
||||
"description": "Monitor Codex CLI and Claude.ai usage limits, resets, and credits at a glance.",
|
||||
"scheme": "codexbar",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "dark",
|
||||
"backgroundColor": "#090D11",
|
||||
"primaryColor": "#10A37F",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
"bundleIdentifier": "dev.reversed.codexbar",
|
||||
"buildNumber": "1",
|
||||
"supportsTablet": true,
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"package": "dev.reversed.codexbar",
|
||||
"versionCode": 1,
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"backgroundColor": "#090D11",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/android-icon-background.png",
|
||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||
},
|
||||
"predictiveBackGestureEnabled": false
|
||||
"predictiveBackGestureEnabled": true
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-status-bar",
|
||||
"expo-secure-store",
|
||||
"expo-document-picker",
|
||||
"expo-notifications"
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/splash-icon.png",
|
||||
"imageWidth": 220,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#090D11",
|
||||
"dark": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"backgroundColor": "#090D11"
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-secure-store",
|
||||
{
|
||||
"configureAndroidBackup": true,
|
||||
"faceIDPermission": "Allow Codexbar to access your securely stored credentials."
|
||||
}
|
||||
],
|
||||
"expo-document-picker",
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"icon": "./assets/notification-icon.png",
|
||||
"color": "#10A37F",
|
||||
"defaultChannel": "usage-alerts"
|
||||
}
|
||||
],
|
||||
"expo-font"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"extra": {
|
||||
"router": {},
|
||||
"eas": {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Tabs } from "expo-router";
|
||||
import { useColorScheme } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { MaterialIcons } from "@expo/vector-icons";
|
||||
import { COLORS } from "@/lib/constants";
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
@@ -12,7 +13,7 @@ export default function TabLayout() {
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: "#10a37f",
|
||||
tabBarActiveTintColor: COLORS.codex,
|
||||
tabBarInactiveTintColor: isDark ? "#52525b" : "#a1a1aa",
|
||||
tabBarStyle: {
|
||||
backgroundColor: isDark ? "#09090b" : "#ffffff",
|
||||
|
||||
@@ -14,8 +14,11 @@ import { MaterialIcons } from "@expo/vector-icons";
|
||||
import { useClaudeUsage } from "@/hooks/useClaudeUsage";
|
||||
import { UsageStat } from "@/components/UsageStat";
|
||||
import { ErrorMessage } from "@/components/ErrorMessage";
|
||||
import { LastUpdated } from "@/components/LastUpdated";
|
||||
import { ScreenErrorBoundary } from "@/components/ScreenErrorBoundary";
|
||||
import { COLORS } from "@/lib/constants";
|
||||
|
||||
export default function ClaudeTab() {
|
||||
function ClaudeTabContent() {
|
||||
const {
|
||||
auth,
|
||||
pendingKey,
|
||||
@@ -23,15 +26,16 @@ export default function ClaudeTab() {
|
||||
pendingOrg,
|
||||
setPendingOrg,
|
||||
usage,
|
||||
lastFetchedAt,
|
||||
status,
|
||||
error,
|
||||
keyValidationError,
|
||||
canSaveKey,
|
||||
saveKey,
|
||||
refresh,
|
||||
clearKey,
|
||||
} = useClaudeUsage();
|
||||
|
||||
const canSave = pendingKey.trim().startsWith("sk-ant-");
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
className="flex-1 bg-neutral-50 dark:bg-neutral-950"
|
||||
@@ -49,7 +53,7 @@ export default function ClaudeTab() {
|
||||
<RefreshControl
|
||||
refreshing={status === "loading"}
|
||||
onRefresh={refresh}
|
||||
tintColor="#d97706"
|
||||
tintColor={COLORS.claude}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -57,9 +61,9 @@ export default function ClaudeTab() {
|
||||
<View className="flex-row items-center gap-x-3 py-5">
|
||||
<View
|
||||
className="w-9 h-9 rounded-xl items-center justify-center"
|
||||
style={{ backgroundColor: "#d9770622" }}
|
||||
style={{ backgroundColor: `${COLORS.claude}22` }}
|
||||
>
|
||||
<MaterialIcons name="psychology" size={18} color="#d97706" />
|
||||
<MaterialIcons name="psychology" size={18} color={COLORS.claude} />
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-xl font-bold text-neutral-900 dark:text-white tracking-tight">
|
||||
@@ -111,6 +115,11 @@ export default function ClaudeTab() {
|
||||
secureTextEntry
|
||||
className="border border-neutral-200 dark:border-neutral-700 rounded-xl px-3 py-3 text-sm text-neutral-900 dark:text-white bg-white dark:bg-neutral-800 mb-2"
|
||||
/>
|
||||
{keyValidationError && (
|
||||
<Text selectable className="mb-2 text-xs text-red-500">
|
||||
{keyValidationError}
|
||||
</Text>
|
||||
)}
|
||||
<TextInput
|
||||
value={pendingOrg}
|
||||
onChangeText={setPendingOrg}
|
||||
@@ -122,13 +131,15 @@ export default function ClaudeTab() {
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={saveKey}
|
||||
disabled={!canSave}
|
||||
disabled={!canSaveKey}
|
||||
className="py-3.5 px-4 rounded-xl items-center"
|
||||
style={{ backgroundColor: canSave ? "#d97706" : "#e5e5e5" }}
|
||||
style={{
|
||||
backgroundColor: canSaveKey ? COLORS.claude : COLORS.disabled,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className="font-semibold text-sm"
|
||||
style={{ color: canSave ? "white" : "#a3a3a3" }}
|
||||
style={{ color: canSaveKey ? "white" : COLORS.muted }}
|
||||
>
|
||||
Save Session Key
|
||||
</Text>
|
||||
@@ -162,11 +173,11 @@ export default function ClaudeTab() {
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{status === "loading" && (
|
||||
<ActivityIndicator color="#d97706" style={{ marginVertical: 24 }} />
|
||||
{status === "loading" && !usage && (
|
||||
<ActivityIndicator color={COLORS.claude} style={{ marginVertical: 24 }} />
|
||||
)}
|
||||
{status === "error" && error && <ErrorMessage message={error} />}
|
||||
{status === "success" && usage && (
|
||||
{usage && (
|
||||
<View>
|
||||
<UsageStat
|
||||
label="5-hour window"
|
||||
@@ -198,6 +209,9 @@ export default function ClaudeTab() {
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
<View className="mt-4 border-t border-neutral-100 pt-3 dark:border-neutral-800">
|
||||
<LastUpdated timestamp={lastFetchedAt} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -206,3 +220,11 @@ export default function ClaudeTab() {
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClaudeTab() {
|
||||
return (
|
||||
<ScreenErrorBoundary screenName="Claude">
|
||||
<ClaudeTabContent />
|
||||
</ScreenErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,11 +11,64 @@ import { MaterialIcons } from "@expo/vector-icons";
|
||||
import { useCodexUsage } from "@/hooks/useCodexUsage";
|
||||
import { UsageStat } from "@/components/UsageStat";
|
||||
import { ErrorMessage } from "@/components/ErrorMessage";
|
||||
import { LastUpdated } from "@/components/LastUpdated";
|
||||
import { ScreenErrorBoundary } from "@/components/ScreenErrorBoundary";
|
||||
import { COLORS } from "@/lib/constants";
|
||||
import { windowLabel } from "@/lib/timeUtils";
|
||||
import type { CodexResetCredit } from "@/types/codex";
|
||||
|
||||
export default function CodexTab() {
|
||||
const { auth, usage, status, error, importAuthFile, refresh, clearAuth } =
|
||||
function formatResetStatus(status?: string) {
|
||||
if (!status) return "Unknown";
|
||||
return status.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function ResetCreditRow({ credit }: { credit: CodexResetCredit }) {
|
||||
return (
|
||||
<View className="mt-2 rounded-xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 p-3">
|
||||
<View className="flex-row items-center justify-between mb-1.5">
|
||||
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||
Credit {credit.index}
|
||||
</Text>
|
||||
<View className="px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<Text className="text-[11px] font-semibold text-green-700 dark:text-green-300">
|
||||
{formatResetStatus(credit.status)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{credit.timeUntilExpiry && (
|
||||
<Text className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Expires in {credit.timeUntilExpiry}
|
||||
</Text>
|
||||
)}
|
||||
{credit.expiresAtLocal && (
|
||||
<Text className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
Expires {credit.expiresAtLocal}
|
||||
</Text>
|
||||
)}
|
||||
{credit.grantedAtLocal && (
|
||||
<Text className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
Granted {credit.grantedAtLocal}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CodexTabContent() {
|
||||
const {
|
||||
auth,
|
||||
usage,
|
||||
lastFetchedAt,
|
||||
status,
|
||||
error,
|
||||
importAuthFile,
|
||||
refresh,
|
||||
clearAuth,
|
||||
} =
|
||||
useCodexUsage();
|
||||
const resetCoupons = usage?.resetCoupons;
|
||||
const availableResetCredits =
|
||||
resetCoupons?.credits?.filter((credit) => credit.status === "available") ?? [];
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
@@ -29,7 +82,7 @@ export default function CodexTab() {
|
||||
<RefreshControl
|
||||
refreshing={status === "loading"}
|
||||
onRefresh={refresh}
|
||||
tintColor="#10a37f"
|
||||
tintColor={COLORS.codex}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -37,9 +90,9 @@ export default function CodexTab() {
|
||||
<View className="flex-row items-center gap-x-3 py-5">
|
||||
<View
|
||||
className="w-9 h-9 rounded-xl items-center justify-center"
|
||||
style={{ backgroundColor: "#10a37f22" }}
|
||||
style={{ backgroundColor: `${COLORS.codex}22` }}
|
||||
>
|
||||
<MaterialIcons name="auto-awesome" size={18} color="#10a37f" />
|
||||
<MaterialIcons name="auto-awesome" size={18} color={COLORS.codex} />
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-xl font-bold text-neutral-900 dark:text-white tracking-tight">
|
||||
@@ -84,7 +137,7 @@ export default function CodexTab() {
|
||||
<TouchableOpacity
|
||||
onPress={importAuthFile}
|
||||
className="flex-row items-center justify-center gap-x-2 py-3.5 px-4 rounded-xl"
|
||||
style={{ backgroundColor: "#10a37f" }}
|
||||
style={{ backgroundColor: COLORS.codex }}
|
||||
>
|
||||
<MaterialIcons name="upload-file" size={16} color="white" />
|
||||
<Text className="text-white font-semibold text-sm">
|
||||
@@ -112,31 +165,35 @@ export default function CodexTab() {
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{status === "loading" && (
|
||||
<ActivityIndicator color="#10a37f" style={{ marginVertical: 24 }} />
|
||||
{status === "loading" && !usage && (
|
||||
<ActivityIndicator color={COLORS.codex} style={{ marginVertical: 24 }} />
|
||||
)}
|
||||
{status === "error" && error && <ErrorMessage message={error} />}
|
||||
{status === "success" && usage && (
|
||||
{usage && (
|
||||
<View>
|
||||
{/* Plan badge */}
|
||||
<View className="flex-row items-center mb-5">
|
||||
<View className="px-2.5 py-1 rounded-full bg-green-100 dark:bg-green-900/40">
|
||||
<Text className="text-xs font-semibold text-green-700 dark:text-green-300 capitalize">
|
||||
{usage.plan_type}
|
||||
{usage.planType ?? "codex"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{usage.primary && (
|
||||
<UsageStat
|
||||
label={`Primary (${windowLabel(usage.rate_limit.primary_window.limit_window_seconds)})`}
|
||||
percent={usage.rate_limit.primary_window.used_percent}
|
||||
resetAtSeconds={usage.rate_limit.primary_window.reset_at}
|
||||
label={`Primary (${windowLabel(usage.primary.windowSeconds)})`}
|
||||
percent={usage.primary.usedPercent}
|
||||
resetAtSeconds={usage.primary.resetsAt}
|
||||
/>
|
||||
)}
|
||||
{usage.secondary && (
|
||||
<UsageStat
|
||||
label={`Secondary (${windowLabel(usage.rate_limit.secondary_window.limit_window_seconds)})`}
|
||||
percent={usage.rate_limit.secondary_window.used_percent}
|
||||
resetAtSeconds={usage.rate_limit.secondary_window.reset_at}
|
||||
label={`Secondary (${windowLabel(usage.secondary.windowSeconds)})`}
|
||||
percent={usage.secondary.usedPercent}
|
||||
resetAtSeconds={usage.secondary.resetsAt}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Credits */}
|
||||
<View className="mt-1 p-3.5 rounded-xl bg-neutral-50 dark:bg-neutral-800 border border-neutral-100 dark:border-neutral-700">
|
||||
@@ -145,12 +202,12 @@ export default function CodexTab() {
|
||||
</Text>
|
||||
{usage.credits.unlimited ? (
|
||||
<View className="flex-row items-center gap-x-2">
|
||||
<MaterialIcons name="all-inclusive" size={16} color="#10a37f" />
|
||||
<MaterialIcons name="all-inclusive" size={16} color={COLORS.codex} />
|
||||
<Text className="text-sm font-semibold text-green-600 dark:text-green-400">
|
||||
Unlimited
|
||||
</Text>
|
||||
</View>
|
||||
) : usage.credits.has_credits ? (
|
||||
) : usage.credits.hasCredits ? (
|
||||
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||
${Number(usage.credits.balance).toFixed(2)} remaining
|
||||
</Text>
|
||||
@@ -158,6 +215,50 @@ export default function CodexTab() {
|
||||
<Text className="text-sm text-neutral-400">No credits</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{resetCoupons && (
|
||||
<View className="mt-3 p-3.5 rounded-xl bg-neutral-50 dark:bg-neutral-800 border border-neutral-100 dark:border-neutral-700">
|
||||
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 mb-2">
|
||||
Reset Credits
|
||||
</Text>
|
||||
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||
{resetCoupons.availableCount ?? 0} available
|
||||
</Text>
|
||||
{typeof resetCoupons.totalEarnedCount === "number" && (
|
||||
<Text className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{resetCoupons.totalEarnedCount} earned total
|
||||
</Text>
|
||||
)}
|
||||
{resetCoupons.nextExpiringCredit?.expiresAtLocal && (
|
||||
<Text className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
Next expires {resetCoupons.nextExpiringCredit.expiresAtLocal}
|
||||
</Text>
|
||||
)}
|
||||
{resetCoupons.nextExpiringCredit?.timeUntilExpiry && (
|
||||
<Text className="text-xs text-neutral-400 dark:text-neutral-500 mt-0.5">
|
||||
In {resetCoupons.nextExpiringCredit.timeUntilExpiry}
|
||||
</Text>
|
||||
)}
|
||||
{availableResetCredits.length > 0 && (
|
||||
<View className="mt-3 pt-3 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 mb-1">
|
||||
Available Now
|
||||
</Text>
|
||||
{availableResetCredits.map((credit) => (
|
||||
<ResetCreditRow key={`${credit.index}-${credit.expiresAt ?? "none"}`} credit={credit} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
{resetCoupons.source !== "live_api" && (
|
||||
<Text className="text-xs text-neutral-400 dark:text-neutral-500 mt-3">
|
||||
Source: {resetCoupons.sourceDescription}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
<View className="mt-4 border-t border-neutral-100 pt-3 dark:border-neutral-800">
|
||||
<LastUpdated timestamp={lastFetchedAt} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -165,3 +266,11 @@ export default function CodexTab() {
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CodexTab() {
|
||||
return (
|
||||
<ScreenErrorBoundary screenName="Codex">
|
||||
<CodexTabContent />
|
||||
</ScreenErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ import { useClaudeUsage } from "@/hooks/useClaudeUsage";
|
||||
import { onUsageDataLoaded } from "@/lib/notifications";
|
||||
import { ProgressBar } from "@/components/ProgressBar";
|
||||
import { ResetCountdown } from "@/components/ResetCountdown";
|
||||
import { LastUpdated } from "@/components/LastUpdated";
|
||||
import { ErrorMessage } from "@/components/ErrorMessage";
|
||||
import { ScreenErrorBoundary } from "@/components/ScreenErrorBoundary";
|
||||
import { COLORS, getUsageColor } from "@/lib/constants";
|
||||
import { windowLabel } from "@/lib/timeUtils";
|
||||
|
||||
function UsageRow({
|
||||
@@ -28,8 +32,7 @@ function UsageRow({
|
||||
resetAtSeconds?: number;
|
||||
resetAtISO?: string;
|
||||
}) {
|
||||
const color =
|
||||
percent >= 90 ? "#ef4444" : percent >= 70 ? "#f59e0b" : "#10a37f";
|
||||
const color = getUsageColor(percent);
|
||||
return (
|
||||
<View className="mb-4">
|
||||
<View className="flex-row justify-between items-baseline mb-1.5">
|
||||
@@ -65,33 +68,33 @@ function SetupPrompt({ label }: { label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardTab() {
|
||||
function DashboardTabContent() {
|
||||
const codex = useCodexUsage();
|
||||
const claude = useClaudeUsage();
|
||||
|
||||
// Fire daily digest reschedule + threshold check whenever fresh data arrives
|
||||
useEffect(() => {
|
||||
if (codex.status === "success" || claude.status === "success") {
|
||||
if (codex.usage || claude.usage) {
|
||||
void onUsageDataLoaded(
|
||||
claude.status === "success" ? claude.usage : null,
|
||||
codex.status === "success" ? codex.usage : null
|
||||
claude.usage,
|
||||
codex.usage
|
||||
);
|
||||
}
|
||||
}, [codex.status, claude.status]);
|
||||
}, [codex.lastFetchedAt, claude.lastFetchedAt]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
codex.reload();
|
||||
claude.reload();
|
||||
}, [])
|
||||
void codex.reloadCredentials();
|
||||
void claude.reloadCredentials();
|
||||
}, [codex.reloadCredentials, claude.reloadCredentials])
|
||||
);
|
||||
|
||||
const isRefreshing =
|
||||
codex.status === "loading" || claude.status === "loading";
|
||||
|
||||
const handleRefresh = () => {
|
||||
codex.refresh();
|
||||
claude.refresh();
|
||||
void codex.refresh();
|
||||
void claude.refresh();
|
||||
};
|
||||
|
||||
const connectedCount = [codex.auth, claude.auth].filter(Boolean).length;
|
||||
@@ -108,7 +111,7 @@ export default function DashboardTab() {
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor="#10a37f"
|
||||
tintColor={COLORS.codex}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -135,38 +138,38 @@ export default function DashboardTab() {
|
||||
onPress={handleRefresh}
|
||||
className="w-9 h-9 rounded-xl bg-neutral-100 dark:bg-neutral-800 items-center justify-center"
|
||||
>
|
||||
<MaterialIcons name="refresh" size={18} color="#10a37f" />
|
||||
<MaterialIcons name="refresh" size={18} color={COLORS.codex} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Codex card */}
|
||||
<View className="rounded-2xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 mb-4 overflow-hidden">
|
||||
<View style={{ height: 3, backgroundColor: "#10a37f" }} />
|
||||
<View style={{ height: 3, backgroundColor: COLORS.codex }} />
|
||||
<View className="p-4">
|
||||
<View className="flex-row items-center justify-between mb-4">
|
||||
<View className="flex-row items-center gap-x-2.5">
|
||||
<View
|
||||
className="w-8 h-8 rounded-xl items-center justify-center"
|
||||
style={{ backgroundColor: "#10a37f18" }}
|
||||
style={{ backgroundColor: `${COLORS.codex}18` }}
|
||||
>
|
||||
<MaterialIcons name="auto-awesome" size={16} color="#10a37f" />
|
||||
<MaterialIcons name="auto-awesome" size={16} color={COLORS.codex} />
|
||||
</View>
|
||||
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||
Codex CLI
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-x-2">
|
||||
{codex.usage?.plan_type && (
|
||||
{codex.usage?.planType && (
|
||||
<View className="px-2.5 py-1 rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<Text className="text-xs font-semibold text-green-700 dark:text-green-300 capitalize">
|
||||
{codex.usage.plan_type}
|
||||
{codex.usage.planType}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{codex.auth && codex.status === "error" && (
|
||||
<View className="w-2 h-2 rounded-full bg-red-500" />
|
||||
)}
|
||||
{codex.status === "success" && !codex.usage?.plan_type && (
|
||||
{codex.status === "success" && !codex.usage?.planType && (
|
||||
<View className="w-2 h-2 rounded-full bg-green-500" />
|
||||
)}
|
||||
</View>
|
||||
@@ -175,38 +178,33 @@ export default function DashboardTab() {
|
||||
{!codex.auth && (
|
||||
<SetupPrompt label="Import auth.json to see your Codex rate limits." />
|
||||
)}
|
||||
{codex.auth && codex.status === "loading" && (
|
||||
{codex.auth && codex.status === "loading" && !codex.usage && (
|
||||
<ActivityIndicator
|
||||
color="#10a37f"
|
||||
color={COLORS.codex}
|
||||
style={{ marginVertical: 16 }}
|
||||
/>
|
||||
)}
|
||||
{codex.auth && codex.status === "error" && (
|
||||
<View className="flex-row items-center gap-x-2 py-1">
|
||||
<MaterialIcons name="error-outline" size={16} color="#ef4444" />
|
||||
<Text className="text-sm text-red-500 dark:text-red-400">
|
||||
Failed to fetch usage
|
||||
</Text>
|
||||
</View>
|
||||
<ErrorMessage message={codex.error} />
|
||||
)}
|
||||
{codex.status === "success" && codex.usage && (
|
||||
{codex.usage && (
|
||||
<View>
|
||||
{codex.usage.primary && (
|
||||
<UsageRow
|
||||
label={`${windowLabel(codex.usage.rate_limit.primary_window.limit_window_seconds)} window`}
|
||||
percent={codex.usage.rate_limit.primary_window.used_percent}
|
||||
resetAtSeconds={codex.usage.rate_limit.primary_window.reset_at}
|
||||
label={`${windowLabel(codex.usage.primary.windowSeconds)} window`}
|
||||
percent={codex.usage.primary.usedPercent}
|
||||
resetAtSeconds={codex.usage.primary.resetsAt}
|
||||
/>
|
||||
)}
|
||||
{codex.usage.secondary && (
|
||||
<UsageRow
|
||||
label={`${windowLabel(codex.usage.rate_limit.secondary_window.limit_window_seconds)} window`}
|
||||
percent={
|
||||
codex.usage.rate_limit.secondary_window.used_percent
|
||||
}
|
||||
resetAtSeconds={
|
||||
codex.usage.rate_limit.secondary_window.reset_at
|
||||
}
|
||||
label={`${windowLabel(codex.usage.secondary.windowSeconds)} window`}
|
||||
percent={codex.usage.secondary.usedPercent}
|
||||
resetAtSeconds={codex.usage.secondary.resetsAt}
|
||||
/>
|
||||
)}
|
||||
{!codex.usage.credits.unlimited &&
|
||||
codex.usage.credits.has_credits && (
|
||||
codex.usage.credits.hasCredits && (
|
||||
<View className="pt-3 border-t border-neutral-100 dark:border-neutral-800 flex-row items-center gap-x-2">
|
||||
<MaterialIcons name="toll" size={14} color="#a3a3a3" />
|
||||
<Text className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
@@ -220,13 +218,32 @@ export default function DashboardTab() {
|
||||
<MaterialIcons
|
||||
name="all-inclusive"
|
||||
size={14}
|
||||
color="#10a37f"
|
||||
color={COLORS.codex}
|
||||
/>
|
||||
<Text className="text-xs text-green-600 dark:text-green-400">
|
||||
Unlimited credits
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className="mt-3 border-t border-neutral-100 pt-3 dark:border-neutral-800">
|
||||
{!!codex.usage.resetCoupons && (
|
||||
<View className="mb-3">
|
||||
<View className="flex-row items-center gap-x-2">
|
||||
<MaterialIcons name="restart-alt" size={14} color={COLORS.codex} />
|
||||
<Text className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{codex.usage.resetCoupons.availableCount ?? 0} reset credits
|
||||
available
|
||||
</Text>
|
||||
</View>
|
||||
{!!codex.usage.resetCoupons.nextExpiringCredit?.timeUntilExpiry && (
|
||||
<Text className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
|
||||
Next expires in {codex.usage.resetCoupons.nextExpiringCredit.timeUntilExpiry}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
<LastUpdated timestamp={codex.lastFetchedAt} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -234,15 +251,15 @@ export default function DashboardTab() {
|
||||
|
||||
{/* Claude card */}
|
||||
<View className="rounded-2xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 mb-4 overflow-hidden">
|
||||
<View style={{ height: 3, backgroundColor: "#d97706" }} />
|
||||
<View style={{ height: 3, backgroundColor: COLORS.claude }} />
|
||||
<View className="p-4">
|
||||
<View className="flex-row items-center justify-between mb-4">
|
||||
<View className="flex-row items-center gap-x-2.5">
|
||||
<View
|
||||
className="w-8 h-8 rounded-xl items-center justify-center"
|
||||
style={{ backgroundColor: "#d9770618" }}
|
||||
style={{ backgroundColor: `${COLORS.claude}18` }}
|
||||
>
|
||||
<MaterialIcons name="psychology" size={16} color="#d97706" />
|
||||
<MaterialIcons name="psychology" size={16} color={COLORS.claude} />
|
||||
</View>
|
||||
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||
Claude.ai
|
||||
@@ -259,21 +276,16 @@ export default function DashboardTab() {
|
||||
{!claude.auth && (
|
||||
<SetupPrompt label="Add your session key to see Claude usage windows." />
|
||||
)}
|
||||
{claude.auth && claude.status === "loading" && (
|
||||
{claude.auth && claude.status === "loading" && !claude.usage && (
|
||||
<ActivityIndicator
|
||||
color="#d97706"
|
||||
color={COLORS.claude}
|
||||
style={{ marginVertical: 16 }}
|
||||
/>
|
||||
)}
|
||||
{claude.auth && claude.status === "error" && (
|
||||
<View className="flex-row items-center gap-x-2 py-1">
|
||||
<MaterialIcons name="error-outline" size={16} color="#ef4444" />
|
||||
<Text className="text-sm text-red-500 dark:text-red-400">
|
||||
Failed to fetch usage
|
||||
</Text>
|
||||
</View>
|
||||
<ErrorMessage message={claude.error} />
|
||||
)}
|
||||
{claude.status === "success" && claude.usage && (
|
||||
{claude.usage && (
|
||||
<View>
|
||||
<UsageRow
|
||||
label="5-hour window"
|
||||
@@ -305,6 +317,9 @@ export default function DashboardTab() {
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
<View className="mt-3 border-t border-neutral-100 pt-3 dark:border-neutral-800">
|
||||
<LastUpdated timestamp={claude.lastFetchedAt} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -313,3 +328,11 @@ export default function DashboardTab() {
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardTab() {
|
||||
return (
|
||||
<ScreenErrorBoundary screenName="Dashboard">
|
||||
<DashboardTabContent />
|
||||
</ScreenErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
TextInput,
|
||||
Switch,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { router, useFocusEffect } from "expo-router";
|
||||
@@ -26,22 +25,22 @@ import {
|
||||
import { pickAndReadCodexAuth } from "@/lib/fileReader";
|
||||
import { resetOnboarding } from "@/lib/setupState";
|
||||
import { useNotificationSettings } from "@/hooks/useNotificationSettings";
|
||||
import {
|
||||
isClaudeSessionKeyValid,
|
||||
validateClaudeSessionKey,
|
||||
} from "@/lib/claudeCredentials";
|
||||
import { COLORS } from "@/lib/constants";
|
||||
import { formatTime, parseTimeInput } from "@/lib/timeUtils";
|
||||
import { ScreenErrorBoundary } from "@/components/ScreenErrorBoundary";
|
||||
import type { CodexAuth } from "@/types/codex";
|
||||
import Constants from "expo-constants";
|
||||
|
||||
function parseTimeInput(s: string): { hour: number; minute: number } | null {
|
||||
const m = s.trim().match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (!m) return null;
|
||||
const h = parseInt(m[1], 10);
|
||||
const min = parseInt(m[2], 10);
|
||||
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
|
||||
return { hour: h, minute: min };
|
||||
function humanizeImportError(code: string): string {
|
||||
if (code === "DOCUMENT_NOT_READABLE") {
|
||||
return "The selected file could not be read. Try copying auth.json into Files or Downloads and import it again.";
|
||||
}
|
||||
|
||||
function formatTime(hour: number, minute: number): string {
|
||||
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||
return code;
|
||||
}
|
||||
|
||||
function SettingRow({
|
||||
icon,
|
||||
label,
|
||||
@@ -120,7 +119,7 @@ function Divider() {
|
||||
return <View className="ml-16 mr-0 h-px bg-neutral-100 dark:bg-neutral-800" />;
|
||||
}
|
||||
|
||||
export default function SettingsTab() {
|
||||
function SettingsTabContent() {
|
||||
const [codexAuth, setCodexAuth] = useState<CodexAuth | null>(null);
|
||||
const [claudeKey, setClaudeKey] = useState<string | null>(null);
|
||||
const [pendingKey, setPendingKey] = useState("");
|
||||
@@ -129,6 +128,7 @@ export default function SettingsTab() {
|
||||
|
||||
const notif = useNotificationSettings();
|
||||
const [timeInput, setTimeInput] = useState("");
|
||||
const [timeError, setTimeError] = useState<string | null>(null);
|
||||
const [thresholdInput, setThresholdInput] = useState("");
|
||||
|
||||
// Sync local text inputs when settings load
|
||||
@@ -166,15 +166,16 @@ export default function SettingsTab() {
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
|
||||
if (msg !== "PICKER_CANCELLED") {
|
||||
Alert.alert("Import failed", msg);
|
||||
Alert.alert("Import failed", humanizeImportError(msg));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveClaude = async () => {
|
||||
const trimmed = pendingKey.trim();
|
||||
if (!trimmed.startsWith("sk-ant-")) {
|
||||
setClaudeError("Key must start with sk-ant-");
|
||||
const validationError = validateClaudeSessionKey(trimmed);
|
||||
if (validationError) {
|
||||
setClaudeError(validationError);
|
||||
return;
|
||||
}
|
||||
const orgTrimmed = pendingOrg.trim() || undefined;
|
||||
@@ -249,9 +250,10 @@ export default function SettingsTab() {
|
||||
const parsed = parseTimeInput(timeInput);
|
||||
if (parsed) {
|
||||
void notif.update({ dailyHour: parsed.hour, dailyMinute: parsed.minute });
|
||||
setTimeInput(formatTime(parsed.hour, parsed.minute));
|
||||
setTimeError(null);
|
||||
} else {
|
||||
// Reset to last valid value
|
||||
setTimeInput(formatTime(notif.settings.dailyHour, notif.settings.dailyMinute));
|
||||
setTimeError("Use HH:MM format, from 00:00 to 23:59.");
|
||||
}
|
||||
}, [timeInput, notif]);
|
||||
|
||||
@@ -264,7 +266,9 @@ export default function SettingsTab() {
|
||||
}
|
||||
}, [thresholdInput, notif]);
|
||||
|
||||
const canSaveClaude = pendingKey.trim().startsWith("sk-ant-");
|
||||
const canSaveClaude = isClaudeSessionKeyValid(pendingKey);
|
||||
const inlineClaudeError =
|
||||
claudeError ?? (pendingKey ? validateClaudeSessionKey(pendingKey) : null);
|
||||
const appVersion = Constants.expoConfig?.version ?? "1.0.0";
|
||||
|
||||
return (
|
||||
@@ -274,7 +278,7 @@ export default function SettingsTab() {
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
className="flex-1"
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
behavior={process.env.EXPO_OS === "ios" ? "padding" : "height"}
|
||||
>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
@@ -341,9 +345,9 @@ export default function SettingsTab() {
|
||||
</>
|
||||
) : (
|
||||
<View className="p-4">
|
||||
{claudeError && (
|
||||
<Text className="text-xs text-red-500 mb-2">
|
||||
{claudeError}
|
||||
{inlineClaudeError && (
|
||||
<Text selectable className="text-xs text-red-500 mb-2">
|
||||
{inlineClaudeError}
|
||||
</Text>
|
||||
)}
|
||||
<TextInput
|
||||
@@ -373,12 +377,14 @@ export default function SettingsTab() {
|
||||
disabled={!canSaveClaude}
|
||||
className="py-3 px-4 rounded-xl items-center"
|
||||
style={{
|
||||
backgroundColor: canSaveClaude ? "#d97706" : "#e5e5e5",
|
||||
backgroundColor: canSaveClaude
|
||||
? COLORS.claude
|
||||
: COLORS.disabled,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className="font-semibold text-sm"
|
||||
style={{ color: canSaveClaude ? "white" : "#a3a3a3" }}
|
||||
style={{ color: canSaveClaude ? "white" : COLORS.muted }}
|
||||
>
|
||||
Save Session Key
|
||||
</Text>
|
||||
@@ -446,7 +452,7 @@ export default function SettingsTab() {
|
||||
<Switch
|
||||
value={notif.settings.dailyEnabled}
|
||||
onValueChange={(v) => void notif.update({ dailyEnabled: v })}
|
||||
trackColor={{ false: "#e5e5e5", true: "#10a37f" }}
|
||||
trackColor={{ false: COLORS.disabled, true: COLORS.codex }}
|
||||
thumbColor="white"
|
||||
/>
|
||||
</View>
|
||||
@@ -454,7 +460,8 @@ export default function SettingsTab() {
|
||||
{notif.settings.dailyEnabled && (
|
||||
<>
|
||||
<Divider />
|
||||
<View className="flex-row items-center gap-x-3 py-3 px-4">
|
||||
<View className="px-4 py-3">
|
||||
<View className="flex-row items-center gap-x-3">
|
||||
<View className="w-8 items-center">
|
||||
<MaterialIcons name="schedule" size={20} color="#737373" />
|
||||
</View>
|
||||
@@ -463,10 +470,13 @@ export default function SettingsTab() {
|
||||
</Text>
|
||||
<TextInput
|
||||
value={timeInput}
|
||||
onChangeText={setTimeInput}
|
||||
onChangeText={(value) => {
|
||||
setTimeInput(value);
|
||||
setTimeError(null);
|
||||
}}
|
||||
onBlur={handleTimeBlur}
|
||||
placeholder="09:00"
|
||||
placeholderTextColor="#a3a3a3"
|
||||
placeholderTextColor={COLORS.muted}
|
||||
keyboardType="numbers-and-punctuation"
|
||||
returnKeyType="done"
|
||||
maxLength={5}
|
||||
@@ -474,6 +484,12 @@ export default function SettingsTab() {
|
||||
style={{ minWidth: 52 }}
|
||||
/>
|
||||
</View>
|
||||
{timeError && (
|
||||
<Text selectable className="ml-11 mt-1.5 text-xs text-red-500">
|
||||
{timeError}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -495,7 +511,7 @@ export default function SettingsTab() {
|
||||
<Switch
|
||||
value={notif.settings.thresholdEnabled}
|
||||
onValueChange={(v) => void notif.update({ thresholdEnabled: v })}
|
||||
trackColor={{ false: "#e5e5e5", true: "#d97706" }}
|
||||
trackColor={{ false: COLORS.disabled, true: COLORS.claude }}
|
||||
thumbColor="white"
|
||||
/>
|
||||
</View>
|
||||
@@ -554,3 +570,11 @@ export default function SettingsTab() {
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsTab() {
|
||||
return (
|
||||
<ScreenErrorBoundary screenName="Settings">
|
||||
<SettingsTabContent />
|
||||
</ScreenErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from "react";
|
||||
import { View, ActivityIndicator } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { hasCompletedOnboarding } from "@/lib/setupState";
|
||||
import { COLORS } from "@/lib/constants";
|
||||
|
||||
export default function Index() {
|
||||
useEffect(() => {
|
||||
@@ -12,7 +13,7 @@ export default function Index() {
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-neutral-950 items-center justify-center">
|
||||
<ActivityIndicator color="#10a37f" size="large" />
|
||||
<ActivityIndicator color={COLORS.codex} size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
@@ -18,6 +17,11 @@ import {
|
||||
loadClaudeSessionKey,
|
||||
loadClaudeLastActiveOrg,
|
||||
} from "@/lib/storage";
|
||||
import {
|
||||
isClaudeSessionKeyValid,
|
||||
validateClaudeSessionKey,
|
||||
} from "@/lib/claudeCredentials";
|
||||
import { COLORS } from "@/lib/constants";
|
||||
|
||||
export default function OnboardingClaudeScreen() {
|
||||
const [savedKey, setSavedKey] = useState<string | null>(null);
|
||||
@@ -35,12 +39,15 @@ export default function OnboardingClaudeScreen() {
|
||||
);
|
||||
}, []);
|
||||
|
||||
const canSave = pendingKey.trim().startsWith("sk-ant-");
|
||||
const canSave = isClaudeSessionKeyValid(pendingKey);
|
||||
const inlineError =
|
||||
error ?? (pendingKey ? validateClaudeSessionKey(pendingKey) : null);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const trimmed = pendingKey.trim();
|
||||
if (!trimmed.startsWith("sk-ant-")) {
|
||||
setError("Session key must start with sk-ant-");
|
||||
const validationError = validateClaudeSessionKey(trimmed);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
const orgTrimmed = pendingOrg.trim() || undefined;
|
||||
@@ -58,7 +65,7 @@ export default function OnboardingClaudeScreen() {
|
||||
<SafeAreaView className="flex-1 bg-neutral-950">
|
||||
<KeyboardAvoidingView
|
||||
className="flex-1"
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
behavior={process.env.EXPO_OS === "ios" ? "padding" : "height"}
|
||||
>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
@@ -77,14 +84,14 @@ export default function OnboardingClaudeScreen() {
|
||||
<View className="flex-row items-center gap-x-2">
|
||||
<View
|
||||
className="w-6 h-6 rounded-full items-center justify-center"
|
||||
style={{ backgroundColor: "#10a37f" }}
|
||||
style={{ backgroundColor: COLORS.codex }}
|
||||
>
|
||||
<MaterialIcons name="check" size={14} color="white" />
|
||||
</View>
|
||||
<View className="w-12 h-0.5" style={{ backgroundColor: "#d97706" }} />
|
||||
<View className="w-12 h-0.5" style={{ backgroundColor: COLORS.claude }} />
|
||||
<View
|
||||
className="w-6 h-6 rounded-full items-center justify-center"
|
||||
style={{ backgroundColor: "#d97706" }}
|
||||
style={{ backgroundColor: COLORS.claude }}
|
||||
>
|
||||
<Text className="text-white text-xs font-bold">2</Text>
|
||||
</View>
|
||||
@@ -96,9 +103,9 @@ export default function OnboardingClaudeScreen() {
|
||||
<View>
|
||||
<View
|
||||
className="w-16 h-16 rounded-2xl items-center justify-center mb-6"
|
||||
style={{ backgroundColor: "#d9770622" }}
|
||||
style={{ backgroundColor: `${COLORS.claude}22` }}
|
||||
>
|
||||
<MaterialIcons name="psychology" size={32} color="#d97706" />
|
||||
<MaterialIcons name="psychology" size={32} color={COLORS.claude} />
|
||||
</View>
|
||||
<Text className="text-3xl font-bold text-white mb-3">
|
||||
Connect Claude.ai
|
||||
@@ -112,9 +119,9 @@ export default function OnboardingClaudeScreen() {
|
||||
<View className="flex-row items-center gap-x-3 p-4">
|
||||
<View
|
||||
className="w-10 h-10 rounded-xl items-center justify-center"
|
||||
style={{ backgroundColor: "#d9770622" }}
|
||||
style={{ backgroundColor: `${COLORS.claude}22` }}
|
||||
>
|
||||
<MaterialIcons name="check-circle" size={22} color="#d97706" />
|
||||
<MaterialIcons name="check-circle" size={22} color={COLORS.claude} />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-white font-semibold">Connected</Text>
|
||||
@@ -143,9 +150,9 @@ export default function OnboardingClaudeScreen() {
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
{error && (
|
||||
{inlineError && (
|
||||
<View className="bg-red-950 border border-red-900 rounded-xl px-4 py-3 mb-4">
|
||||
<Text className="text-red-400 text-sm">{error}</Text>
|
||||
<Text selectable className="text-red-400 text-sm">{inlineError}</Text>
|
||||
</View>
|
||||
)}
|
||||
<TextInput
|
||||
@@ -174,7 +181,9 @@ export default function OnboardingClaudeScreen() {
|
||||
onPress={handleSave}
|
||||
disabled={!canSave}
|
||||
className="rounded-2xl py-4 items-center mb-4"
|
||||
style={{ backgroundColor: canSave ? "#d97706" : "#1a1a1a" }}
|
||||
style={{
|
||||
backgroundColor: canSave ? COLORS.claude : "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className="font-bold text-base"
|
||||
@@ -201,7 +210,9 @@ export default function OnboardingClaudeScreen() {
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push("/onboarding/done")}
|
||||
className="rounded-2xl py-4 items-center"
|
||||
style={{ backgroundColor: savedKey ? "#d97706" : "#1a1a1a" }}
|
||||
style={{
|
||||
backgroundColor: savedKey ? COLORS.claude : "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className="font-bold text-base"
|
||||
|
||||
@@ -6,6 +6,14 @@ import { MaterialIcons } from "@expo/vector-icons";
|
||||
import { pickAndReadCodexAuth } from "@/lib/fileReader";
|
||||
import { saveCodexAuth, loadCodexAuth } from "@/lib/storage";
|
||||
import type { CodexAuth } from "@/types/codex";
|
||||
import { COLORS } from "@/lib/constants";
|
||||
|
||||
function humanizeImportError(code: string): string {
|
||||
if (code === "DOCUMENT_NOT_READABLE") {
|
||||
return "The selected file could not be read. Try copying auth.json into Files or Downloads and import it again.";
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
export default function OnboardingCodexScreen() {
|
||||
const [auth, setAuth] = useState<CodexAuth | null>(null);
|
||||
@@ -27,7 +35,7 @@ export default function OnboardingCodexScreen() {
|
||||
setAuth(parsed);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
|
||||
if (msg !== "PICKER_CANCELLED") setError(msg);
|
||||
if (msg !== "PICKER_CANCELLED") setError(humanizeImportError(msg));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -45,7 +53,7 @@ export default function OnboardingCodexScreen() {
|
||||
<MaterialIcons name="arrow-back" size={18} color="#a3a3a3" />
|
||||
</TouchableOpacity>
|
||||
<View className="flex-row items-center gap-x-2">
|
||||
<View className="w-6 h-6 rounded-full items-center justify-center" style={{ backgroundColor: "#10a37f" }}>
|
||||
<View className="w-6 h-6 rounded-full items-center justify-center" style={{ backgroundColor: COLORS.codex }}>
|
||||
<Text className="text-white text-xs font-bold">1</Text>
|
||||
</View>
|
||||
<View className="w-12 h-0.5 bg-neutral-800" />
|
||||
@@ -60,9 +68,9 @@ export default function OnboardingCodexScreen() {
|
||||
<View>
|
||||
<View
|
||||
className="w-16 h-16 rounded-2xl items-center justify-center mb-6"
|
||||
style={{ backgroundColor: "#10a37f22" }}
|
||||
style={{ backgroundColor: `${COLORS.codex}22` }}
|
||||
>
|
||||
<MaterialIcons name="auto-awesome" size={32} color="#10a37f" />
|
||||
<MaterialIcons name="auto-awesome" size={32} color={COLORS.codex} />
|
||||
</View>
|
||||
<Text className="text-3xl font-bold text-white mb-3">
|
||||
Connect Codex CLI
|
||||
@@ -80,12 +88,12 @@ export default function OnboardingCodexScreen() {
|
||||
<View className="flex-row items-center gap-x-3 bg-neutral-900 rounded-2xl p-4 border border-neutral-800 mb-4">
|
||||
<View
|
||||
className="w-10 h-10 rounded-xl items-center justify-center"
|
||||
style={{ backgroundColor: "#10a37f22" }}
|
||||
style={{ backgroundColor: `${COLORS.codex}22` }}
|
||||
>
|
||||
<MaterialIcons name="check-circle" size={22} color="#10a37f" />
|
||||
<MaterialIcons name="check-circle" size={22} color={COLORS.codex} />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-white font-semibold">Connected</Text>
|
||||
<Text className="text-white font-semibold">Imported</Text>
|
||||
{auth.accountId && (
|
||||
<Text className="text-neutral-500 text-sm font-mono">
|
||||
{auth.accountId.slice(0, 12)}…
|
||||
@@ -107,10 +115,10 @@ export default function OnboardingCodexScreen() {
|
||||
onPress={importFile}
|
||||
disabled={loading}
|
||||
className="rounded-2xl py-4 items-center mb-3"
|
||||
style={{ backgroundColor: loading ? "#1a1a1a" : "#10a37f" }}
|
||||
style={{ backgroundColor: loading ? "#1a1a1a" : COLORS.codex }}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#10a37f" />
|
||||
<ActivityIndicator color={COLORS.codex} />
|
||||
) : (
|
||||
<View className="flex-row items-center gap-x-2">
|
||||
<MaterialIcons name="upload-file" size={18} color="white" />
|
||||
@@ -132,7 +140,7 @@ export default function OnboardingCodexScreen() {
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push("/onboarding/claude")}
|
||||
className="rounded-2xl py-4 items-center"
|
||||
style={{ backgroundColor: auth ? "#10a37f" : "#1a1a1a" }}
|
||||
style={{ backgroundColor: auth ? COLORS.codex : "#1a1a1a" }}
|
||||
>
|
||||
<Text
|
||||
className="font-bold text-base"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { router } from "expo-router";
|
||||
import { MaterialIcons } from "@expo/vector-icons";
|
||||
import { markOnboardingComplete } from "@/lib/setupState";
|
||||
import { loadCodexAuth, loadClaudeSessionKey } from "@/lib/storage";
|
||||
import { COLORS } from "@/lib/constants";
|
||||
|
||||
export default function OnboardingDoneScreen() {
|
||||
const [codexConfigured, setCodexConfigured] = useState(false);
|
||||
@@ -32,12 +33,12 @@ export default function OnboardingDoneScreen() {
|
||||
{/* Icon */}
|
||||
<View
|
||||
className="w-20 h-20 rounded-3xl items-center justify-center mb-8"
|
||||
style={{ backgroundColor: "#10a37f22" }}
|
||||
style={{ backgroundColor: `${COLORS.codex}22` }}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={noneConfigured ? "info-outline" : "check-circle"}
|
||||
size={40}
|
||||
color={noneConfigured ? "#a3a3a3" : "#10a37f"}
|
||||
color={noneConfigured ? COLORS.muted : COLORS.codex}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -57,13 +58,15 @@ export default function OnboardingDoneScreen() {
|
||||
<View
|
||||
className="w-10 h-10 rounded-xl items-center justify-center"
|
||||
style={{
|
||||
backgroundColor: codexConfigured ? "#10a37f22" : "#27272a",
|
||||
backgroundColor: codexConfigured
|
||||
? `${COLORS.codex}22`
|
||||
: "#27272a",
|
||||
}}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={codexConfigured ? "check-circle" : "radio-button-unchecked"}
|
||||
size={22}
|
||||
color={codexConfigured ? "#10a37f" : "#525252"}
|
||||
color={codexConfigured ? COLORS.codex : "#525252"}
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
@@ -74,7 +77,7 @@ export default function OnboardingDoneScreen() {
|
||||
Codex CLI
|
||||
</Text>
|
||||
<Text className="text-neutral-600 text-sm">
|
||||
{codexConfigured ? "Connected" : "Not configured"}
|
||||
{codexConfigured ? "Auth imported" : "Not configured"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -83,13 +86,15 @@ export default function OnboardingDoneScreen() {
|
||||
<View
|
||||
className="w-10 h-10 rounded-xl items-center justify-center"
|
||||
style={{
|
||||
backgroundColor: claudeConfigured ? "#d9770622" : "#27272a",
|
||||
backgroundColor: claudeConfigured
|
||||
? `${COLORS.claude}22`
|
||||
: "#27272a",
|
||||
}}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={claudeConfigured ? "check-circle" : "radio-button-unchecked"}
|
||||
size={22}
|
||||
color={claudeConfigured ? "#d97706" : "#525252"}
|
||||
color={claudeConfigured ? COLORS.claude : "#525252"}
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
@@ -100,7 +105,7 @@ export default function OnboardingDoneScreen() {
|
||||
Claude.ai
|
||||
</Text>
|
||||
<Text className="text-neutral-600 text-sm">
|
||||
{claudeConfigured ? "Connected" : "Not configured"}
|
||||
{claudeConfigured ? "Session saved" : "Not configured"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -110,7 +115,7 @@ export default function OnboardingDoneScreen() {
|
||||
<TouchableOpacity
|
||||
onPress={handleEnterApp}
|
||||
className="w-full rounded-2xl py-4 items-center"
|
||||
style={{ backgroundColor: "#10a37f" }}
|
||||
style={{ backgroundColor: COLORS.codex }}
|
||||
>
|
||||
<Text className="text-white font-bold text-base">Open App</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { View, Text, TouchableOpacity } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { router } from "expo-router";
|
||||
import { MaterialIcons } from "@expo/vector-icons";
|
||||
import { COLORS } from "@/lib/constants";
|
||||
|
||||
export default function WelcomeScreen() {
|
||||
return (
|
||||
@@ -12,13 +13,13 @@ export default function WelcomeScreen() {
|
||||
<View className="flex-row items-center justify-center mb-8">
|
||||
<View
|
||||
className="w-16 h-16 rounded-2xl items-center justify-center"
|
||||
style={{ backgroundColor: "#10a37f" }}
|
||||
style={{ backgroundColor: COLORS.codex }}
|
||||
>
|
||||
<MaterialIcons name="auto-awesome" size={28} color="white" />
|
||||
</View>
|
||||
<View
|
||||
className="w-16 h-16 rounded-2xl items-center justify-center -ml-5"
|
||||
style={{ backgroundColor: "#d97706" }}
|
||||
style={{ backgroundColor: COLORS.claude }}
|
||||
>
|
||||
<MaterialIcons name="psychology" size={28} color="white" />
|
||||
</View>
|
||||
@@ -36,9 +37,9 @@ export default function WelcomeScreen() {
|
||||
<View className="flex-row items-center gap-x-3 bg-neutral-900 rounded-2xl p-4 border border-neutral-800">
|
||||
<View
|
||||
className="w-10 h-10 rounded-xl items-center justify-center"
|
||||
style={{ backgroundColor: "#10a37f22" }}
|
||||
style={{ backgroundColor: `${COLORS.codex}22` }}
|
||||
>
|
||||
<MaterialIcons name="auto-awesome" size={20} color="#10a37f" />
|
||||
<MaterialIcons name="auto-awesome" size={20} color={COLORS.codex} />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-white font-semibold mb-0.5">Codex CLI</Text>
|
||||
@@ -50,9 +51,9 @@ export default function WelcomeScreen() {
|
||||
<View className="flex-row items-center gap-x-3 bg-neutral-900 rounded-2xl p-4 border border-neutral-800">
|
||||
<View
|
||||
className="w-10 h-10 rounded-xl items-center justify-center"
|
||||
style={{ backgroundColor: "#d9770622" }}
|
||||
style={{ backgroundColor: `${COLORS.claude}22` }}
|
||||
>
|
||||
<MaterialIcons name="psychology" size={20} color="#d97706" />
|
||||
<MaterialIcons name="psychology" size={20} color={COLORS.claude} />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-white font-semibold mb-0.5">Claude.ai</Text>
|
||||
@@ -68,7 +69,7 @@ export default function WelcomeScreen() {
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push("/onboarding/codex")}
|
||||
className="rounded-2xl py-4 items-center"
|
||||
style={{ backgroundColor: "#10a37f" }}
|
||||
style={{ backgroundColor: COLORS.codex }}
|
||||
>
|
||||
<Text className="text-white font-bold text-base">Get Started</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 1006 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 985 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 985 KiB |
@@ -4,12 +4,32 @@ const ERROR_MESSAGES: Record<string, string> = {
|
||||
TOKEN_EXPIRED: "Your session token has expired. Please reconfigure.",
|
||||
MISSING_ACCESS_TOKEN: "auth.json is missing the accessToken field.",
|
||||
INVALID_JSON: "The selected file is not valid JSON.",
|
||||
DOCUMENT_NOT_READABLE:
|
||||
"The selected file could not be read. Try copying auth.json into Files or Downloads and import it again.",
|
||||
NO_ORGS_FOUND: "No Claude organizations found for this session key.",
|
||||
INVALID_CODEX_RESPONSE:
|
||||
"Codex returned an unexpected response. The app was kept safe from invalid data.",
|
||||
INVALID_CLAUDE_RESPONSE:
|
||||
"Claude returned an unexpected usage response. The app was kept safe from invalid data.",
|
||||
INVALID_CLAUDE_ORGS_RESPONSE:
|
||||
"Claude returned an unexpected organizations response.",
|
||||
UNKNOWN_ERROR: "An unknown error occurred. Try refreshing.",
|
||||
};
|
||||
|
||||
function humanize(code: string): string {
|
||||
if (code.startsWith("HTTP_ERROR_")) {
|
||||
return `Unexpected server error (HTTP ${code.replace("HTTP_ERROR_", "")}).`;
|
||||
const status = code.replace("HTTP_ERROR_", "");
|
||||
if (status === "429") return "Rate limited by the server. Try again shortly.";
|
||||
if (status === "500" || status === "502" || status === "503")
|
||||
return `Server error (${status}). The service may be down.`;
|
||||
return `Unexpected server response (HTTP ${status}).`;
|
||||
}
|
||||
if (
|
||||
code === "Network request failed" ||
|
||||
code.includes("fetch") ||
|
||||
code.includes("network")
|
||||
) {
|
||||
return "Network error. Check your internet connection and try again.";
|
||||
}
|
||||
return ERROR_MESSAGES[code] ?? `Unexpected error: ${code}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Text } from "react-native";
|
||||
import { COUNTDOWN_INTERVAL_MS } from "@/lib/constants";
|
||||
import { formatLastUpdated } from "@/lib/timeUtils";
|
||||
|
||||
export function LastUpdated({ timestamp }: { timestamp: number | null }) {
|
||||
const [now, setNow] = useState(Date.now);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setNow(Date.now()), COUNTDOWN_INTERVAL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (!timestamp) return null;
|
||||
|
||||
return (
|
||||
<Text selectable className="text-xs text-neutral-400 dark:text-neutral-500">
|
||||
{formatLastUpdated(timestamp, now)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { View } from "react-native";
|
||||
import { PROGRESS_THRESHOLDS } from "@/lib/constants";
|
||||
|
||||
interface ProgressBarProps {
|
||||
percent: number;
|
||||
}
|
||||
|
||||
function getBarColor(percent: number): string {
|
||||
if (percent >= 85) return "bg-red-500";
|
||||
if (percent >= 60) return "bg-yellow-400";
|
||||
if (percent >= PROGRESS_THRESHOLDS.danger) return "bg-red-500";
|
||||
if (percent >= PROGRESS_THRESHOLDS.warning) return "bg-yellow-400";
|
||||
return "bg-green-500";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Text } from "react-native";
|
||||
import { COUNTDOWN_INTERVAL_MS } from "@/lib/constants";
|
||||
import { formatResetCountdown, formatResetCountdownISO } from "@/lib/timeUtils";
|
||||
|
||||
interface ResetCountdownProps {
|
||||
@@ -8,18 +9,28 @@ interface ResetCountdownProps {
|
||||
}
|
||||
|
||||
export function ResetCountdown({ resetAtSeconds, resetAtISO }: ResetCountdownProps) {
|
||||
const latest = useRef({ resetAtSeconds, resetAtISO });
|
||||
latest.current = { resetAtSeconds, resetAtISO };
|
||||
|
||||
const getLabel = () => {
|
||||
if (resetAtSeconds !== undefined) return formatResetCountdown(resetAtSeconds);
|
||||
if (resetAtISO) return formatResetCountdownISO(resetAtISO);
|
||||
if (latest.current.resetAtSeconds !== undefined) {
|
||||
return formatResetCountdown(latest.current.resetAtSeconds);
|
||||
}
|
||||
if (latest.current.resetAtISO) return formatResetCountdownISO(latest.current.resetAtISO);
|
||||
return "";
|
||||
};
|
||||
|
||||
const [label, setLabel] = useState(getLabel);
|
||||
|
||||
useEffect(() => {
|
||||
setLabel(getLabel());
|
||||
const interval = setInterval(() => setLabel(getLabel()), 60_000);
|
||||
const update = () => setLabel(getLabel());
|
||||
update();
|
||||
const interval = setInterval(update, COUNTDOWN_INTERVAL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLabel(getLabel());
|
||||
}, [resetAtSeconds, resetAtISO]);
|
||||
|
||||
if (!label) return null;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
import { COLORS } from "@/lib/constants";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
screenName: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ScreenErrorBoundary extends React.Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children;
|
||||
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-neutral-50 dark:bg-neutral-950 px-8">
|
||||
<Text selectable className="text-xl font-bold text-neutral-900 dark:text-white">
|
||||
{this.props.screenName} hit a snag
|
||||
</Text>
|
||||
<Text
|
||||
selectable
|
||||
className="mt-2 text-center text-sm text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
This screen could not be rendered. The rest of Codexbar is still available.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => this.setState({ error: null })}
|
||||
className="mt-5 rounded-xl px-4 py-3"
|
||||
style={{ backgroundColor: COLORS.codex }}
|
||||
>
|
||||
<Text className="text-sm font-semibold text-white">Try again</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,52 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { fetchClaudeOrgs, fetchClaudeUsage } from "@/lib/api/claudeApi";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
fetchClaudeOrgs,
|
||||
fetchClaudeUsage,
|
||||
parseClaudeUsageResponse,
|
||||
} from "@/lib/api/claudeApi";
|
||||
import {
|
||||
isClaudeSessionKeyValid,
|
||||
validateClaudeSessionKey,
|
||||
} from "@/lib/claudeCredentials";
|
||||
import { RETRY_DELAY_MS } from "@/lib/constants";
|
||||
import {
|
||||
loadClaudeSessionKey,
|
||||
saveClaudeSessionKey,
|
||||
clearClaudeSessionKey,
|
||||
loadClaudeLastActiveOrg,
|
||||
saveClaudeLastActiveOrg,
|
||||
clearClaudeLastActiveOrg,
|
||||
clearClaudeSessionKey,
|
||||
clearClaudeUsageCache,
|
||||
loadClaudeLastActiveOrg,
|
||||
loadClaudeSessionKey,
|
||||
loadClaudeUsageCache,
|
||||
saveClaudeLastActiveOrg,
|
||||
saveClaudeSessionKey,
|
||||
saveClaudeUsageCache,
|
||||
} from "@/lib/storage";
|
||||
import type { ClaudeAuth, ClaudeUsageResponse } from "@/types/claude";
|
||||
|
||||
type Status = "idle" | "loading" | "success" | "error";
|
||||
|
||||
async function doFetch(
|
||||
auth: ClaudeAuth,
|
||||
set: {
|
||||
auth: (v: ClaudeAuth | null) => void;
|
||||
usage: (v: ClaudeUsageResponse | null) => void;
|
||||
status: (v: Status) => void;
|
||||
error: (v: string | null) => void;
|
||||
function shouldRetry(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
return ![
|
||||
"TOKEN_EXPIRED",
|
||||
"NO_ORGS_FOUND",
|
||||
"INVALID_CLAUDE_RESPONSE",
|
||||
"INVALID_CLAUDE_ORGS_RESPONSE",
|
||||
].includes(message);
|
||||
}
|
||||
) {
|
||||
set.status("loading");
|
||||
set.error(null);
|
||||
try {
|
||||
let orgUuid = auth.lastActiveOrg;
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function requestClaudeUsage(auth: ClaudeAuth): Promise<ClaudeUsageResponse> {
|
||||
let orgUuid = auth.lastActiveOrg?.trim();
|
||||
if (!orgUuid) {
|
||||
const orgs = await fetchClaudeOrgs(auth);
|
||||
if (!orgs.length) throw new Error("NO_ORGS_FOUND");
|
||||
orgUuid = orgs[0].uuid;
|
||||
await saveClaudeLastActiveOrg(orgUuid);
|
||||
}
|
||||
const data = await fetchClaudeUsage(auth, orgUuid);
|
||||
set.usage(data);
|
||||
set.status("success");
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
|
||||
set.error(msg);
|
||||
set.status("error");
|
||||
if (msg === "TOKEN_EXPIRED") {
|
||||
await clearClaudeSessionKey();
|
||||
set.auth(null);
|
||||
}
|
||||
}
|
||||
return fetchClaudeUsage(auth, orgUuid);
|
||||
}
|
||||
|
||||
export function useClaudeUsage() {
|
||||
@@ -49,60 +54,171 @@ export function useClaudeUsage() {
|
||||
const [pendingKey, setPendingKey] = useState("");
|
||||
const [pendingOrg, setPendingOrg] = useState("");
|
||||
const [usage, setUsage] = useState<ClaudeUsageResponse | null>(null);
|
||||
const [lastFetchedAt, setLastFetchedAt] = useState<number | null>(null);
|
||||
const [status, setStatus] = useState<Status>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const setters = { auth: setAuth, usage: setUsage, status: setStatus, error: setError };
|
||||
const mounted = useRef(true);
|
||||
const inFlight = useRef<{
|
||||
key: string;
|
||||
promise: Promise<void>;
|
||||
} | null>(null);
|
||||
const requestVersion = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([loadClaudeSessionKey(), loadClaudeLastActiveOrg()]).then(
|
||||
([key, org]) => {
|
||||
if (key) setAuth({ sessionKey: key, lastActiveOrg: org ?? undefined });
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchUsage = useCallback((credentials: ClaudeAuth): Promise<void> => {
|
||||
const sanitized = {
|
||||
sessionKey: credentials.sessionKey.trim(),
|
||||
lastActiveOrg: credentials.lastActiveOrg?.trim() || undefined,
|
||||
};
|
||||
const requestKey = `${sanitized.lastActiveOrg ?? ""}:${sanitized.sessionKey}`;
|
||||
if (inFlight.current?.key === requestKey) return inFlight.current.promise;
|
||||
const version = ++requestVersion.current;
|
||||
|
||||
const promise = (async () => {
|
||||
if (mounted.current) {
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
let data: ClaudeUsageResponse;
|
||||
try {
|
||||
data = await requestClaudeUsage(sanitized);
|
||||
} catch (firstError) {
|
||||
if (!shouldRetry(firstError)) throw firstError;
|
||||
await wait(RETRY_DELAY_MS);
|
||||
if (version !== requestVersion.current) return;
|
||||
data = await requestClaudeUsage(sanitized);
|
||||
}
|
||||
|
||||
if (version !== requestVersion.current) return;
|
||||
const fetchedAt = Date.now();
|
||||
await saveClaudeUsageCache({ data, lastFetchedAt: fetchedAt });
|
||||
if (mounted.current && version === requestVersion.current) {
|
||||
setUsage(data);
|
||||
setLastFetchedAt(fetchedAt);
|
||||
setStatus("success");
|
||||
}
|
||||
} catch (caught: unknown) {
|
||||
if (version !== requestVersion.current) return;
|
||||
const message = caught instanceof Error ? caught.message : "UNKNOWN_ERROR";
|
||||
if (message === "TOKEN_EXPIRED") {
|
||||
await clearClaudeSessionKey();
|
||||
if (mounted.current) {
|
||||
setAuth(null);
|
||||
setUsage(null);
|
||||
setLastFetchedAt(null);
|
||||
}
|
||||
}
|
||||
if (mounted.current) {
|
||||
setError(message);
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
})().finally(() => {
|
||||
if (inFlight.current?.promise === promise) inFlight.current = null;
|
||||
});
|
||||
|
||||
inFlight.current = { key: requestKey, promise };
|
||||
return promise;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth) void doFetch(auth, setters);
|
||||
}, [auth]);
|
||||
let active = true;
|
||||
|
||||
void Promise.all([
|
||||
loadClaudeSessionKey(),
|
||||
loadClaudeLastActiveOrg(),
|
||||
loadClaudeUsageCache(),
|
||||
]).then(async ([key, org, cache]) => {
|
||||
if (!active) return;
|
||||
|
||||
const storedAuth = key
|
||||
? { sessionKey: key.trim(), lastActiveOrg: org?.trim() || undefined }
|
||||
: null;
|
||||
|
||||
if (storedAuth && cache) {
|
||||
try {
|
||||
if (!Number.isFinite(cache.lastFetchedAt)) {
|
||||
throw new Error("INVALID_CACHE_TIMESTAMP");
|
||||
}
|
||||
const cachedUsage = parseClaudeUsageResponse(cache.data);
|
||||
setUsage(cachedUsage);
|
||||
setLastFetchedAt(cache.lastFetchedAt);
|
||||
setStatus("success");
|
||||
} catch {
|
||||
await clearClaudeUsageCache();
|
||||
}
|
||||
}
|
||||
|
||||
setAuth(storedAuth);
|
||||
if (storedAuth) await fetchUsage(storedAuth);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [fetchUsage]);
|
||||
|
||||
const saveKey = useCallback(async () => {
|
||||
const trimmed = pendingKey.trim();
|
||||
if (!trimmed.startsWith("sk-ant-")) {
|
||||
setError("Session key must start with sk-ant-");
|
||||
const trimmedKey = pendingKey.trim();
|
||||
const validationError = validateClaudeSessionKey(trimmedKey);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
const orgTrimmed = pendingOrg.trim() || undefined;
|
||||
await saveClaudeSessionKey(trimmed);
|
||||
if (orgTrimmed) await saveClaudeLastActiveOrg(orgTrimmed);
|
||||
|
||||
const trimmedOrg = pendingOrg.trim() || undefined;
|
||||
await saveClaudeSessionKey(trimmedKey);
|
||||
if (trimmedOrg) await saveClaudeLastActiveOrg(trimmedOrg);
|
||||
else await clearClaudeLastActiveOrg();
|
||||
setAuth({ sessionKey: trimmed, lastActiveOrg: orgTrimmed });
|
||||
|
||||
const nextAuth = { sessionKey: trimmedKey, lastActiveOrg: trimmedOrg };
|
||||
setAuth(nextAuth);
|
||||
setPendingKey("");
|
||||
setPendingOrg("");
|
||||
setError(null);
|
||||
}, [pendingKey, pendingOrg]);
|
||||
await fetchUsage(nextAuth);
|
||||
}, [fetchUsage, pendingKey, pendingOrg]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!auth) return;
|
||||
await doFetch(auth, setters);
|
||||
}, [auth]);
|
||||
if (auth) await fetchUsage(auth);
|
||||
}, [auth, fetchUsage]);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const [key, org] = await Promise.all([loadClaudeSessionKey(), loadClaudeLastActiveOrg()]);
|
||||
const reloadCredentials = useCallback(async () => {
|
||||
const [key, org] = await Promise.all([
|
||||
loadClaudeSessionKey(),
|
||||
loadClaudeLastActiveOrg(),
|
||||
]);
|
||||
if (!key) {
|
||||
requestVersion.current += 1;
|
||||
setAuth(null);
|
||||
setUsage(null);
|
||||
setLastFetchedAt(null);
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
// Creating a new object always triggers the [auth] useEffect even if values are identical
|
||||
setAuth({ sessionKey: key, lastActiveOrg: org ?? undefined });
|
||||
}, []);
|
||||
|
||||
const stored = {
|
||||
sessionKey: key.trim(),
|
||||
lastActiveOrg: org?.trim() || undefined,
|
||||
};
|
||||
setAuth(stored);
|
||||
await fetchUsage(stored);
|
||||
}, [fetchUsage]);
|
||||
|
||||
const clearKey = useCallback(async () => {
|
||||
requestVersion.current += 1;
|
||||
await clearClaudeSessionKey();
|
||||
setAuth(null);
|
||||
setUsage(null);
|
||||
setLastFetchedAt(null);
|
||||
setStatus("idle");
|
||||
setError(null);
|
||||
}, []);
|
||||
@@ -114,11 +230,16 @@ export function useClaudeUsage() {
|
||||
pendingOrg,
|
||||
setPendingOrg,
|
||||
usage,
|
||||
lastFetchedAt,
|
||||
status,
|
||||
error,
|
||||
keyValidationError: pendingKey
|
||||
? validateClaudeSessionKey(pendingKey)
|
||||
: null,
|
||||
canSaveKey: isClaudeSessionKeyValid(pendingKey),
|
||||
saveKey,
|
||||
refresh,
|
||||
reload,
|
||||
reloadCredentials,
|
||||
clearKey,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,54 +1,127 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { fetchCodexUsage } from "@/lib/api/codexApi";
|
||||
import { loadCodexAuth, saveCodexAuth, clearCodexAuth } from "@/lib/storage";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
fetchCodexUsage,
|
||||
parseCodexUsageResponse,
|
||||
} from "@/lib/api/codexApi";
|
||||
import { RETRY_DELAY_MS } from "@/lib/constants";
|
||||
import { pickAndReadCodexAuth } from "@/lib/fileReader";
|
||||
import type { CodexUsageResponse, CodexAuth } from "@/types/codex";
|
||||
import {
|
||||
clearCodexAuth,
|
||||
clearCodexUsageCache,
|
||||
loadCodexAuth,
|
||||
loadCodexUsageCache,
|
||||
saveCodexAuth,
|
||||
saveCodexUsageCache,
|
||||
} from "@/lib/storage";
|
||||
import type { CodexAuth, CodexUsageResponse } from "@/types/codex";
|
||||
|
||||
type Status = "idle" | "loading" | "success" | "error";
|
||||
|
||||
async function doFetch(
|
||||
auth: CodexAuth,
|
||||
set: {
|
||||
auth: (v: CodexAuth | null) => void;
|
||||
usage: (v: CodexUsageResponse | null) => void;
|
||||
status: (v: Status) => void;
|
||||
error: (v: string | null) => void;
|
||||
}
|
||||
) {
|
||||
set.status("loading");
|
||||
set.error(null);
|
||||
try {
|
||||
const data = await fetchCodexUsage(auth);
|
||||
set.usage(data);
|
||||
set.status("success");
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
|
||||
set.error(msg);
|
||||
set.status("error");
|
||||
if (msg === "TOKEN_EXPIRED") {
|
||||
await clearCodexAuth();
|
||||
set.auth(null);
|
||||
}
|
||||
function shouldRetry(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
return message !== "TOKEN_EXPIRED" && message !== "INVALID_CODEX_RESPONSE";
|
||||
}
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function useCodexUsage() {
|
||||
const [auth, setAuth] = useState<CodexAuth | null>(null);
|
||||
const [usage, setUsage] = useState<CodexUsageResponse | null>(null);
|
||||
const [lastFetchedAt, setLastFetchedAt] = useState<number | null>(null);
|
||||
const [status, setStatus] = useState<Status>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const setters = { auth: setAuth, usage: setUsage, status: setStatus, error: setError };
|
||||
const mounted = useRef(true);
|
||||
const inFlight = useRef<{
|
||||
key: string;
|
||||
promise: Promise<void>;
|
||||
} | null>(null);
|
||||
const requestVersion = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
loadCodexAuth().then((stored) => {
|
||||
if (stored) setAuth(stored);
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchUsage = useCallback((credentials: CodexAuth): Promise<void> => {
|
||||
const requestKey = `${credentials.accountId ?? ""}:${credentials.accessToken}`;
|
||||
if (inFlight.current?.key === requestKey) return inFlight.current.promise;
|
||||
const version = ++requestVersion.current;
|
||||
|
||||
const promise = (async () => {
|
||||
if (mounted.current) {
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
}
|
||||
|
||||
try {
|
||||
let data: CodexUsageResponse;
|
||||
try {
|
||||
data = await fetchCodexUsage(credentials);
|
||||
} catch (firstError) {
|
||||
if (!shouldRetry(firstError)) throw firstError;
|
||||
await wait(RETRY_DELAY_MS);
|
||||
if (version !== requestVersion.current) return;
|
||||
data = await fetchCodexUsage(credentials);
|
||||
}
|
||||
|
||||
if (version !== requestVersion.current) return;
|
||||
const fetchedAt = Date.now();
|
||||
await saveCodexUsageCache({ data, lastFetchedAt: fetchedAt });
|
||||
if (mounted.current && version === requestVersion.current) {
|
||||
setUsage(data);
|
||||
setLastFetchedAt(fetchedAt);
|
||||
setStatus("success");
|
||||
}
|
||||
} catch (caught: unknown) {
|
||||
if (version !== requestVersion.current) return;
|
||||
const message = caught instanceof Error ? caught.message : "UNKNOWN_ERROR";
|
||||
if (mounted.current) {
|
||||
setError(message);
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
})().finally(() => {
|
||||
if (inFlight.current?.promise === promise) inFlight.current = null;
|
||||
});
|
||||
|
||||
inFlight.current = { key: requestKey, promise };
|
||||
return promise;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth) void doFetch(auth, setters);
|
||||
}, [auth]);
|
||||
let active = true;
|
||||
|
||||
void Promise.all([loadCodexAuth(), loadCodexUsageCache()]).then(
|
||||
async ([storedAuth, cache]) => {
|
||||
if (!active) return;
|
||||
|
||||
if (storedAuth && cache) {
|
||||
try {
|
||||
if (!Number.isFinite(cache.lastFetchedAt)) {
|
||||
throw new Error("INVALID_CACHE_TIMESTAMP");
|
||||
}
|
||||
const cachedUsage = parseCodexUsageResponse(cache.data);
|
||||
setUsage(cachedUsage);
|
||||
setLastFetchedAt(cache.lastFetchedAt);
|
||||
setStatus("success");
|
||||
} catch {
|
||||
await clearCodexUsageCache();
|
||||
}
|
||||
}
|
||||
|
||||
setAuth(storedAuth);
|
||||
if (storedAuth) await fetchUsage(storedAuth);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [fetchUsage]);
|
||||
|
||||
const importAuthFile = useCallback(async () => {
|
||||
try {
|
||||
@@ -56,39 +129,50 @@ export function useCodexUsage() {
|
||||
await saveCodexAuth(parsed);
|
||||
setAuth(parsed);
|
||||
setError(null);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
|
||||
if (msg !== "PICKER_CANCELLED") setError(msg);
|
||||
await fetchUsage(parsed);
|
||||
} catch (caught: unknown) {
|
||||
const message = caught instanceof Error ? caught.message : "UNKNOWN_ERROR";
|
||||
if (message !== "PICKER_CANCELLED") setError(message);
|
||||
}
|
||||
}, []);
|
||||
}, [fetchUsage]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!auth) return;
|
||||
await doFetch(auth, setters);
|
||||
}, [auth]);
|
||||
if (auth) await fetchUsage(auth);
|
||||
}, [auth, fetchUsage]);
|
||||
|
||||
// Re-reads auth from storage and fetches fresh data. Call from useFocusEffect
|
||||
// so the dashboard stays current when credentials are saved from another tab.
|
||||
const reload = useCallback(async () => {
|
||||
const reloadCredentials = useCallback(async () => {
|
||||
const stored = await loadCodexAuth();
|
||||
if (!stored) {
|
||||
requestVersion.current += 1;
|
||||
setAuth(null);
|
||||
setUsage(null);
|
||||
setLastFetchedAt(null);
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
// Always pass a fresh object so the [auth] effect fires even if the content
|
||||
// is identical (JSON.parse returns a new reference each time, which is fine).
|
||||
setAuth(stored);
|
||||
}, []);
|
||||
await fetchUsage(stored);
|
||||
}, [fetchUsage]);
|
||||
|
||||
const clearAuth = useCallback(async () => {
|
||||
requestVersion.current += 1;
|
||||
await clearCodexAuth();
|
||||
setAuth(null);
|
||||
setUsage(null);
|
||||
setLastFetchedAt(null);
|
||||
setStatus("idle");
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return { auth, usage, status, error, importAuthFile, refresh, reload, clearAuth };
|
||||
return {
|
||||
auth,
|
||||
usage,
|
||||
lastFetchedAt,
|
||||
status,
|
||||
error,
|
||||
importAuthFile,
|
||||
refresh,
|
||||
reloadCredentials,
|
||||
clearAuth,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,6 +19,78 @@ function buildHeaders(auth: ClaudeAuth): Record<string, string> {
|
||||
};
|
||||
}
|
||||
|
||||
function isClaudeWindow(value: unknown): boolean {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const window = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof window.utilization === "number" &&
|
||||
Number.isFinite(window.utilization) &&
|
||||
(window.resets_at === undefined ||
|
||||
window.resets_at === null ||
|
||||
typeof window.resets_at === "string")
|
||||
);
|
||||
}
|
||||
|
||||
export function parseClaudeOrgsResponse(data: unknown): ClaudeOrg[] {
|
||||
if (
|
||||
!Array.isArray(data) ||
|
||||
data.some(
|
||||
(org) =>
|
||||
!org ||
|
||||
typeof org !== "object" ||
|
||||
typeof (org as Record<string, unknown>).uuid !== "string" ||
|
||||
typeof (org as Record<string, unknown>).name !== "string"
|
||||
)
|
||||
) {
|
||||
throw new Error("INVALID_CLAUDE_ORGS_RESPONSE");
|
||||
}
|
||||
return data.map((org) => {
|
||||
const value = org as Record<string, unknown>;
|
||||
return { uuid: value.uuid as string, name: value.name as string };
|
||||
});
|
||||
}
|
||||
|
||||
export function parseClaudeUsageResponse(data: unknown): ClaudeUsageResponse {
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new Error("INVALID_CLAUDE_RESPONSE");
|
||||
}
|
||||
|
||||
const value = data as Record<string, unknown>;
|
||||
if (
|
||||
!isClaudeWindow(value.five_hour) ||
|
||||
!isClaudeWindow(value.seven_day) ||
|
||||
(value.seven_day_sonnet !== undefined &&
|
||||
value.seven_day_sonnet !== null &&
|
||||
!isClaudeWindow(value.seven_day_sonnet)) ||
|
||||
(value.seven_day_opus !== undefined &&
|
||||
value.seven_day_opus !== null &&
|
||||
!isClaudeWindow(value.seven_day_opus))
|
||||
) {
|
||||
throw new Error("INVALID_CLAUDE_RESPONSE");
|
||||
}
|
||||
|
||||
const toWindow = (window: unknown) => {
|
||||
const item = window as Record<string, unknown>;
|
||||
return {
|
||||
utilization: item.utilization as number,
|
||||
...(typeof item.resets_at === "string"
|
||||
? { resets_at: item.resets_at }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
five_hour: toWindow(value.five_hour),
|
||||
seven_day: toWindow(value.seven_day),
|
||||
...(value.seven_day_sonnet
|
||||
? { seven_day_sonnet: toWindow(value.seven_day_sonnet) }
|
||||
: {}),
|
||||
...(value.seven_day_opus
|
||||
? { seven_day_opus: toWindow(value.seven_day_opus) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchClaudeOrgs(auth: ClaudeAuth): Promise<ClaudeOrg[]> {
|
||||
const response = await fetch(`${BASE_URL}/organizations`, {
|
||||
headers: buildHeaders(auth),
|
||||
@@ -31,7 +103,8 @@ export async function fetchClaudeOrgs(auth: ClaudeAuth): Promise<ClaudeOrg[]> {
|
||||
throw new Error(`HTTP_ERROR_${response.status}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<ClaudeOrg[]>;
|
||||
const data: unknown = await response.json();
|
||||
return parseClaudeOrgsResponse(data);
|
||||
}
|
||||
|
||||
export async function fetchClaudeUsage(
|
||||
@@ -49,5 +122,6 @@ export async function fetchClaudeUsage(
|
||||
throw new Error(`HTTP_ERROR_${response.status}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<ClaudeUsageResponse>;
|
||||
const data: unknown = await response.json();
|
||||
return parseClaudeUsageResponse(data);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,86 @@
|
||||
import type { CodexAuth, CodexUsageResponse } from "@/types/codex";
|
||||
|
||||
const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
||||
const RESET_CREDITS_URL =
|
||||
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
|
||||
|
||||
export async function fetchCodexUsage(auth: CodexAuth): Promise<CodexUsageResponse> {
|
||||
interface RawUsageWindow {
|
||||
used_percent?: number;
|
||||
limit_window_seconds?: number;
|
||||
reset_at?: number;
|
||||
}
|
||||
|
||||
interface RawUsageResponse {
|
||||
plan_type?: string | null;
|
||||
rate_limit?: {
|
||||
primary_window?: RawUsageWindow | null;
|
||||
secondary_window?: RawUsageWindow | null;
|
||||
} | null;
|
||||
credits?: {
|
||||
has_credits?: boolean;
|
||||
unlimited?: boolean;
|
||||
balance?: string | number;
|
||||
} | null;
|
||||
rate_limit_reached_type?: string | null;
|
||||
rate_limit_reset_credits?: {
|
||||
available_count?: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface RawResetCredit {
|
||||
status?: string;
|
||||
granted_at?: string | null;
|
||||
expires_at?: string | null;
|
||||
}
|
||||
|
||||
interface RawResetCreditsResponse {
|
||||
available_count?: number | null;
|
||||
total_earned_count?: number | null;
|
||||
credits?: RawResetCredit[] | null;
|
||||
}
|
||||
|
||||
function isNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function formatDurationUntil(isoString: string | null | undefined): string | null {
|
||||
if (!isoString) return null;
|
||||
|
||||
const targetMs = new Date(isoString).getTime();
|
||||
if (Number.isNaN(targetMs)) return null;
|
||||
|
||||
let remainingSeconds = Math.max(Math.round((targetMs - Date.now()) / 1000), 0);
|
||||
const days = Math.floor(remainingSeconds / 86400);
|
||||
remainingSeconds -= days * 86400;
|
||||
const hours = Math.floor(remainingSeconds / 3600);
|
||||
remainingSeconds -= hours * 3600;
|
||||
const minutes = Math.floor(remainingSeconds / 60);
|
||||
remainingSeconds -= minutes * 60;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
if (remainingSeconds > 0 || parts.length === 0) parts.push(`${remainingSeconds}s`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function normalizeWindow(window: RawUsageWindow | null | undefined) {
|
||||
if (!window) return null;
|
||||
|
||||
const windowSeconds =
|
||||
typeof window.limit_window_seconds === "number" ? window.limit_window_seconds : 0;
|
||||
const resetsAt = typeof window.reset_at === "number" ? window.reset_at : 0;
|
||||
|
||||
return {
|
||||
usedPercent: typeof window.used_percent === "number" ? window.used_percent : 0,
|
||||
resetsAt,
|
||||
windowMinutes: Math.round(windowSeconds / 60),
|
||||
windowSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBaseHeaders(auth: CodexAuth): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${auth.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
@@ -12,6 +90,103 @@ export async function fetchCodexUsage(auth: CodexAuth): Promise<CodexUsageRespon
|
||||
headers["ChatGPT-Account-Id"] = auth.accountId;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function fetchResetCoupons(auth: CodexAuth, usage: RawUsageResponse) {
|
||||
const headers = {
|
||||
...buildBaseHeaders(auth),
|
||||
"OpenAI-Beta": "codex-1",
|
||||
originator: "Codex Desktop",
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(RESET_CREDITS_URL, { headers });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP_ERROR_${response.status}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as RawResetCreditsResponse;
|
||||
const credits = Array.isArray(payload.credits) ? payload.credits : [];
|
||||
const normalizedCredits = credits
|
||||
.map((credit, index) => ({
|
||||
index: index + 1,
|
||||
status: credit.status,
|
||||
grantedAt: credit.granted_at ?? null,
|
||||
grantedAtLocal: credit.granted_at
|
||||
? new Date(credit.granted_at).toLocaleString()
|
||||
: null,
|
||||
expiresAt: credit.expires_at ?? null,
|
||||
expiresAtLocal: credit.expires_at
|
||||
? new Date(credit.expires_at).toLocaleString()
|
||||
: null,
|
||||
timeUntilExpiry: formatDurationUntil(credit.expires_at),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const left = a.expiresAt ? new Date(a.expiresAt).getTime() : Number.MAX_SAFE_INTEGER;
|
||||
const right = b.expiresAt ? new Date(b.expiresAt).getTime() : Number.MAX_SAFE_INTEGER;
|
||||
return left - right;
|
||||
});
|
||||
|
||||
const availableCredits = normalizedCredits.filter(
|
||||
(credit) => credit.status === "available"
|
||||
);
|
||||
|
||||
return {
|
||||
source: "live_api" as const,
|
||||
sourceDescription: "Live Codex reset-credit endpoint",
|
||||
availableCount:
|
||||
payload.available_count ?? usage.rate_limit_reset_credits?.available_count ?? null,
|
||||
totalEarnedCount: payload.total_earned_count ?? null,
|
||||
credits: normalizedCredits,
|
||||
nextExpiringCredit: availableCredits[0] ?? normalizedCredits[0] ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return {
|
||||
source: "unavailable" as const,
|
||||
sourceDescription: "Reset-credit endpoint unavailable",
|
||||
availableCount: usage.rate_limit_reset_credits?.available_count ?? null,
|
||||
fallbackReason: message,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCodexUsageResponse(data: unknown): CodexUsageResponse {
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new Error("INVALID_CODEX_RESPONSE");
|
||||
}
|
||||
|
||||
const value = data as Record<string, unknown>;
|
||||
const primary = value.primary as Record<string, unknown> | null | undefined;
|
||||
const secondary = value.secondary as Record<string, unknown> | null | undefined;
|
||||
const credits = value.credits as Record<string, unknown> | undefined;
|
||||
|
||||
const validWindow = (window: Record<string, unknown> | null | undefined) =>
|
||||
window === null ||
|
||||
window === undefined ||
|
||||
(isNumber(window.usedPercent) &&
|
||||
isNumber(window.resetsAt) &&
|
||||
isNumber(window.windowMinutes) &&
|
||||
isNumber(window.windowSeconds));
|
||||
|
||||
if (
|
||||
!validWindow(primary) ||
|
||||
!validWindow(secondary) ||
|
||||
!credits ||
|
||||
typeof credits.hasCredits !== "boolean" ||
|
||||
typeof credits.unlimited !== "boolean" ||
|
||||
!isNumber(credits.balance)
|
||||
) {
|
||||
throw new Error("INVALID_CODEX_RESPONSE");
|
||||
}
|
||||
|
||||
return value as unknown as CodexUsageResponse;
|
||||
}
|
||||
|
||||
export async function fetchCodexUsage(auth: CodexAuth): Promise<CodexUsageResponse> {
|
||||
const headers = buildBaseHeaders(auth);
|
||||
const response = await fetch(USAGE_URL, { headers });
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
@@ -22,5 +197,19 @@ export async function fetchCodexUsage(auth: CodexAuth): Promise<CodexUsageRespon
|
||||
throw new Error(`HTTP_ERROR_${response.status}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<CodexUsageResponse>;
|
||||
const payload = (await response.json()) as RawUsageResponse;
|
||||
const resetCoupons = await fetchResetCoupons(auth, payload);
|
||||
|
||||
return {
|
||||
planType: payload.plan_type ?? null,
|
||||
rateLimitReachedType: payload.rate_limit_reached_type ?? null,
|
||||
primary: normalizeWindow(payload.rate_limit?.primary_window),
|
||||
secondary: normalizeWindow(payload.rate_limit?.secondary_window),
|
||||
credits: {
|
||||
hasCredits: Boolean(payload.credits?.has_credits),
|
||||
unlimited: Boolean(payload.credits?.unlimited),
|
||||
balance: Number(payload.credits?.balance ?? 0),
|
||||
},
|
||||
resetCoupons,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
const CLAUDE_SESSION_KEY_PATTERN = /^sk-ant-[A-Za-z0-9_-]+$/;
|
||||
|
||||
export function validateClaudeSessionKey(value: string): string | null {
|
||||
const key = value.trim();
|
||||
if (!key.startsWith("sk-ant-")) {
|
||||
return "Session key must start with sk-ant-";
|
||||
}
|
||||
if (key.length <= 40) {
|
||||
return "Session key looks too short. Paste the complete cookie value.";
|
||||
}
|
||||
if (!CLAUDE_SESSION_KEY_PATTERN.test(key)) {
|
||||
return "Session key contains unexpected characters. Check the pasted value.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isClaudeSessionKeyValid(value: string): boolean {
|
||||
return validateClaudeSessionKey(value) === null;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export const COLORS = {
|
||||
codex: "#10a37f",
|
||||
claude: "#d97706",
|
||||
danger: "#ef4444",
|
||||
warning: "#f59e0b",
|
||||
disabled: "#e5e5e5",
|
||||
muted: "#a3a3a3",
|
||||
appBackground: "#090d11",
|
||||
} as const;
|
||||
|
||||
export const PROGRESS_THRESHOLDS = {
|
||||
warning: 60,
|
||||
danger: 85,
|
||||
} as const;
|
||||
|
||||
export const COUNTDOWN_INTERVAL_MS = 60_000;
|
||||
export const RETRY_DELAY_MS = 5_000;
|
||||
|
||||
export function getUsageColor(percent: number): string {
|
||||
if (percent >= PROGRESS_THRESHOLDS.danger) return COLORS.danger;
|
||||
if (percent >= PROGRESS_THRESHOLDS.warning) return COLORS.warning;
|
||||
return COLORS.codex;
|
||||
}
|
||||
@@ -1,6 +1,41 @@
|
||||
import * as DocumentPicker from "expo-document-picker";
|
||||
import * as LegacyFileSystem from "expo-file-system/legacy";
|
||||
import type { CodexAuth } from "@/types/codex";
|
||||
|
||||
function sanitizeFileName(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
}
|
||||
|
||||
async function readPickedTextFile(uri: string, fileName?: string): Promise<string> {
|
||||
try {
|
||||
const response = await fetch(uri);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP_ERROR_${response.status}`);
|
||||
}
|
||||
return await response.text();
|
||||
} catch {
|
||||
try {
|
||||
return await LegacyFileSystem.readAsStringAsync(uri);
|
||||
} catch {
|
||||
const cacheDirectory = LegacyFileSystem.cacheDirectory;
|
||||
if (!cacheDirectory) {
|
||||
throw new Error("DOCUMENT_NOT_READABLE");
|
||||
}
|
||||
|
||||
const destination = `${cacheDirectory}${Date.now()}-${sanitizeFileName(
|
||||
fileName ?? "import.json"
|
||||
)}`;
|
||||
|
||||
try {
|
||||
await LegacyFileSystem.copyAsync({ from: uri, to: destination });
|
||||
return await LegacyFileSystem.readAsStringAsync(destination);
|
||||
} catch {
|
||||
throw new Error("DOCUMENT_NOT_READABLE");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function pickAndReadCodexAuth(): Promise<CodexAuth> {
|
||||
const result = await DocumentPicker.getDocumentAsync({
|
||||
type: "application/json",
|
||||
@@ -9,9 +44,8 @@ export async function pickAndReadCodexAuth(): Promise<CodexAuth> {
|
||||
|
||||
if (result.canceled) throw new Error("PICKER_CANCELLED");
|
||||
|
||||
const { uri } = result.assets[0];
|
||||
const response = await fetch(uri);
|
||||
const text = await response.text();
|
||||
const { uri, name } = result.assets[0];
|
||||
const text = await readPickedTextFile(uri, name);
|
||||
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
@@ -22,7 +56,11 @@ export async function pickAndReadCodexAuth(): Promise<CodexAuth> {
|
||||
|
||||
const tokens = parsed["tokens"] as Record<string, unknown> | undefined;
|
||||
const accessToken = (tokens?.["access_token"] ?? parsed["accessToken"]) as string | undefined;
|
||||
const accountId = (tokens?.["account_id"] ?? parsed["accountId"]) as string | undefined;
|
||||
const accountId = (
|
||||
tokens?.["account_id"] ??
|
||||
parsed["accountId"] ??
|
||||
parsed["account_id"]
|
||||
) as string | undefined;
|
||||
|
||||
if (!accessToken || typeof accessToken !== "string") {
|
||||
throw new Error("MISSING_ACCESS_TOKEN");
|
||||
|
||||
@@ -56,7 +56,7 @@ export async function scheduleDailyDigest(
|
||||
}
|
||||
if (codexUsage) {
|
||||
parts.push(
|
||||
`Codex: ${Math.round(codexUsage.rate_limit.secondary_window.used_percent)}% used`
|
||||
`Codex: ${Math.round(codexUsage.secondary?.usedPercent ?? 0)}% used`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ function buildThresholdAlerts(
|
||||
}
|
||||
|
||||
if (codexUsage) {
|
||||
const remaining = 100 - codexUsage.rate_limit.secondary_window.used_percent;
|
||||
const remaining = 100 - (codexUsage.secondary?.usedPercent ?? 0);
|
||||
if (remaining <= thresholdPct) {
|
||||
alerts.push({ service: "Codex", window: "weekly", remaining });
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import type { ClaudeUsageResponse } from "@/types/claude";
|
||||
import type { CodexUsageResponse } from "@/types/codex";
|
||||
|
||||
const KEYS = {
|
||||
CODEX_AUTH: "codexbar_codex_auth",
|
||||
CLAUDE_SESSION_KEY: "codexbar_claude_session_key",
|
||||
CLAUDE_LAST_ACTIVE_ORG: "codexbar_claude_last_active_org",
|
||||
CODEX_USAGE_CACHE: "codexbar_codex_usage_cache",
|
||||
CLAUDE_USAGE_CACHE: "codexbar_claude_usage_cache",
|
||||
NOTIF_DAILY_ENABLED: "notifDailyEnabled",
|
||||
NOTIF_DAILY_HOUR: "notifDailyHour",
|
||||
NOTIF_DAILY_MINUTE: "notifDailyMinute",
|
||||
@@ -12,6 +16,27 @@ const KEYS = {
|
||||
NOTIF_THRESHOLD_LAST_FIRED: "notifThresholdLastFired",
|
||||
} as const;
|
||||
|
||||
export interface UsageCache<T> {
|
||||
data: T;
|
||||
lastFetchedAt: number;
|
||||
}
|
||||
|
||||
async function loadJson<T>(key: string): Promise<T | null> {
|
||||
const raw = await SecureStore.getItemAsync(key);
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveJson(key: string, value: unknown): Promise<void> {
|
||||
await SecureStore.setItemAsync(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
export interface NotifSettings {
|
||||
dailyEnabled: boolean;
|
||||
dailyHour: number;
|
||||
@@ -60,20 +85,46 @@ export async function saveCodexAuth(auth: {
|
||||
accessToken: string;
|
||||
accountId?: string;
|
||||
}): Promise<void> {
|
||||
await SecureStore.setItemAsync(KEYS.CODEX_AUTH, JSON.stringify(auth));
|
||||
await saveJson(KEYS.CODEX_AUTH, auth);
|
||||
}
|
||||
|
||||
export async function loadCodexAuth(): Promise<{
|
||||
accessToken: string;
|
||||
accountId?: string;
|
||||
} | null> {
|
||||
const raw = await SecureStore.getItemAsync(KEYS.CODEX_AUTH);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as { accessToken: string; accountId?: string };
|
||||
return loadJson(KEYS.CODEX_AUTH);
|
||||
}
|
||||
|
||||
export function loadCodexUsageCache(): Promise<UsageCache<CodexUsageResponse> | null> {
|
||||
return loadJson(KEYS.CODEX_USAGE_CACHE);
|
||||
}
|
||||
|
||||
export function saveCodexUsageCache(
|
||||
cache: UsageCache<CodexUsageResponse>
|
||||
): Promise<void> {
|
||||
return saveJson(KEYS.CODEX_USAGE_CACHE, cache);
|
||||
}
|
||||
|
||||
export function clearCodexUsageCache(): Promise<void> {
|
||||
return SecureStore.deleteItemAsync(KEYS.CODEX_USAGE_CACHE);
|
||||
}
|
||||
|
||||
export function loadClaudeUsageCache(): Promise<UsageCache<ClaudeUsageResponse> | null> {
|
||||
return loadJson(KEYS.CLAUDE_USAGE_CACHE);
|
||||
}
|
||||
|
||||
export function saveClaudeUsageCache(
|
||||
cache: UsageCache<ClaudeUsageResponse>
|
||||
): Promise<void> {
|
||||
return saveJson(KEYS.CLAUDE_USAGE_CACHE, cache);
|
||||
}
|
||||
|
||||
export function clearClaudeUsageCache(): Promise<void> {
|
||||
return SecureStore.deleteItemAsync(KEYS.CLAUDE_USAGE_CACHE);
|
||||
}
|
||||
|
||||
export async function saveClaudeSessionKey(key: string): Promise<void> {
|
||||
await SecureStore.setItemAsync(KEYS.CLAUDE_SESSION_KEY, key);
|
||||
await SecureStore.setItemAsync(KEYS.CLAUDE_SESSION_KEY, key.trim());
|
||||
}
|
||||
|
||||
export async function loadClaudeSessionKey(): Promise<string | null> {
|
||||
@@ -81,7 +132,7 @@ export async function loadClaudeSessionKey(): Promise<string | null> {
|
||||
}
|
||||
|
||||
export async function saveClaudeLastActiveOrg(orgId: string): Promise<void> {
|
||||
await SecureStore.setItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG, orgId);
|
||||
await SecureStore.setItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG, orgId.trim());
|
||||
}
|
||||
|
||||
export async function loadClaudeLastActiveOrg(): Promise<string | null> {
|
||||
@@ -93,10 +144,16 @@ export async function clearClaudeLastActiveOrg(): Promise<void> {
|
||||
}
|
||||
|
||||
export async function clearCodexAuth(): Promise<void> {
|
||||
await SecureStore.deleteItemAsync(KEYS.CODEX_AUTH);
|
||||
await Promise.all([
|
||||
SecureStore.deleteItemAsync(KEYS.CODEX_AUTH),
|
||||
clearCodexUsageCache(),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function clearClaudeSessionKey(): Promise<void> {
|
||||
await SecureStore.deleteItemAsync(KEYS.CLAUDE_SESSION_KEY);
|
||||
await SecureStore.deleteItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG);
|
||||
await Promise.all([
|
||||
SecureStore.deleteItemAsync(KEYS.CLAUDE_SESSION_KEY),
|
||||
SecureStore.deleteItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG),
|
||||
clearClaudeUsageCache(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,37 @@ export function formatResetCountdown(resetAtSeconds: number): string {
|
||||
return `resets in ${minutes}m`;
|
||||
}
|
||||
|
||||
export function parseTimeInput(
|
||||
value: string
|
||||
): { hour: number; minute: number } | null {
|
||||
const match = value.trim().match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (!match) return null;
|
||||
|
||||
const hour = Number.parseInt(match[1], 10);
|
||||
const minute = Number.parseInt(match[2], 10);
|
||||
if (hour > 23 || minute > 59) return null;
|
||||
|
||||
return { hour, minute };
|
||||
}
|
||||
|
||||
export function formatTime(hour: number, minute: number): string {
|
||||
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function formatLastUpdated(timestamp: number, now = Date.now()): string {
|
||||
const elapsedSeconds = Math.max(0, Math.floor((now - timestamp) / 1000));
|
||||
if (elapsedSeconds < 60) return "Updated just now";
|
||||
|
||||
const minutes = Math.floor(elapsedSeconds / 60);
|
||||
if (minutes < 60) return `Updated ${minutes} min ago`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `Updated ${hours} hr${hours === 1 ? "" : "s"} ago`;
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
return `Updated ${days} day${days === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
|
||||
export function formatResetCountdownISO(isoString: string): string {
|
||||
const resetAtSeconds = Math.floor(new Date(isoString).getTime() / 1000);
|
||||
return formatResetCountdown(resetAtSeconds);
|
||||
|
||||
@@ -3,23 +3,30 @@
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"dependencies": {
|
||||
"@expo/metro-runtime": "~56.0.15",
|
||||
"@expo/vector-icons": "^15.1.1",
|
||||
"babel-preset-expo": "~56.0.0",
|
||||
"expo": "~56.0.12",
|
||||
"expo-constants": "~56.0.18",
|
||||
"expo": "~56.0.14",
|
||||
"expo-constants": "~56.0.20",
|
||||
"expo-document-picker": "~56.0.4",
|
||||
"expo-file-system": "~56.0.8",
|
||||
"expo-linking": "~56.0.14",
|
||||
"expo-notifications": "~56.0.18",
|
||||
"expo-router": "~56.2.11",
|
||||
"expo-font": "~56.0.7",
|
||||
"expo-linking": "~56.0.15",
|
||||
"expo-notifications": "~56.0.19",
|
||||
"expo-router": "~56.2.13",
|
||||
"expo-secure-store": "~56.0.4",
|
||||
"expo-splash-screen": "~56.0.10",
|
||||
"expo-status-bar": "~56.0.4",
|
||||
"expo-system-ui": "~56.0.5",
|
||||
"nativewind": "^4.2.6",
|
||||
"react": "19.2.7",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-native": "0.85.3",
|
||||
"react-native-reanimated": "4.3.1",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "4.25.2"
|
||||
"react-native-screens": "4.25.2",
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-worklets": "^0.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
|
||||
@@ -3,21 +3,55 @@ export interface CodexAuth {
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
export interface UsageWindow {
|
||||
used_percent: number;
|
||||
reset_at: number;
|
||||
limit_window_seconds: number;
|
||||
export interface CodexUsageWindow {
|
||||
usedPercent: number;
|
||||
resetsAt: number;
|
||||
windowMinutes: number;
|
||||
windowSeconds: number;
|
||||
}
|
||||
|
||||
export interface CodexUsageResponse {
|
||||
plan_type: string;
|
||||
rate_limit: {
|
||||
primary_window: UsageWindow;
|
||||
secondary_window: UsageWindow;
|
||||
};
|
||||
credits: {
|
||||
has_credits: boolean;
|
||||
export interface CodexCreditsSummary {
|
||||
hasCredits: boolean;
|
||||
unlimited: boolean;
|
||||
balance: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CodexResetCredit {
|
||||
index: number;
|
||||
status?: string;
|
||||
grantedAt?: string | null;
|
||||
grantedAtLocal?: string | null;
|
||||
expiresAt?: string | null;
|
||||
expiresAtLocal?: string | null;
|
||||
timeUntilExpiry?: string | null;
|
||||
}
|
||||
|
||||
export interface CodexResetCoupons {
|
||||
source: "live_api" | "local_state_fallback" | "unavailable";
|
||||
sourceDescription: string;
|
||||
availableCount?: number | null;
|
||||
totalEarnedCount?: number | null;
|
||||
credits?: CodexResetCredit[];
|
||||
nextExpiringCredit?: CodexResetCredit | null;
|
||||
dismissedAtLocal?: string | null;
|
||||
latestPossibleExpiryLocal?: string | null;
|
||||
latestPossibleExpiryNote?: string | null;
|
||||
fallbackReason?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CodexDesktopSnapshot {
|
||||
sessionFile?: string;
|
||||
threadId?: string | null;
|
||||
snapshotTimestamp?: string | null;
|
||||
limitId?: string | null;
|
||||
planType?: string | null;
|
||||
rateLimitReachedType?: string | null;
|
||||
primary: CodexUsageWindow | null;
|
||||
secondary: CodexUsageWindow | null;
|
||||
}
|
||||
|
||||
export interface CodexUsageResponse extends CodexDesktopSnapshot {
|
||||
credits: CodexCreditsSummary;
|
||||
resetCoupons?: CodexResetCoupons | null;
|
||||
}
|
||||
|
||||