12 Commits

Author SHA1 Message Date
Space-Banane dc7e497388 Fix Codex auth import and reset credit details
CodexBar Mobile Build / build-android (push) Successful in 17m26s
CodexBar Mobile Build / release (push) Successful in 12s
# Conflicts:
#	app/(tabs)/codex.tsx
#	app/(tabs)/index.tsx
#	app/(tabs)/settings.tsx
#	hooks/useCodexUsage.ts
#	lib/api/codexApi.ts
#	package-lock.json
#	package.json
2026-07-04 18:59:56 +02:00
space 5a00822690 fix: show meaningful error messages in dashboard
CodexBar Mobile Build / build-android (push) Successful in 22m12s
CodexBar Mobile Build / release (push) Successful in 17s
Replace hardcoded "Failed to fetch usage" in the Dashboard tab with the
ErrorMessage component so errors like TOKEN_EXPIRED, HTTP 429, and network
failures display human-readable text consistent with the detail tabs.
Also improve HTTP error copy and add network-level error detection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 16:28:44 +02:00
space 2c31be11c4 by codex: feat. General Improvements
CodexBar Mobile Build / build-android (push) Successful in 21m9s
CodexBar Mobile Build / release (push) Successful in 11s
2026-06-25 14:27:58 +02:00
space 9dd17cf565 fix: add react-dom and regenerate lock file without --legacy-peer-deps
CodexBar Mobile Build / build-android (push) Successful in 21m11s
CodexBar Mobile Build / release (push) Successful in 11s
expo-router's radix-ui deps require react-dom as a peer. The lock file
was previously generated with --legacy-peer-deps which omitted peer dep
entries, causing `npm ci` on CI to fail with "Missing from lock file".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 11:22:35 +02:00
space 6992e58b9c fixed version issues
CodexBar Mobile Build / build-android (push) Failing after 1m24s
CodexBar Mobile Build / release (push) Has been skipped
2026-06-25 11:13:22 +02:00
Space-Banane 050c8e5cce ci: suppress SIGPIPE from yes | sdkmanager NDK install
CodexBar Mobile Build / build-android (push) Successful in 21m17s
CodexBar Mobile Build / release (push) Successful in 11s
sdkmanager closes stdin once done; yes keeps writing and gets SIGPIPE
(exit 141), failing the step even though the NDK installed successfully.
Add || true to treat SIGPIPE as success.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 21:57:46 +02:00
Space-Banane 6bf66c0afd ci: pre-install NDK 27.1.12297006 before EAS local build
CodexBar Mobile Build / build-android (push) Failing after 52s
CodexBar Mobile Build / release (push) Has been skipped
Gradle's auto-download of the NDK was failing on the runner with
"Archive is not a ZIP archive" — the download was corrupted/unavailable.
Fix by explicitly installing the NDK via sdkmanager after setup-android
so it is present in $ANDROID_HOME before gradlew runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 21:54:18 +02:00
Space-Banane 2c63dcfd75 fix: guard expo-notifications against Expo Go crash on import
CodexBar Mobile Build / build-android (push) Failing after 3m21s
CodexBar Mobile Build / release (push) Has been skipped
SDK 53+ removed push notification support from Expo Go, causing the
module to throw at require-time. The static import in notifications.ts
propagated the error to _layout.tsx and both tab screens, making expo-
router report missing default exports and crash the app.

Switch to a try/catch require() so the module initialises safely in
Expo Go (Notifications stays null, all functions become no-ops) while
still working correctly in EAS / development builds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 21:47:44 +02:00
Space-Banane 487f89699c yes
CodexBar Mobile Build / build-android (push) Successful in 22m24s
CodexBar Mobile Build / release (push) Successful in 23s
2026-06-24 21:13:43 +02:00
Space-Banane ef245d4e61 fix: bump react to 19.2.7 to satisfy react-dom peer dependency
CodexBar Mobile Build / build-android (push) Successful in 21m4s
CodexBar Mobile Build / release (push) Successful in 10s
react-dom@19.2.7 requires react@^19.2.7; the project had 19.2.3 which
caused an ERESOLVE conflict on npm install.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 20:23:50 +02:00
space 17a5ab73cb fix: sync package-lock.json with package.json
CodexBar Mobile Build / build-android (push) Failing after 1m45s
CodexBar Mobile Build / release (push) Has been skipped
2026-06-24 20:20:06 +02:00
space ba7d957155 Maximus upgradus
CodexBar Mobile Build / build-android (push) Failing after 1m38s
CodexBar Mobile Build / release (push) Has been skipped
2026-06-24 20:16:34 +02:00
41 changed files with 3029 additions and 914 deletions
+8 -2
View File
@@ -32,6 +32,9 @@ jobs:
- name: 🏗 Setup Android SDK - name: 🏗 Setup Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
- name: 🏗 Install Android NDK
run: yes | sdkmanager "ndk;27.1.12297006" || true
- name: 🏗 Setup Expo and EAS - name: 🏗 Setup Expo and EAS
uses: expo/expo-github-action@v8 uses: expo/expo-github-action@v8
with: with:
@@ -53,11 +56,14 @@ jobs:
- name: 📝 Rename build to APK - name: 📝 Rename build to APK
run: mv app-build codexbar-release.apk run: mv app-build codexbar-release.apk
- name: 🗜 Zip APK
run: zip codexbar-release.zip codexbar-release.apk
- name: 📤 Upload build artifact - name: 📤 Upload build artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: codexbar-android-preview-build name: codexbar-android-preview-build
path: codexbar-release.apk path: codexbar-release.zip
if-no-files-found: error if-no-files-found: error
release: release:
@@ -84,7 +90,7 @@ jobs:
with: with:
tag_name: ${{ env.RELEASE_TAG }} tag_name: ${{ env.RELEASE_TAG }}
name: ${{ env.RELEASE_TAG }} name: ${{ env.RELEASE_TAG }}
files: codexbar-release.apk files: codexbar-release.zip
generate_release_notes: true generate_release_notes: true
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+49 -8
View File
@@ -1,34 +1,75 @@
{ {
"expo": { "expo": {
"name": "codexbar.mobile", "name": "Codexbar",
"slug": "codexbar-mobile", "slug": "codexbar-mobile",
"description": "Monitor Codex CLI and Claude.ai usage limits, resets, and credits at a glance.",
"scheme": "codexbar", "scheme": "codexbar",
"version": "1.0.0", "version": "1.0.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "light", "backgroundColor": "#090D11",
"primaryColor": "#10A37F",
"userInterfaceStyle": "automatic",
"ios": { "ios": {
"supportsTablet": true "bundleIdentifier": "dev.reversed.codexbar",
"buildNumber": "1",
"supportsTablet": true,
"config": {
"usesNonExemptEncryption": false
}
}, },
"android": { "android": {
"package": "dev.reversed.codexbar", "package": "dev.reversed.codexbar",
"versionCode": 1,
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#E6F4FE", "backgroundColor": "#090D11",
"foregroundImage": "./assets/android-icon-foreground.png", "foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png", "backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png" "monochromeImage": "./assets/android-icon-monochrome.png"
}, },
"predictiveBackGestureEnabled": false "predictiveBackGestureEnabled": true
}, },
"web": { "web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/favicon.png" "favicon": "./assets/favicon.png"
}, },
"plugins": [ "plugins": [
"expo-router", "expo-router",
"expo-status-bar", [
"expo-secure-store", "expo-splash-screen",
"expo-document-picker" {
"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": { "extra": {
"router": {}, "router": {},
"eas": { "eas": {
+8 -21
View File
@@ -1,22 +1,25 @@
import { Tabs } from "expo-router"; import { Tabs } from "expo-router";
import { useColorScheme } from "react-native"; import { useColorScheme } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { MaterialIcons } from "@expo/vector-icons"; import { MaterialIcons } from "@expo/vector-icons";
import { COLORS } from "@/lib/constants";
export default function TabLayout() { export default function TabLayout() {
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
const isDark = colorScheme === "dark"; const isDark = colorScheme === "dark";
const insets = useSafeAreaInsets();
return ( return (
<Tabs <Tabs
screenOptions={{ screenOptions={{
headerShown: false, headerShown: false,
tabBarActiveTintColor: "#10a37f", tabBarActiveTintColor: COLORS.codex,
tabBarInactiveTintColor: isDark ? "#52525b" : "#a1a1aa", tabBarInactiveTintColor: isDark ? "#52525b" : "#a1a1aa",
tabBarStyle: { tabBarStyle: {
backgroundColor: isDark ? "#09090b" : "#ffffff", backgroundColor: isDark ? "#09090b" : "#ffffff",
borderTopColor: isDark ? "#27272a" : "#f4f4f5", borderTopColor: isDark ? "#27272a" : "#f4f4f5",
height: 56, height: 56 + insets.bottom,
paddingBottom: 8, paddingBottom: 8 + insets.bottom,
paddingTop: 6, paddingTop: 6,
}, },
tabBarLabelStyle: { tabBarLabelStyle: {
@@ -34,24 +37,8 @@ export default function TabLayout() {
), ),
}} }}
/> />
<Tabs.Screen <Tabs.Screen name="codex" options={{ href: null }} />
name="codex" <Tabs.Screen name="claude" options={{ href: null }} />
options={{
title: "Codex",
tabBarIcon: ({ color, size }) => (
<MaterialIcons name="auto-awesome" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="claude"
options={{
title: "Claude",
tabBarIcon: ({ color, size }) => (
<MaterialIcons name="psychology" size={size} color={color} />
),
}}
/>
<Tabs.Screen <Tabs.Screen
name="settings" name="settings"
options={{ options={{
+54 -21
View File
@@ -14,22 +14,28 @@ import { MaterialIcons } from "@expo/vector-icons";
import { useClaudeUsage } from "@/hooks/useClaudeUsage"; import { useClaudeUsage } from "@/hooks/useClaudeUsage";
import { UsageStat } from "@/components/UsageStat"; import { UsageStat } from "@/components/UsageStat";
import { ErrorMessage } from "@/components/ErrorMessage"; 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 { const {
sessionKey, auth,
pendingKey, pendingKey,
setPendingKey, setPendingKey,
pendingOrg,
setPendingOrg,
usage, usage,
lastFetchedAt,
status, status,
error, error,
keyValidationError,
canSaveKey,
saveKey, saveKey,
refresh, refresh,
clearKey, clearKey,
} = useClaudeUsage(); } = useClaudeUsage();
const canSave = pendingKey.trim().startsWith("sk-ant-");
return ( return (
<SafeAreaView <SafeAreaView
className="flex-1 bg-neutral-50 dark:bg-neutral-950" className="flex-1 bg-neutral-50 dark:bg-neutral-950"
@@ -47,7 +53,7 @@ export default function ClaudeTab() {
<RefreshControl <RefreshControl
refreshing={status === "loading"} refreshing={status === "loading"}
onRefresh={refresh} onRefresh={refresh}
tintColor="#d97706" tintColor={COLORS.claude}
/> />
} }
> >
@@ -55,9 +61,9 @@ export default function ClaudeTab() {
<View className="flex-row items-center gap-x-3 py-5"> <View className="flex-row items-center gap-x-3 py-5">
<View <View
className="w-9 h-9 rounded-xl items-center justify-center" 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>
<View> <View>
<Text className="text-xl font-bold text-neutral-900 dark:text-white tracking-tight"> <Text className="text-xl font-bold text-neutral-900 dark:text-white tracking-tight">
@@ -77,7 +83,7 @@ export default function ClaudeTab() {
<ErrorMessage <ErrorMessage
message={error && (status === "idle" || status === "error") ? error : null} message={error && (status === "idle" || status === "error") ? error : null}
/> />
{sessionKey ? ( {auth ? (
<View className="flex-row items-center justify-between"> <View className="flex-row items-center justify-between">
<View className="flex-row items-center gap-x-2.5"> <View className="flex-row items-center gap-x-2.5">
<View className="w-2 h-2 rounded-full bg-green-500" /> <View className="w-2 h-2 rounded-full bg-green-500" />
@@ -85,7 +91,7 @@ export default function ClaudeTab() {
Connected Connected
</Text> </Text>
<Text className="text-xs text-neutral-400 dark:text-neutral-500 font-mono"> <Text className="text-xs text-neutral-400 dark:text-neutral-500 font-mono">
{sessionKey.slice(0, 10)} {auth.sessionKey.slice(0, 10)}
</Text> </Text>
</View> </View>
<TouchableOpacity <TouchableOpacity
@@ -102,22 +108,38 @@ export default function ClaudeTab() {
<TextInput <TextInput
value={pendingKey} value={pendingKey}
onChangeText={setPendingKey} onChangeText={setPendingKey}
placeholder="sk-ant-..." placeholder="sk-ant-… (required)"
placeholderTextColor="#a3a3a3" placeholderTextColor="#a3a3a3"
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
secureTextEntry 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}
placeholder="lastActiveOrg UUID (optional)"
placeholderTextColor="#a3a3a3"
autoCapitalize="none"
autoCorrect={false}
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-3" 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-3"
/> />
<TouchableOpacity <TouchableOpacity
onPress={saveKey} onPress={saveKey}
disabled={!canSave} disabled={!canSaveKey}
className="py-3.5 px-4 rounded-xl items-center" className="py-3.5 px-4 rounded-xl items-center"
style={{ backgroundColor: canSave ? "#d97706" : "#e5e5e5" }} style={{
backgroundColor: canSaveKey ? COLORS.claude : COLORS.disabled,
}}
> >
<Text <Text
className="font-semibold text-sm" className="font-semibold text-sm"
style={{ color: canSave ? "white" : "#a3a3a3" }} style={{ color: canSaveKey ? "white" : COLORS.muted }}
> >
Save Session Key Save Session Key
</Text> </Text>
@@ -127,10 +149,10 @@ export default function ClaudeTab() {
Where to find it Where to find it
</Text> </Text>
<Text className="text-xs text-neutral-500 dark:text-neutral-400 leading-relaxed"> <Text className="text-xs text-neutral-500 dark:text-neutral-400 leading-relaxed">
Chrome DevTools Application Cookies claude.ai {" "} Chrome DevTools Application Cookies claude.ai copy{" "}
<Text className="font-mono text-neutral-600 dark:text-neutral-300"> <Text className="font-mono text-neutral-600 dark:text-neutral-300">sessionKey</Text>
sessionKey {" "}and optionally{" "}
</Text> <Text className="font-mono text-neutral-600 dark:text-neutral-300">lastActiveOrg</Text>
</Text> </Text>
</View> </View>
</View> </View>
@@ -143,7 +165,7 @@ export default function ClaudeTab() {
Usage Usage
</Text> </Text>
{status === "idle" && !sessionKey && ( {status === "idle" && !auth && (
<View className="items-center py-8"> <View className="items-center py-8">
<MaterialIcons name="insert-chart-outlined" size={32} color="#d4d4d4" /> <MaterialIcons name="insert-chart-outlined" size={32} color="#d4d4d4" />
<Text className="text-sm text-neutral-400 text-center mt-3"> <Text className="text-sm text-neutral-400 text-center mt-3">
@@ -151,11 +173,11 @@ export default function ClaudeTab() {
</Text> </Text>
</View> </View>
)} )}
{status === "loading" && ( {status === "loading" && !usage && (
<ActivityIndicator color="#d97706" style={{ marginVertical: 24 }} /> <ActivityIndicator color={COLORS.claude} style={{ marginVertical: 24 }} />
)} )}
{status === "error" && error && <ErrorMessage message={error} />} {status === "error" && error && <ErrorMessage message={error} />}
{status === "success" && usage && ( {usage && (
<View> <View>
<UsageStat <UsageStat
label="5-hour window" label="5-hour window"
@@ -187,6 +209,9 @@ export default function ClaudeTab() {
)} )}
</View> </View>
)} )}
<View className="mt-4 border-t border-neutral-100 pt-3 dark:border-neutral-800">
<LastUpdated timestamp={lastFetchedAt} />
</View>
</View> </View>
)} )}
</View> </View>
@@ -195,3 +220,11 @@ export default function ClaudeTab() {
</SafeAreaView> </SafeAreaView>
); );
} }
export default function ClaudeTab() {
return (
<ScreenErrorBoundary screenName="Claude">
<ClaudeTabContent />
</ScreenErrorBoundary>
);
}
+127 -18
View File
@@ -11,11 +11,64 @@ import { MaterialIcons } from "@expo/vector-icons";
import { useCodexUsage } from "@/hooks/useCodexUsage"; import { useCodexUsage } from "@/hooks/useCodexUsage";
import { UsageStat } from "@/components/UsageStat"; import { UsageStat } from "@/components/UsageStat";
import { ErrorMessage } from "@/components/ErrorMessage"; 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 { windowLabel } from "@/lib/timeUtils";
import type { CodexResetCredit } from "@/types/codex";
export default function CodexTab() { function formatResetStatus(status?: string) {
const { auth, usage, status, error, importAuthFile, refresh, clearAuth } = 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(); useCodexUsage();
const resetCoupons = usage?.resetCoupons;
const availableResetCredits =
resetCoupons?.credits?.filter((credit) => credit.status === "available") ?? [];
return ( return (
<SafeAreaView <SafeAreaView
@@ -29,7 +82,7 @@ export default function CodexTab() {
<RefreshControl <RefreshControl
refreshing={status === "loading"} refreshing={status === "loading"}
onRefresh={refresh} 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="flex-row items-center gap-x-3 py-5">
<View <View
className="w-9 h-9 rounded-xl items-center justify-center" 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>
<View> <View>
<Text className="text-xl font-bold text-neutral-900 dark:text-white tracking-tight"> <Text className="text-xl font-bold text-neutral-900 dark:text-white tracking-tight">
@@ -84,7 +137,7 @@ export default function CodexTab() {
<TouchableOpacity <TouchableOpacity
onPress={importAuthFile} onPress={importAuthFile}
className="flex-row items-center justify-center gap-x-2 py-3.5 px-4 rounded-xl" 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" /> <MaterialIcons name="upload-file" size={16} color="white" />
<Text className="text-white font-semibold text-sm"> <Text className="text-white font-semibold text-sm">
@@ -112,31 +165,35 @@ export default function CodexTab() {
</Text> </Text>
</View> </View>
)} )}
{status === "loading" && ( {status === "loading" && !usage && (
<ActivityIndicator color="#10a37f" style={{ marginVertical: 24 }} /> <ActivityIndicator color={COLORS.codex} style={{ marginVertical: 24 }} />
)} )}
{status === "error" && error && <ErrorMessage message={error} />} {status === "error" && error && <ErrorMessage message={error} />}
{status === "success" && usage && ( {usage && (
<View> <View>
{/* Plan badge */} {/* Plan badge */}
<View className="flex-row items-center mb-5"> <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"> <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"> <Text className="text-xs font-semibold text-green-700 dark:text-green-300 capitalize">
{usage.plan_type} {usage.planType ?? "codex"}
</Text> </Text>
</View> </View>
</View> </View>
{usage.primary && (
<UsageStat <UsageStat
label={`Primary (${windowLabel(usage.rate_limit.primary_window.limit_window_seconds)})`} label={`Primary (${windowLabel(usage.primary.windowSeconds)})`}
percent={usage.rate_limit.primary_window.used_percent} percent={usage.primary.usedPercent}
resetAtSeconds={usage.rate_limit.primary_window.reset_at} resetAtSeconds={usage.primary.resetsAt}
/> />
)}
{usage.secondary && (
<UsageStat <UsageStat
label={`Secondary (${windowLabel(usage.rate_limit.secondary_window.limit_window_seconds)})`} label={`Secondary (${windowLabel(usage.secondary.windowSeconds)})`}
percent={usage.rate_limit.secondary_window.used_percent} percent={usage.secondary.usedPercent}
resetAtSeconds={usage.rate_limit.secondary_window.reset_at} resetAtSeconds={usage.secondary.resetsAt}
/> />
)}
{/* Credits */} {/* 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"> <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> </Text>
{usage.credits.unlimited ? ( {usage.credits.unlimited ? (
<View className="flex-row items-center gap-x-2"> <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"> <Text className="text-sm font-semibold text-green-600 dark:text-green-400">
Unlimited Unlimited
</Text> </Text>
</View> </View>
) : usage.credits.has_credits ? ( ) : usage.credits.hasCredits ? (
<Text className="text-sm font-semibold text-neutral-900 dark:text-white"> <Text className="text-sm font-semibold text-neutral-900 dark:text-white">
${Number(usage.credits.balance).toFixed(2)} remaining ${Number(usage.credits.balance).toFixed(2)} remaining
</Text> </Text>
@@ -158,6 +215,50 @@ export default function CodexTab() {
<Text className="text-sm text-neutral-400">No credits</Text> <Text className="text-sm text-neutral-400">No credits</Text>
)} )}
</View> </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>
)} )}
</View> </View>
@@ -165,3 +266,11 @@ export default function CodexTab() {
</SafeAreaView> </SafeAreaView>
); );
} }
export default function CodexTab() {
return (
<ScreenErrorBoundary screenName="Codex">
<CodexTabContent />
</ScreenErrorBoundary>
);
}
+268 -78
View File
@@ -4,145 +4,335 @@ import {
Text, Text,
TouchableOpacity, TouchableOpacity,
RefreshControl, RefreshControl,
ActivityIndicator,
} from "react-native"; } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
import { router, useFocusEffect } from "expo-router"; import { router, useFocusEffect } from "expo-router";
import { useCallback } from "react"; import { useCallback, useEffect } from "react";
import { MaterialIcons } from "@expo/vector-icons"; import { MaterialIcons } from "@expo/vector-icons";
import { useCodexUsage } from "@/hooks/useCodexUsage"; import { useCodexUsage } from "@/hooks/useCodexUsage";
import { useClaudeUsage } from "@/hooks/useClaudeUsage"; import { useClaudeUsage } from "@/hooks/useClaudeUsage";
import { ServiceStatusCard } from "@/components/ServiceStatusCard"; 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"; import { windowLabel } from "@/lib/timeUtils";
export default function DashboardTab() { function UsageRow({
label,
percent,
resetAtSeconds,
resetAtISO,
}: {
label: string;
percent: number;
resetAtSeconds?: number;
resetAtISO?: string;
}) {
const color = getUsageColor(percent);
return (
<View className="mb-4">
<View className="flex-row justify-between items-baseline mb-1.5">
<Text className="text-xs font-medium text-neutral-500 dark:text-neutral-400">
{label}
</Text>
<Text className="text-sm font-bold" style={{ color }}>
{Math.round(percent)}% used
</Text>
</View>
<ProgressBar percent={percent} />
<ResetCountdown resetAtSeconds={resetAtSeconds} resetAtISO={resetAtISO} />
</View>
);
}
function SetupPrompt({ label }: { label: string }) {
return (
<View>
<Text className="text-sm text-neutral-400 dark:text-neutral-500 mb-3">
{label}
</Text>
<TouchableOpacity
onPress={() => router.navigate("/(tabs)/settings")}
className="flex-row items-center self-start gap-x-1.5 px-3 py-2 rounded-xl border border-neutral-200 dark:border-neutral-700"
>
<Text className="text-xs font-semibold text-neutral-600 dark:text-neutral-300">
Set up in Settings
</Text>
<MaterialIcons name="arrow-forward" size={12} color="#737373" />
</TouchableOpacity>
</View>
);
}
function DashboardTabContent() {
const codex = useCodexUsage(); const codex = useCodexUsage();
const claude = useClaudeUsage(); const claude = useClaudeUsage();
// Fire daily digest reschedule + threshold check whenever fresh data arrives
useEffect(() => {
if (codex.usage || claude.usage) {
void onUsageDataLoaded(
claude.usage,
codex.usage
);
}
}, [codex.lastFetchedAt, claude.lastFetchedAt]);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
codex.reload(); void codex.reloadCredentials();
claude.reload(); void claude.reloadCredentials();
}, []) }, [codex.reloadCredentials, claude.reloadCredentials])
); );
const isRefreshing = const isRefreshing =
codex.status === "loading" || claude.status === "loading"; codex.status === "loading" || claude.status === "loading";
const handleRefresh = () => { const handleRefresh = () => {
codex.refresh(); void codex.refresh();
claude.refresh(); void claude.refresh();
}; };
const codexRows = const connectedCount = [codex.auth, claude.auth].filter(Boolean).length;
codex.usage
? [
{
label: `Primary (${windowLabel(codex.usage.rate_limit.primary_window.limit_window_seconds)})`,
percent: codex.usage.rate_limit.primary_window.used_percent,
resetAtSeconds: codex.usage.rate_limit.primary_window.reset_at,
},
{
label: `Secondary (${windowLabel(codex.usage.rate_limit.secondary_window.limit_window_seconds)})`,
percent: codex.usage.rate_limit.secondary_window.used_percent,
resetAtSeconds: codex.usage.rate_limit.secondary_window.reset_at,
},
]
: undefined;
const claudeRows =
claude.usage
? [
{
label: "5-hour window",
percent: claude.usage.five_hour.utilization,
resetAtISO: claude.usage.five_hour.resets_at,
},
{
label: "7-day window",
percent: claude.usage.seven_day.utilization,
resetAtISO: claude.usage.seven_day.resets_at,
},
]
: undefined;
return ( return (
<SafeAreaView <SafeAreaView
className="flex-1 bg-neutral-50 dark:bg-neutral-950" className="flex-1 bg-neutral-50 dark:bg-neutral-950"
edges={["top", "bottom"]} edges={["top"]}
> >
<ScrollView <ScrollView
className="flex-1" className="flex-1"
contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 32 }} contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 40 }}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={isRefreshing} refreshing={isRefreshing}
onRefresh={handleRefresh} onRefresh={handleRefresh}
tintColor="#10a37f" tintColor={COLORS.codex}
/> />
} }
> >
{/* Header */} {/* Header */}
<View className="flex-row items-center justify-between py-5"> <View className="flex-row items-center justify-between pt-6 pb-5">
<View className="flex-row items-center gap-x-3">
<View className="w-10 h-10 rounded-2xl bg-emerald-500 items-center justify-center">
<MaterialIcons name="bar-chart" size={20} color="white" />
</View>
<View> <View>
<Text className="text-2xl font-bold text-neutral-900 dark:text-white tracking-tight"> <Text className="text-xl font-bold text-neutral-900 dark:text-white tracking-tight">
Codexbar Codexbar
</Text> </Text>
<Text className="text-xs text-neutral-400 mt-0.5"> <Text className="text-xs text-neutral-400 mt-0.5">
AI usage monitor {connectedCount === 0
? "No services connected"
: connectedCount === 2
? "2 services connected"
: "1 of 2 services connected"}
</Text> </Text>
</View> </View>
</View>
<TouchableOpacity <TouchableOpacity
onPress={handleRefresh} onPress={handleRefresh}
className="w-9 h-9 rounded-xl bg-neutral-100 dark:bg-neutral-800 items-center justify-center" 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> </TouchableOpacity>
</View> </View>
{/* Codex card */} {/* Codex card */}
<ServiceStatusCard <View className="rounded-2xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 mb-4 overflow-hidden">
title="Codex CLI" <View style={{ height: 3, backgroundColor: COLORS.codex }} />
icon="auto-awesome" <View className="p-4">
accentColor="#10a37f" <View className="flex-row items-center justify-between mb-4">
status={!codex.auth ? "idle" : codex.status} <View className="flex-row items-center gap-x-2.5">
badge={codex.usage?.plan_type} <View
rows={codexRows} className="w-8 h-8 rounded-xl items-center justify-center"
unconfiguredLabel="Import auth.json to see your Codex rate limits." style={{ backgroundColor: `${COLORS.codex}18` }}
onConfigurePress={() => router.navigate("/(tabs)/codex")} >
footer={ <MaterialIcons name="auto-awesome" size={16} color={COLORS.codex} />
codex.usage && !codex.usage.credits.unlimited && codex.usage.credits.has_credits </View>
? ( <Text className="text-sm font-semibold text-neutral-900 dark:text-white">
<View className="flex-row items-center gap-x-2"> Codex CLI
<MaterialIcons name="toll" size={14} color="#a3a3a3" />
<Text className="text-xs text-neutral-500 dark:text-neutral-400">
${Number(codex.usage.credits.balance).toFixed(2)} credits remaining
</Text> </Text>
</View> </View>
)
: codex.usage?.credits.unlimited
? (
<View className="flex-row items-center gap-x-2"> <View className="flex-row items-center gap-x-2">
<MaterialIcons name="all-inclusive" size={14} color="#10a37f" /> {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.planType}
</Text>
</View>
)}
{codex.auth && codex.status === "error" && (
<View className="w-2 h-2 rounded-full bg-red-500" />
)}
{codex.status === "success" && !codex.usage?.planType && (
<View className="w-2 h-2 rounded-full bg-green-500" />
)}
</View>
</View>
{!codex.auth && (
<SetupPrompt label="Import auth.json to see your Codex rate limits." />
)}
{codex.auth && codex.status === "loading" && !codex.usage && (
<ActivityIndicator
color={COLORS.codex}
style={{ marginVertical: 16 }}
/>
)}
{codex.auth && codex.status === "error" && (
<ErrorMessage message={codex.error} />
)}
{codex.usage && (
<View>
{codex.usage.primary && (
<UsageRow
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.secondary.windowSeconds)} window`}
percent={codex.usage.secondary.usedPercent}
resetAtSeconds={codex.usage.secondary.resetsAt}
/>
)}
{!codex.usage.credits.unlimited &&
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">
${Number(codex.usage.credits.balance).toFixed(2)}{" "}
credits remaining
</Text>
</View>
)}
{codex.usage.credits.unlimited && (
<View className="pt-3 border-t border-neutral-100 dark:border-neutral-800 flex-row items-center gap-x-2">
<MaterialIcons
name="all-inclusive"
size={14}
color={COLORS.codex}
/>
<Text className="text-xs text-green-600 dark:text-green-400"> <Text className="text-xs text-green-600 dark:text-green-400">
Unlimited credits Unlimited credits
</Text> </Text>
</View> </View>
) )}
: null <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>
</View>
{/* Claude card */} {/* Claude card */}
<ServiceStatusCard <View className="rounded-2xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 mb-4 overflow-hidden">
title="Claude.ai" <View style={{ height: 3, backgroundColor: COLORS.claude }} />
icon="psychology" <View className="p-4">
accentColor="#d97706" <View className="flex-row items-center justify-between mb-4">
status={!claude.sessionKey ? "idle" : claude.status} <View className="flex-row items-center gap-x-2.5">
rows={claudeRows} <View
unconfiguredLabel="Add your session key to see Claude usage windows." className="w-8 h-8 rounded-xl items-center justify-center"
onConfigurePress={() => router.navigate("/(tabs)/claude")} style={{ backgroundColor: `${COLORS.claude}18` }}
>
<MaterialIcons name="psychology" size={16} color={COLORS.claude} />
</View>
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
Claude.ai
</Text>
</View>
{claude.auth && claude.status === "error" && (
<View className="w-2 h-2 rounded-full bg-red-500" />
)}
{claude.status === "success" && (
<View className="w-2 h-2 rounded-full bg-green-500" />
)}
</View>
{!claude.auth && (
<SetupPrompt label="Add your session key to see Claude usage windows." />
)}
{claude.auth && claude.status === "loading" && !claude.usage && (
<ActivityIndicator
color={COLORS.claude}
style={{ marginVertical: 16 }}
/> />
)}
{claude.auth && claude.status === "error" && (
<ErrorMessage message={claude.error} />
)}
{claude.usage && (
<View>
<UsageRow
label="5-hour window"
percent={claude.usage.five_hour.utilization}
resetAtISO={claude.usage.five_hour.resets_at}
/>
<UsageRow
label="7-day window"
percent={claude.usage.seven_day.utilization}
resetAtISO={claude.usage.seven_day.resets_at}
/>
{(claude.usage.seven_day_sonnet ||
claude.usage.seven_day_opus) && (
<View className="pt-3 border-t border-neutral-100 dark:border-neutral-800">
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 mb-3">
By Model (7-day)
</Text>
{claude.usage.seven_day_sonnet && (
<UsageRow
label="Sonnet"
percent={claude.usage.seven_day_sonnet.utilization}
/>
)}
{claude.usage.seven_day_opus && (
<UsageRow
label="Opus"
percent={claude.usage.seven_day_opus.utilization}
/>
)}
</View>
)}
<View className="mt-3 border-t border-neutral-100 pt-3 dark:border-neutral-800">
<LastUpdated timestamp={claude.lastFetchedAt} />
</View>
</View>
)}
</View>
</View>
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
); );
} }
export default function DashboardTab() {
return (
<ScreenErrorBoundary screenName="Dashboard">
<DashboardTabContent />
</ScreenErrorBoundary>
);
}
+328 -14
View File
@@ -1,18 +1,46 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { View, Text, TouchableOpacity, Alert, ScrollView } from "react-native"; import {
View,
Text,
TouchableOpacity,
Alert,
ScrollView,
TextInput,
Switch,
KeyboardAvoidingView,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
import { router, useFocusEffect } from "expo-router"; import { router, useFocusEffect } from "expo-router";
import { MaterialIcons } from "@expo/vector-icons"; import { MaterialIcons } from "@expo/vector-icons";
import { import {
loadCodexAuth, loadCodexAuth,
clearCodexAuth, clearCodexAuth,
saveCodexAuth,
loadClaudeSessionKey, loadClaudeSessionKey,
clearClaudeSessionKey, clearClaudeSessionKey,
saveClaudeSessionKey,
saveClaudeLastActiveOrg,
clearClaudeLastActiveOrg,
} from "@/lib/storage"; } from "@/lib/storage";
import { pickAndReadCodexAuth } from "@/lib/fileReader";
import { resetOnboarding } from "@/lib/setupState"; 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 type { CodexAuth } from "@/types/codex";
import Constants from "expo-constants"; import Constants from "expo-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;
}
function SettingRow({ function SettingRow({
icon, icon,
label, label,
@@ -88,14 +116,28 @@ function SectionCard({ children }: { children: React.ReactNode }) {
} }
function Divider() { function Divider() {
return ( return <View className="ml-16 mr-0 h-px bg-neutral-100 dark:bg-neutral-800" />;
<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 [codexAuth, setCodexAuth] = useState<CodexAuth | null>(null);
const [claudeKey, setClaudeKey] = useState<string | null>(null); const [claudeKey, setClaudeKey] = useState<string | null>(null);
const [pendingKey, setPendingKey] = useState("");
const [pendingOrg, setPendingOrg] = useState("");
const [claudeError, setClaudeError] = useState<string | null>(null);
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
useEffect(() => {
if (notif.loaded) {
setTimeInput(formatTime(notif.settings.dailyHour, notif.settings.dailyMinute));
setThresholdInput(String(notif.settings.thresholdPct));
}
}, [notif.loaded]);
const reload = useCallback(() => { const reload = useCallback(() => {
Promise.all([loadCodexAuth(), loadClaudeSessionKey()]).then( Promise.all([loadCodexAuth(), loadClaudeSessionKey()]).then(
@@ -110,9 +152,41 @@ export default function SettingsTab() {
reload(); reload();
}, [reload]); }, [reload]);
useFocusEffect(useCallback(() => { useFocusEffect(
useCallback(() => {
reload(); reload();
}, [reload])); }, [reload])
);
const handleImportCodex = async () => {
try {
const auth = await pickAndReadCodexAuth();
await saveCodexAuth(auth);
setCodexAuth(auth);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
if (msg !== "PICKER_CANCELLED") {
Alert.alert("Import failed", humanizeImportError(msg));
}
}
};
const handleSaveClaude = async () => {
const trimmed = pendingKey.trim();
const validationError = validateClaudeSessionKey(trimmed);
if (validationError) {
setClaudeError(validationError);
return;
}
const orgTrimmed = pendingOrg.trim() || undefined;
await saveClaudeSessionKey(trimmed);
if (orgTrimmed) await saveClaudeLastActiveOrg(orgTrimmed);
else await clearClaudeLastActiveOrg();
setClaudeKey(trimmed);
setPendingKey("");
setPendingOrg("");
setClaudeError(null);
};
const handleClearCodex = () => { const handleClearCodex = () => {
Alert.alert( Alert.alert(
@@ -172,6 +246,29 @@ export default function SettingsTab() {
); );
}; };
const handleTimeBlur = useCallback(() => {
const parsed = parseTimeInput(timeInput);
if (parsed) {
void notif.update({ dailyHour: parsed.hour, dailyMinute: parsed.minute });
setTimeInput(formatTime(parsed.hour, parsed.minute));
setTimeError(null);
} else {
setTimeError("Use HH:MM format, from 00:00 to 23:59.");
}
}, [timeInput, notif]);
const handleThresholdBlur = useCallback(() => {
const n = parseInt(thresholdInput, 10);
if (!isNaN(n) && n >= 1 && n <= 99) {
void notif.update({ thresholdPct: n });
} else {
setThresholdInput(String(notif.settings.thresholdPct));
}
}, [thresholdInput, notif]);
const canSaveClaude = isClaudeSessionKeyValid(pendingKey);
const inlineClaudeError =
claudeError ?? (pendingKey ? validateClaudeSessionKey(pendingKey) : null);
const appVersion = Constants.expoConfig?.version ?? "1.0.0"; const appVersion = Constants.expoConfig?.version ?? "1.0.0";
return ( return (
@@ -179,7 +276,15 @@ export default function SettingsTab() {
className="flex-1 bg-neutral-50 dark:bg-neutral-950" className="flex-1 bg-neutral-50 dark:bg-neutral-950"
edges={["top", "bottom"]} edges={["top", "bottom"]}
> >
<ScrollView className="flex-1" contentContainerStyle={{ paddingBottom: 48 }}> <KeyboardAvoidingView
className="flex-1"
behavior={process.env.EXPO_OS === "ios" ? "padding" : "height"}
>
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingBottom: 48 }}
keyboardShouldPersistTaps="handled"
>
{/* Header */} {/* Header */}
<View className="px-4 py-5"> <View className="px-4 py-5">
<Text className="text-2xl font-bold text-neutral-900 dark:text-white tracking-tight"> <Text className="text-2xl font-bold text-neutral-900 dark:text-white tracking-tight">
@@ -195,7 +300,11 @@ export default function SettingsTab() {
<SettingRow <SettingRow
icon="check-circle" icon="check-circle"
label="Connected" label="Connected"
value={codexAuth.accountId ? `${codexAuth.accountId.slice(0, 16)}` : undefined} value={
codexAuth.accountId
? `${codexAuth.accountId.slice(0, 16)}`
: undefined
}
disabled disabled
/> />
<Divider /> <Divider />
@@ -210,7 +319,7 @@ export default function SettingsTab() {
<SettingRow <SettingRow
icon="upload-file" icon="upload-file"
label="Import auth.json" label="Import auth.json"
onPress={() => router.navigate("/(tabs)/codex")} onPress={handleImportCodex}
/> />
)} )}
</SectionCard> </SectionCard>
@@ -235,11 +344,207 @@ export default function SettingsTab() {
/> />
</> </>
) : ( ) : (
<SettingRow <View className="p-4">
icon="vpn-key" {inlineClaudeError && (
label="Enter session key" <Text selectable className="text-xs text-red-500 mb-2">
onPress={() => router.navigate("/(tabs)/claude")} {inlineClaudeError}
</Text>
)}
<TextInput
value={pendingKey}
onChangeText={(t) => {
setPendingKey(t);
setClaudeError(null);
}}
placeholder="sk-ant-… (required)"
placeholderTextColor="#a3a3a3"
autoCapitalize="none"
autoCorrect={false}
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-neutral-50 dark:bg-neutral-800 mb-2"
/> />
<TextInput
value={pendingOrg}
onChangeText={setPendingOrg}
placeholder="lastActiveOrg UUID (optional)"
placeholderTextColor="#a3a3a3"
autoCapitalize="none"
autoCorrect={false}
className="border border-neutral-200 dark:border-neutral-700 rounded-xl px-3 py-3 text-sm text-neutral-900 dark:text-white bg-neutral-50 dark:bg-neutral-800 mb-3"
/>
<TouchableOpacity
onPress={handleSaveClaude}
disabled={!canSaveClaude}
className="py-3 px-4 rounded-xl items-center"
style={{
backgroundColor: canSaveClaude
? COLORS.claude
: COLORS.disabled,
}}
>
<Text
className="font-semibold text-sm"
style={{ color: canSaveClaude ? "white" : COLORS.muted }}
>
Save Session Key
</Text>
</TouchableOpacity>
<Text className="text-xs text-neutral-400 text-center mt-3 leading-relaxed">
Chrome DevTools Application Cookies claude.ai {" "}
<Text className="font-mono text-neutral-500">sessionKey</Text>
</Text>
</View>
)}
</SectionCard>
{/* Notifications section */}
<SectionHeader title="Notifications" />
<SectionCard>
{notif.permissionStatus !== "granted" ? (
<TouchableOpacity
onPress={async () => {
const granted = await notif.askPermissions();
if (!granted && notif.permissionStatus === "denied") {
Alert.alert(
"Notifications blocked",
"Go to Settings → CodexBar → Notifications to enable them."
);
}
}}
className="flex-row items-center gap-x-3 py-3.5 px-4"
>
<View className="w-8 items-center">
<MaterialIcons name="notifications-off" size={20} color="#737373" />
</View>
<View className="flex-1">
<Text className="text-sm font-medium text-neutral-800 dark:text-neutral-100">
Enable notifications
</Text>
<Text className="text-xs text-neutral-400 mt-0.5">
{notif.permissionStatus === "denied"
? "Blocked — open system Settings to allow"
: "Required to receive alerts"}
</Text>
</View>
{notif.permissionStatus !== "denied" && (
<View className="px-2.5 py-1 rounded-full bg-amber-100 dark:bg-amber-900/30">
<Text className="text-xs font-semibold text-amber-700 dark:text-amber-300">
Allow
</Text>
</View>
)}
</TouchableOpacity>
) : (
<>
{/* Daily digest */}
<View className="flex-row items-center gap-x-3 py-3.5 px-4">
<View className="w-8 items-center">
<MaterialIcons name="alarm" size={20} color="#737373" />
</View>
<View className="flex-1">
<Text className="text-sm font-medium text-neutral-800 dark:text-neutral-100">
Daily digest
</Text>
<Text className="text-xs text-neutral-400 mt-0.5">
Daily reminder with last-known usage
</Text>
</View>
<Switch
value={notif.settings.dailyEnabled}
onValueChange={(v) => void notif.update({ dailyEnabled: v })}
trackColor={{ false: COLORS.disabled, true: COLORS.codex }}
thumbColor="white"
/>
</View>
{notif.settings.dailyEnabled && (
<>
<Divider />
<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>
<Text className="flex-1 text-sm font-medium text-neutral-800 dark:text-neutral-100">
Time
</Text>
<TextInput
value={timeInput}
onChangeText={(value) => {
setTimeInput(value);
setTimeError(null);
}}
onBlur={handleTimeBlur}
placeholder="09:00"
placeholderTextColor={COLORS.muted}
keyboardType="numbers-and-punctuation"
returnKeyType="done"
maxLength={5}
className="text-sm font-mono text-neutral-600 dark:text-neutral-300 text-right"
style={{ minWidth: 52 }}
/>
</View>
{timeError && (
<Text selectable className="ml-11 mt-1.5 text-xs text-red-500">
{timeError}
</Text>
)}
</View>
</>
)}
<Divider />
{/* Threshold alert */}
<View className="flex-row items-center gap-x-3 py-3.5 px-4">
<View className="w-8 items-center">
<MaterialIcons name="warning" size={20} color="#737373" />
</View>
<View className="flex-1">
<Text className="text-sm font-medium text-neutral-800 dark:text-neutral-100">
Low quota alert
</Text>
<Text className="text-xs text-neutral-400 mt-0.5">
Alert when weekly quota runs low
</Text>
</View>
<Switch
value={notif.settings.thresholdEnabled}
onValueChange={(v) => void notif.update({ thresholdEnabled: v })}
trackColor={{ false: COLORS.disabled, true: COLORS.claude }}
thumbColor="white"
/>
</View>
{notif.settings.thresholdEnabled && (
<>
<Divider />
<View className="flex-row items-center gap-x-3 py-3 px-4">
<View className="w-8 items-center">
<MaterialIcons name="battery-alert" size={20} color="#737373" />
</View>
<Text className="flex-1 text-sm font-medium text-neutral-800 dark:text-neutral-100">
Alert below
</Text>
<View className="flex-row items-center gap-x-1">
<TextInput
value={thresholdInput}
onChangeText={setThresholdInput}
onBlur={handleThresholdBlur}
keyboardType="numeric"
returnKeyType="done"
maxLength={2}
className="text-sm font-mono text-neutral-600 dark:text-neutral-300 text-right"
style={{ minWidth: 28 }}
/>
<Text className="text-sm text-neutral-400 dark:text-neutral-500">
% remaining
</Text>
</View>
</View>
</>
)}
</>
)} )}
</SectionCard> </SectionCard>
@@ -261,6 +566,15 @@ export default function SettingsTab() {
/> />
</SectionCard> </SectionCard>
</ScrollView> </ScrollView>
</KeyboardAvoidingView>
</SafeAreaView> </SafeAreaView>
); );
} }
export default function SettingsTab() {
return (
<ScreenErrorBoundary screenName="Settings">
<SettingsTabContent />
</ScreenErrorBoundary>
);
}
+2 -1
View File
@@ -2,11 +2,12 @@ import "../global.css";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import { SafeAreaProvider } from "react-native-safe-area-context"; import { SafeAreaProvider } from "react-native-safe-area-context";
import { StatusBar } from "expo-status-bar"; import { StatusBar } from "expo-status-bar";
import "@/lib/notifications"; // registers setNotificationHandler on app start
export default function RootLayout() { export default function RootLayout() {
return ( return (
<SafeAreaProvider> <SafeAreaProvider>
<StatusBar style="auto" /> <StatusBar style="light" />
<Stack screenOptions={{ headerShown: false }} /> <Stack screenOptions={{ headerShown: false }} />
</SafeAreaProvider> </SafeAreaProvider>
); );
+2 -1
View File
@@ -2,6 +2,7 @@ import { useEffect } from "react";
import { View, ActivityIndicator } from "react-native"; import { View, ActivityIndicator } from "react-native";
import { router } from "expo-router"; import { router } from "expo-router";
import { hasCompletedOnboarding } from "@/lib/setupState"; import { hasCompletedOnboarding } from "@/lib/setupState";
import { COLORS } from "@/lib/constants";
export default function Index() { export default function Index() {
useEffect(() => { useEffect(() => {
@@ -12,7 +13,7 @@ export default function Index() {
return ( return (
<View className="flex-1 bg-neutral-950 items-center justify-center"> <View className="flex-1 bg-neutral-950 items-center justify-center">
<ActivityIndicator color="#10a37f" size="large" /> <ActivityIndicator color={COLORS.codex} size="large" />
</View> </View>
); );
} }
+72 -25
View File
@@ -5,44 +5,67 @@ import {
TouchableOpacity, TouchableOpacity,
TextInput, TextInput,
KeyboardAvoidingView, KeyboardAvoidingView,
Platform,
ScrollView, ScrollView,
} from "react-native"; } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
import { router } from "expo-router"; import { router } from "expo-router";
import { MaterialIcons } from "@expo/vector-icons"; import { MaterialIcons } from "@expo/vector-icons";
import { saveClaudeSessionKey, loadClaudeSessionKey } from "@/lib/storage"; import {
saveClaudeSessionKey,
saveClaudeLastActiveOrg,
clearClaudeLastActiveOrg,
loadClaudeSessionKey,
loadClaudeLastActiveOrg,
} from "@/lib/storage";
import {
isClaudeSessionKeyValid,
validateClaudeSessionKey,
} from "@/lib/claudeCredentials";
import { COLORS } from "@/lib/constants";
export default function OnboardingClaudeScreen() { export default function OnboardingClaudeScreen() {
const [savedKey, setSavedKey] = useState<string | null>(null); const [savedKey, setSavedKey] = useState<string | null>(null);
const [savedOrg, setSavedOrg] = useState<string | null>(null);
const [pendingKey, setPendingKey] = useState(""); const [pendingKey, setPendingKey] = useState("");
const [pendingOrg, setPendingOrg] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
loadClaudeSessionKey().then((k) => { Promise.all([loadClaudeSessionKey(), loadClaudeLastActiveOrg()]).then(
([k, o]) => {
if (k) setSavedKey(k); if (k) setSavedKey(k);
}); if (o) setSavedOrg(o);
}
);
}, []); }, []);
const canSave = pendingKey.trim().startsWith("sk-ant-"); const canSave = isClaudeSessionKeyValid(pendingKey);
const inlineError =
error ?? (pendingKey ? validateClaudeSessionKey(pendingKey) : null);
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
const trimmed = pendingKey.trim(); const trimmed = pendingKey.trim();
if (!trimmed.startsWith("sk-ant-")) { const validationError = validateClaudeSessionKey(trimmed);
setError("Session key must start with sk-ant-"); if (validationError) {
setError(validationError);
return; return;
} }
const orgTrimmed = pendingOrg.trim() || undefined;
await saveClaudeSessionKey(trimmed); await saveClaudeSessionKey(trimmed);
if (orgTrimmed) await saveClaudeLastActiveOrg(orgTrimmed);
else await clearClaudeLastActiveOrg();
setSavedKey(trimmed); setSavedKey(trimmed);
setSavedOrg(orgTrimmed ?? null);
setPendingKey(""); setPendingKey("");
setPendingOrg("");
setError(null); setError(null);
}, [pendingKey]); }, [pendingKey, pendingOrg]);
return ( return (
<SafeAreaView className="flex-1 bg-neutral-950"> <SafeAreaView className="flex-1 bg-neutral-950">
<KeyboardAvoidingView <KeyboardAvoidingView
className="flex-1" className="flex-1"
behavior={Platform.OS === "ios" ? "padding" : "height"} behavior={process.env.EXPO_OS === "ios" ? "padding" : "height"}
> >
<ScrollView <ScrollView
className="flex-1" className="flex-1"
@@ -61,14 +84,14 @@ export default function OnboardingClaudeScreen() {
<View className="flex-row items-center gap-x-2"> <View className="flex-row items-center gap-x-2">
<View <View
className="w-6 h-6 rounded-full items-center justify-center" 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" /> <MaterialIcons name="check" size={14} color="white" />
</View> </View>
<View className="w-12 h-0.5" style={{ backgroundColor: "#d97706" }} /> <View className="w-12 h-0.5" style={{ backgroundColor: COLORS.claude }} />
<View <View
className="w-6 h-6 rounded-full items-center justify-center" 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> <Text className="text-white text-xs font-bold">2</Text>
</View> </View>
@@ -80,9 +103,9 @@ export default function OnboardingClaudeScreen() {
<View> <View>
<View <View
className="w-16 h-16 rounded-2xl items-center justify-center mb-6" 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> </View>
<Text className="text-3xl font-bold text-white mb-3"> <Text className="text-3xl font-bold text-white mb-3">
Connect Claude.ai Connect Claude.ai
@@ -92,12 +115,13 @@ export default function OnboardingClaudeScreen() {
</Text> </Text>
{savedKey ? ( {savedKey ? (
<View className="flex-row items-center gap-x-3 bg-neutral-900 rounded-2xl p-4 border border-neutral-800 mb-4"> <View className="bg-neutral-900 rounded-2xl border border-neutral-800 mb-4 overflow-hidden">
<View className="flex-row items-center gap-x-3 p-4">
<View <View
className="w-10 h-10 rounded-xl items-center justify-center" 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>
<View className="flex-1"> <View className="flex-1">
<Text className="text-white font-semibold">Connected</Text> <Text className="text-white font-semibold">Connected</Text>
@@ -108,17 +132,27 @@ export default function OnboardingClaudeScreen() {
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
setSavedKey(null); setSavedKey(null);
setSavedOrg(null);
setPendingKey(""); setPendingKey("");
setPendingOrg("");
}} }}
> >
<Text className="text-neutral-400 text-xs">Change</Text> <Text className="text-neutral-400 text-xs">Change</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{savedOrg && (
<View className="border-t border-neutral-800 px-4 py-3">
<Text className="text-neutral-500 text-xs font-mono">
org: {savedOrg.slice(0, 8)}
</Text>
</View>
)}
</View>
) : ( ) : (
<View> <View>
{error && ( {inlineError && (
<View className="bg-red-950 border border-red-900 rounded-xl px-4 py-3 mb-4"> <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> </View>
)} )}
<TextInput <TextInput
@@ -127,18 +161,29 @@ export default function OnboardingClaudeScreen() {
setPendingKey(t); setPendingKey(t);
setError(null); setError(null);
}} }}
placeholder="sk-ant-..." placeholder="sk-ant-… (required)"
placeholderTextColor="#525252" placeholderTextColor="#525252"
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
secureTextEntry secureTextEntry
className="border border-neutral-800 rounded-xl px-4 py-3.5 text-sm text-white bg-neutral-900 mb-3" className="border border-neutral-800 rounded-xl px-4 py-3.5 text-sm text-white bg-neutral-900 mb-3"
/> />
<TextInput
value={pendingOrg}
onChangeText={setPendingOrg}
placeholder="lastActiveOrg UUID (optional)"
placeholderTextColor="#525252"
autoCapitalize="none"
autoCorrect={false}
className="border border-neutral-800 rounded-xl px-4 py-3.5 text-sm text-white bg-neutral-900 mb-3"
/>
<TouchableOpacity <TouchableOpacity
onPress={handleSave} onPress={handleSave}
disabled={!canSave} disabled={!canSave}
className="rounded-2xl py-4 items-center mb-4" className="rounded-2xl py-4 items-center mb-4"
style={{ backgroundColor: canSave ? "#d97706" : "#1a1a1a" }} style={{
backgroundColor: canSave ? COLORS.claude : "#1a1a1a",
}}
> >
<Text <Text
className="font-bold text-base" className="font-bold text-base"
@@ -149,11 +194,11 @@ export default function OnboardingClaudeScreen() {
</TouchableOpacity> </TouchableOpacity>
<View className="bg-neutral-900 rounded-xl p-4 border border-neutral-800"> <View className="bg-neutral-900 rounded-xl p-4 border border-neutral-800">
<Text className="text-neutral-400 text-xs font-semibold uppercase tracking-widest mb-2"> <Text className="text-neutral-400 text-xs font-semibold uppercase tracking-widest mb-2">
How to find your session key How to find your credentials
</Text> </Text>
<Text className="text-neutral-500 text-sm leading-relaxed"> <Text className="text-neutral-500 text-sm leading-relaxed">
Open Claude.ai in Chrome DevTools (F12) Application Cookies claude.ai find{" "} Open Claude.ai in Chrome DevTools (F12) Application Cookies claude.ai{"\n"}
<Text className="text-neutral-300 font-mono">sessionKey</Text> Copy <Text className="text-neutral-300 font-mono">sessionKey</Text> and optionally <Text className="text-neutral-300 font-mono">lastActiveOrg</Text>
</Text> </Text>
</View> </View>
</View> </View>
@@ -165,7 +210,9 @@ export default function OnboardingClaudeScreen() {
<TouchableOpacity <TouchableOpacity
onPress={() => router.push("/onboarding/done")} onPress={() => router.push("/onboarding/done")}
className="rounded-2xl py-4 items-center" className="rounded-2xl py-4 items-center"
style={{ backgroundColor: savedKey ? "#d97706" : "#1a1a1a" }} style={{
backgroundColor: savedKey ? COLORS.claude : "#1a1a1a",
}}
> >
<Text <Text
className="font-bold text-base" className="font-bold text-base"
+18 -10
View File
@@ -6,6 +6,14 @@ import { MaterialIcons } from "@expo/vector-icons";
import { pickAndReadCodexAuth } from "@/lib/fileReader"; import { pickAndReadCodexAuth } from "@/lib/fileReader";
import { saveCodexAuth, loadCodexAuth } from "@/lib/storage"; import { saveCodexAuth, loadCodexAuth } from "@/lib/storage";
import type { CodexAuth } from "@/types/codex"; 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() { export default function OnboardingCodexScreen() {
const [auth, setAuth] = useState<CodexAuth | null>(null); const [auth, setAuth] = useState<CodexAuth | null>(null);
@@ -27,7 +35,7 @@ export default function OnboardingCodexScreen() {
setAuth(parsed); setAuth(parsed);
} catch (e: unknown) { } catch (e: unknown) {
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR"; const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
if (msg !== "PICKER_CANCELLED") setError(msg); if (msg !== "PICKER_CANCELLED") setError(humanizeImportError(msg));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -45,7 +53,7 @@ export default function OnboardingCodexScreen() {
<MaterialIcons name="arrow-back" size={18} color="#a3a3a3" /> <MaterialIcons name="arrow-back" size={18} color="#a3a3a3" />
</TouchableOpacity> </TouchableOpacity>
<View className="flex-row items-center gap-x-2"> <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> <Text className="text-white text-xs font-bold">1</Text>
</View> </View>
<View className="w-12 h-0.5 bg-neutral-800" /> <View className="w-12 h-0.5 bg-neutral-800" />
@@ -60,9 +68,9 @@ export default function OnboardingCodexScreen() {
<View> <View>
<View <View
className="w-16 h-16 rounded-2xl items-center justify-center mb-6" 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> </View>
<Text className="text-3xl font-bold text-white mb-3"> <Text className="text-3xl font-bold text-white mb-3">
Connect Codex CLI 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="flex-row items-center gap-x-3 bg-neutral-900 rounded-2xl p-4 border border-neutral-800 mb-4">
<View <View
className="w-10 h-10 rounded-xl items-center justify-center" 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>
<View className="flex-1"> <View className="flex-1">
<Text className="text-white font-semibold">Connected</Text> <Text className="text-white font-semibold">Imported</Text>
{auth.accountId && ( {auth.accountId && (
<Text className="text-neutral-500 text-sm font-mono"> <Text className="text-neutral-500 text-sm font-mono">
{auth.accountId.slice(0, 12)} {auth.accountId.slice(0, 12)}
@@ -107,10 +115,10 @@ export default function OnboardingCodexScreen() {
onPress={importFile} onPress={importFile}
disabled={loading} disabled={loading}
className="rounded-2xl py-4 items-center mb-3" className="rounded-2xl py-4 items-center mb-3"
style={{ backgroundColor: loading ? "#1a1a1a" : "#10a37f" }} style={{ backgroundColor: loading ? "#1a1a1a" : COLORS.codex }}
> >
{loading ? ( {loading ? (
<ActivityIndicator color="#10a37f" /> <ActivityIndicator color={COLORS.codex} />
) : ( ) : (
<View className="flex-row items-center gap-x-2"> <View className="flex-row items-center gap-x-2">
<MaterialIcons name="upload-file" size={18} color="white" /> <MaterialIcons name="upload-file" size={18} color="white" />
@@ -132,7 +140,7 @@ export default function OnboardingCodexScreen() {
<TouchableOpacity <TouchableOpacity
onPress={() => router.push("/onboarding/claude")} onPress={() => router.push("/onboarding/claude")}
className="rounded-2xl py-4 items-center" className="rounded-2xl py-4 items-center"
style={{ backgroundColor: auth ? "#10a37f" : "#1a1a1a" }} style={{ backgroundColor: auth ? COLORS.codex : "#1a1a1a" }}
> >
<Text <Text
className="font-bold text-base" className="font-bold text-base"
+14 -9
View File
@@ -5,6 +5,7 @@ import { router } from "expo-router";
import { MaterialIcons } from "@expo/vector-icons"; import { MaterialIcons } from "@expo/vector-icons";
import { markOnboardingComplete } from "@/lib/setupState"; import { markOnboardingComplete } from "@/lib/setupState";
import { loadCodexAuth, loadClaudeSessionKey } from "@/lib/storage"; import { loadCodexAuth, loadClaudeSessionKey } from "@/lib/storage";
import { COLORS } from "@/lib/constants";
export default function OnboardingDoneScreen() { export default function OnboardingDoneScreen() {
const [codexConfigured, setCodexConfigured] = useState(false); const [codexConfigured, setCodexConfigured] = useState(false);
@@ -32,12 +33,12 @@ export default function OnboardingDoneScreen() {
{/* Icon */} {/* Icon */}
<View <View
className="w-20 h-20 rounded-3xl items-center justify-center mb-8" className="w-20 h-20 rounded-3xl items-center justify-center mb-8"
style={{ backgroundColor: "#10a37f22" }} style={{ backgroundColor: `${COLORS.codex}22` }}
> >
<MaterialIcons <MaterialIcons
name={noneConfigured ? "info-outline" : "check-circle"} name={noneConfigured ? "info-outline" : "check-circle"}
size={40} size={40}
color={noneConfigured ? "#a3a3a3" : "#10a37f"} color={noneConfigured ? COLORS.muted : COLORS.codex}
/> />
</View> </View>
@@ -57,13 +58,15 @@ export default function OnboardingDoneScreen() {
<View <View
className="w-10 h-10 rounded-xl items-center justify-center" className="w-10 h-10 rounded-xl items-center justify-center"
style={{ style={{
backgroundColor: codexConfigured ? "#10a37f22" : "#27272a", backgroundColor: codexConfigured
? `${COLORS.codex}22`
: "#27272a",
}} }}
> >
<MaterialIcons <MaterialIcons
name={codexConfigured ? "check-circle" : "radio-button-unchecked"} name={codexConfigured ? "check-circle" : "radio-button-unchecked"}
size={22} size={22}
color={codexConfigured ? "#10a37f" : "#525252"} color={codexConfigured ? COLORS.codex : "#525252"}
/> />
</View> </View>
<View className="flex-1"> <View className="flex-1">
@@ -74,7 +77,7 @@ export default function OnboardingDoneScreen() {
Codex CLI Codex CLI
</Text> </Text>
<Text className="text-neutral-600 text-sm"> <Text className="text-neutral-600 text-sm">
{codexConfigured ? "Connected" : "Not configured"} {codexConfigured ? "Auth imported" : "Not configured"}
</Text> </Text>
</View> </View>
</View> </View>
@@ -83,13 +86,15 @@ export default function OnboardingDoneScreen() {
<View <View
className="w-10 h-10 rounded-xl items-center justify-center" className="w-10 h-10 rounded-xl items-center justify-center"
style={{ style={{
backgroundColor: claudeConfigured ? "#d9770622" : "#27272a", backgroundColor: claudeConfigured
? `${COLORS.claude}22`
: "#27272a",
}} }}
> >
<MaterialIcons <MaterialIcons
name={claudeConfigured ? "check-circle" : "radio-button-unchecked"} name={claudeConfigured ? "check-circle" : "radio-button-unchecked"}
size={22} size={22}
color={claudeConfigured ? "#d97706" : "#525252"} color={claudeConfigured ? COLORS.claude : "#525252"}
/> />
</View> </View>
<View className="flex-1"> <View className="flex-1">
@@ -100,7 +105,7 @@ export default function OnboardingDoneScreen() {
Claude.ai Claude.ai
</Text> </Text>
<Text className="text-neutral-600 text-sm"> <Text className="text-neutral-600 text-sm">
{claudeConfigured ? "Connected" : "Not configured"} {claudeConfigured ? "Session saved" : "Not configured"}
</Text> </Text>
</View> </View>
</View> </View>
@@ -110,7 +115,7 @@ export default function OnboardingDoneScreen() {
<TouchableOpacity <TouchableOpacity
onPress={handleEnterApp} onPress={handleEnterApp}
className="w-full rounded-2xl py-4 items-center" 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> <Text className="text-white font-bold text-base">Open App</Text>
</TouchableOpacity> </TouchableOpacity>
+8 -7
View File
@@ -2,6 +2,7 @@ import { View, Text, TouchableOpacity } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
import { router } from "expo-router"; import { router } from "expo-router";
import { MaterialIcons } from "@expo/vector-icons"; import { MaterialIcons } from "@expo/vector-icons";
import { COLORS } from "@/lib/constants";
export default function WelcomeScreen() { export default function WelcomeScreen() {
return ( return (
@@ -12,13 +13,13 @@ export default function WelcomeScreen() {
<View className="flex-row items-center justify-center mb-8"> <View className="flex-row items-center justify-center mb-8">
<View <View
className="w-16 h-16 rounded-2xl items-center justify-center" 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" /> <MaterialIcons name="auto-awesome" size={28} color="white" />
</View> </View>
<View <View
className="w-16 h-16 rounded-2xl items-center justify-center -ml-5" 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" /> <MaterialIcons name="psychology" size={28} color="white" />
</View> </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="flex-row items-center gap-x-3 bg-neutral-900 rounded-2xl p-4 border border-neutral-800">
<View <View
className="w-10 h-10 rounded-xl items-center justify-center" 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>
<View className="flex-1"> <View className="flex-1">
<Text className="text-white font-semibold mb-0.5">Codex CLI</Text> <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="flex-row items-center gap-x-3 bg-neutral-900 rounded-2xl p-4 border border-neutral-800">
<View <View
className="w-10 h-10 rounded-xl items-center justify-center" 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>
<View className="flex-1"> <View className="flex-1">
<Text className="text-white font-semibold mb-0.5">Claude.ai</Text> <Text className="text-white font-semibold mb-0.5">Claude.ai</Text>
@@ -68,7 +69,7 @@ export default function WelcomeScreen() {
<TouchableOpacity <TouchableOpacity
onPress={() => router.push("/onboarding/codex")} onPress={() => router.push("/onboarding/codex")}
className="rounded-2xl py-4 items-center" 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> <Text className="text-white font-bold text-base">Get Started</Text>
</TouchableOpacity> </TouchableOpacity>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 1006 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 985 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 985 KiB

+1
View File
@@ -5,5 +5,6 @@ module.exports = function (api) {
["babel-preset-expo", { jsxImportSource: "nativewind" }], ["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel", "nativewind/babel",
], ],
plugins: ["react-native-reanimated/plugin"],
}; };
}; };
+21 -1
View File
@@ -4,12 +4,32 @@ const ERROR_MESSAGES: Record<string, string> = {
TOKEN_EXPIRED: "Your session token has expired. Please reconfigure.", TOKEN_EXPIRED: "Your session token has expired. Please reconfigure.",
MISSING_ACCESS_TOKEN: "auth.json is missing the accessToken field.", MISSING_ACCESS_TOKEN: "auth.json is missing the accessToken field.",
INVALID_JSON: "The selected file is not valid JSON.", 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.", 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 { function humanize(code: string): string {
if (code.startsWith("HTTP_ERROR_")) { 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}`; return ERROR_MESSAGES[code] ?? `Unexpected error: ${code}`;
} }
+21
View File
@@ -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>
);
}
+3 -2
View File
@@ -1,12 +1,13 @@
import { View } from "react-native"; import { View } from "react-native";
import { PROGRESS_THRESHOLDS } from "@/lib/constants";
interface ProgressBarProps { interface ProgressBarProps {
percent: number; percent: number;
} }
function getBarColor(percent: number): string { function getBarColor(percent: number): string {
if (percent >= 85) return "bg-red-500"; if (percent >= PROGRESS_THRESHOLDS.danger) return "bg-red-500";
if (percent >= 60) return "bg-yellow-400"; if (percent >= PROGRESS_THRESHOLDS.warning) return "bg-yellow-400";
return "bg-green-500"; return "bg-green-500";
} }
+16 -5
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Text } from "react-native"; import { Text } from "react-native";
import { COUNTDOWN_INTERVAL_MS } from "@/lib/constants";
import { formatResetCountdown, formatResetCountdownISO } from "@/lib/timeUtils"; import { formatResetCountdown, formatResetCountdownISO } from "@/lib/timeUtils";
interface ResetCountdownProps { interface ResetCountdownProps {
@@ -8,18 +9,28 @@ interface ResetCountdownProps {
} }
export function ResetCountdown({ resetAtSeconds, resetAtISO }: ResetCountdownProps) { export function ResetCountdown({ resetAtSeconds, resetAtISO }: ResetCountdownProps) {
const latest = useRef({ resetAtSeconds, resetAtISO });
latest.current = { resetAtSeconds, resetAtISO };
const getLabel = () => { const getLabel = () => {
if (resetAtSeconds !== undefined) return formatResetCountdown(resetAtSeconds); if (latest.current.resetAtSeconds !== undefined) {
if (resetAtISO) return formatResetCountdownISO(resetAtISO); return formatResetCountdown(latest.current.resetAtSeconds);
}
if (latest.current.resetAtISO) return formatResetCountdownISO(latest.current.resetAtISO);
return ""; return "";
}; };
const [label, setLabel] = useState(getLabel); const [label, setLabel] = useState(getLabel);
useEffect(() => { useEffect(() => {
setLabel(getLabel()); const update = () => setLabel(getLabel());
const interval = setInterval(() => setLabel(getLabel()), 60_000); update();
const interval = setInterval(update, COUNTDOWN_INTERVAL_MS);
return () => clearInterval(interval); return () => clearInterval(interval);
}, []);
useEffect(() => {
setLabel(getLabel());
}, [resetAtSeconds, resetAtISO]); }, [resetAtSeconds, resetAtISO]);
if (!label) return null; if (!label) return null;
+45
View File
@@ -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>
);
}
}
+191 -61
View File
@@ -1,115 +1,245 @@
import { useState, useCallback, useEffect } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { fetchClaudeOrgs, fetchClaudeUsage } from "@/lib/api/claudeApi";
import { import {
loadClaudeSessionKey, fetchClaudeOrgs,
saveClaudeSessionKey, fetchClaudeUsage,
parseClaudeUsageResponse,
} from "@/lib/api/claudeApi";
import {
isClaudeSessionKeyValid,
validateClaudeSessionKey,
} from "@/lib/claudeCredentials";
import { RETRY_DELAY_MS } from "@/lib/constants";
import {
clearClaudeLastActiveOrg,
clearClaudeSessionKey, clearClaudeSessionKey,
clearClaudeUsageCache,
loadClaudeLastActiveOrg,
loadClaudeSessionKey,
loadClaudeUsageCache,
saveClaudeLastActiveOrg,
saveClaudeSessionKey,
saveClaudeUsageCache,
} from "@/lib/storage"; } from "@/lib/storage";
import type { ClaudeUsageResponse } from "@/types/claude"; import type { ClaudeAuth, ClaudeUsageResponse } from "@/types/claude";
type Status = "idle" | "loading" | "success" | "error"; type Status = "idle" | "loading" | "success" | "error";
async function doFetch( function shouldRetry(error: unknown): boolean {
sessionKey: string, const message = error instanceof Error ? error.message : "";
set: { return ![
sessionKey: (v: string | null) => void; "TOKEN_EXPIRED",
usage: (v: ClaudeUsageResponse | null) => void; "NO_ORGS_FOUND",
status: (v: Status) => void; "INVALID_CLAUDE_RESPONSE",
error: (v: string | null) => void; "INVALID_CLAUDE_ORGS_RESPONSE",
].includes(message);
} }
) {
set.status("loading"); function wait(ms: number): Promise<void> {
set.error(null); return new Promise((resolve) => setTimeout(resolve, ms));
try { }
const orgs = await fetchClaudeOrgs(sessionKey);
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"); if (!orgs.length) throw new Error("NO_ORGS_FOUND");
const orgUuid = orgs[0].uuid; orgUuid = orgs[0].uuid;
const data = await fetchClaudeUsage(sessionKey, orgUuid); await saveClaudeLastActiveOrg(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.sessionKey(null);
}
} }
return fetchClaudeUsage(auth, orgUuid);
} }
export function useClaudeUsage() { export function useClaudeUsage() {
const [sessionKey, setSessionKey] = useState<string | null>(null); const [auth, setAuth] = useState<ClaudeAuth | null>(null);
const [pendingKey, setPendingKey] = useState(""); const [pendingKey, setPendingKey] = useState("");
const [pendingOrg, setPendingOrg] = useState("");
const [usage, setUsage] = useState<ClaudeUsageResponse | null>(null); const [usage, setUsage] = useState<ClaudeUsageResponse | null>(null);
const [lastFetchedAt, setLastFetchedAt] = useState<number | null>(null);
const [status, setStatus] = useState<Status>("idle"); const [status, setStatus] = useState<Status>("idle");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const mounted = useRef(true);
const setters = { sessionKey: setSessionKey, usage: setUsage, status: setStatus, error: setError }; const inFlight = useRef<{
key: string;
promise: Promise<void>;
} | null>(null);
const requestVersion = useRef(0);
useEffect(() => { useEffect(() => {
loadClaudeSessionKey().then((stored) => { mounted.current = true;
if (stored) setSessionKey(stored); 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(() => { useEffect(() => {
if (sessionKey) void doFetch(sessionKey, setters); let active = true;
}, [sessionKey]);
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 saveKey = useCallback(async () => {
const trimmed = pendingKey.trim(); const trimmedKey = pendingKey.trim();
if (!trimmed.startsWith("sk-ant-")) { const validationError = validateClaudeSessionKey(trimmedKey);
setError("Session key must start with sk-ant-"); if (validationError) {
setError(validationError);
return; return;
} }
await saveClaudeSessionKey(trimmed);
setSessionKey(trimmed); const trimmedOrg = pendingOrg.trim() || undefined;
await saveClaudeSessionKey(trimmedKey);
if (trimmedOrg) await saveClaudeLastActiveOrg(trimmedOrg);
else await clearClaudeLastActiveOrg();
const nextAuth = { sessionKey: trimmedKey, lastActiveOrg: trimmedOrg };
setAuth(nextAuth);
setPendingKey(""); setPendingKey("");
setPendingOrg("");
setError(null); setError(null);
}, [pendingKey]); await fetchUsage(nextAuth);
}, [fetchUsage, pendingKey, pendingOrg]);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (!sessionKey) return; if (auth) await fetchUsage(auth);
await doFetch(sessionKey, setters); }, [auth, fetchUsage]);
}, [sessionKey]);
// Re-reads the session key from storage and fetches fresh data. Call from const reloadCredentials = useCallback(async () => {
// useFocusEffect so the dashboard stays current when the key is saved from const [key, org] = await Promise.all([
// another tab. If the string value is unchanged, React bails out on setState loadClaudeSessionKey(),
// so we trigger the fetch directly. loadClaudeLastActiveOrg(),
const reload = useCallback(async () => { ]);
const stored = await loadClaudeSessionKey(); if (!key) {
if (!stored) { requestVersion.current += 1;
setSessionKey(null); setAuth(null);
setUsage(null); setUsage(null);
setLastFetchedAt(null);
setStatus("idle"); setStatus("idle");
return; return;
} }
// If already set to the same string, setState bails out and [sessionKey]
// effect won't fire, so call the fetch directly. const stored = {
setSessionKey(stored); sessionKey: key.trim(),
void doFetch(stored, setters); lastActiveOrg: org?.trim() || undefined,
}, []); };
setAuth(stored);
await fetchUsage(stored);
}, [fetchUsage]);
const clearKey = useCallback(async () => { const clearKey = useCallback(async () => {
requestVersion.current += 1;
await clearClaudeSessionKey(); await clearClaudeSessionKey();
setSessionKey(null); setAuth(null);
setUsage(null); setUsage(null);
setLastFetchedAt(null);
setStatus("idle"); setStatus("idle");
setError(null); setError(null);
}, []); }, []);
return { return {
sessionKey, auth,
pendingKey, pendingKey,
setPendingKey, setPendingKey,
pendingOrg,
setPendingOrg,
usage, usage,
lastFetchedAt,
status, status,
error, error,
keyValidationError: pendingKey
? validateClaudeSessionKey(pendingKey)
: null,
canSaveKey: isClaudeSessionKeyValid(pendingKey),
saveKey, saveKey,
refresh, refresh,
reload, reloadCredentials,
clearKey, clearKey,
}; };
} }
+131 -47
View File
@@ -1,54 +1,127 @@
import { useState, useCallback, useEffect } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { fetchCodexUsage } from "@/lib/api/codexApi"; import {
import { loadCodexAuth, saveCodexAuth, clearCodexAuth } from "@/lib/storage"; fetchCodexUsage,
parseCodexUsageResponse,
} from "@/lib/api/codexApi";
import { RETRY_DELAY_MS } from "@/lib/constants";
import { pickAndReadCodexAuth } from "@/lib/fileReader"; 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"; type Status = "idle" | "loading" | "success" | "error";
async function doFetch( function shouldRetry(error: unknown): boolean {
auth: CodexAuth, const message = error instanceof Error ? error.message : "";
set: { return message !== "TOKEN_EXPIRED" && message !== "INVALID_CODEX_RESPONSE";
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 wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
} }
export function useCodexUsage() { export function useCodexUsage() {
const [auth, setAuth] = useState<CodexAuth | null>(null); const [auth, setAuth] = useState<CodexAuth | null>(null);
const [usage, setUsage] = useState<CodexUsageResponse | null>(null); const [usage, setUsage] = useState<CodexUsageResponse | null>(null);
const [lastFetchedAt, setLastFetchedAt] = useState<number | null>(null);
const [status, setStatus] = useState<Status>("idle"); const [status, setStatus] = useState<Status>("idle");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const mounted = useRef(true);
const setters = { auth: setAuth, usage: setUsage, status: setStatus, error: setError }; const inFlight = useRef<{
key: string;
promise: Promise<void>;
} | null>(null);
const requestVersion = useRef(0);
useEffect(() => { useEffect(() => {
loadCodexAuth().then((stored) => { mounted.current = true;
if (stored) setAuth(stored); 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(() => { useEffect(() => {
if (auth) void doFetch(auth, setters); let active = true;
}, [auth]);
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 () => { const importAuthFile = useCallback(async () => {
try { try {
@@ -56,39 +129,50 @@ export function useCodexUsage() {
await saveCodexAuth(parsed); await saveCodexAuth(parsed);
setAuth(parsed); setAuth(parsed);
setError(null); setError(null);
} catch (e: unknown) { await fetchUsage(parsed);
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR"; } catch (caught: unknown) {
if (msg !== "PICKER_CANCELLED") setError(msg); const message = caught instanceof Error ? caught.message : "UNKNOWN_ERROR";
if (message !== "PICKER_CANCELLED") setError(message);
} }
}, []); }, [fetchUsage]);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (!auth) return; if (auth) await fetchUsage(auth);
await doFetch(auth, setters); }, [auth, fetchUsage]);
}, [auth]);
// Re-reads auth from storage and fetches fresh data. Call from useFocusEffect const reloadCredentials = useCallback(async () => {
// so the dashboard stays current when credentials are saved from another tab.
const reload = useCallback(async () => {
const stored = await loadCodexAuth(); const stored = await loadCodexAuth();
if (!stored) { if (!stored) {
requestVersion.current += 1;
setAuth(null); setAuth(null);
setUsage(null); setUsage(null);
setLastFetchedAt(null);
setStatus("idle"); setStatus("idle");
return; 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); setAuth(stored);
}, []); await fetchUsage(stored);
}, [fetchUsage]);
const clearAuth = useCallback(async () => { const clearAuth = useCallback(async () => {
requestVersion.current += 1;
await clearCodexAuth(); await clearCodexAuth();
setAuth(null); setAuth(null);
setUsage(null); setUsage(null);
setLastFetchedAt(null);
setStatus("idle"); setStatus("idle");
setError(null); setError(null);
}, []); }, []);
return { auth, usage, status, error, importAuthFile, refresh, reload, clearAuth }; return {
auth,
usage,
lastFetchedAt,
status,
error,
importAuthFile,
refresh,
reloadCredentials,
clearAuth,
};
} }
+64
View File
@@ -0,0 +1,64 @@
import { useState, useEffect, useCallback } from "react";
import {
loadNotifSettings,
saveNotifSettings,
type NotifSettings,
} from "@/lib/storage";
import {
requestPermissions,
getPermissionStatus,
scheduleDailyDigest,
cancelDailyDigest,
} from "@/lib/notifications";
export function useNotificationSettings() {
const [settings, setSettings] = useState<NotifSettings>({
dailyEnabled: false,
dailyHour: 9,
dailyMinute: 0,
thresholdEnabled: false,
thresholdPct: 20,
});
const [permissionStatus, setPermissionStatus] = useState("undetermined");
const [loaded, setLoaded] = useState(false);
useEffect(() => {
Promise.all([loadNotifSettings(), getPermissionStatus()]).then(
([stored, status]) => {
setSettings(stored);
setPermissionStatus(status);
setLoaded(true);
}
);
}, []);
const askPermissions = useCallback(async (): Promise<boolean> => {
const granted = await requestPermissions();
setPermissionStatus(granted ? "granted" : "denied");
return granted;
}, []);
const update = useCallback(
async (patch: Partial<NotifSettings>) => {
const next = { ...settings, ...patch };
setSettings(next);
await saveNotifSettings(next);
// Keep scheduled notification in sync when toggling or changing time
const dailyChanged =
"dailyEnabled" in patch ||
"dailyHour" in patch ||
"dailyMinute" in patch;
if (dailyChanged) {
if (next.dailyEnabled) {
await scheduleDailyDigest(next.dailyHour, next.dailyMinute, null, null);
} else {
await cancelDailyDigest();
}
}
},
[settings]
);
return { settings, permissionStatus, loaded, askPermissions, update };
}
+100 -17
View File
@@ -1,44 +1,127 @@
import type { ClaudeOrg, ClaudeUsageResponse } from "@/types/claude"; import type { ClaudeAuth, ClaudeOrg, ClaudeUsageResponse } from "@/types/claude";
const BASE_URL = "https://claude.ai/api"; const BASE_URL = "https://claude.ai/api";
function cookieHeader(sessionKey: string): Record<string, string> { const BROWSER_UA =
"Mozilla/5.0 (Linux; Android 10; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36";
function buildHeaders(auth: ClaudeAuth): Record<string, string> {
const cookies = [`sessionKey=${auth.sessionKey}`];
if (auth.lastActiveOrg) cookies.push(`lastActiveOrg=${auth.lastActiveOrg}`);
return { return {
Cookie: `sessionKey=${sessionKey}`, Cookie: cookies.join("; "),
"Accept": "application/json", Accept: "application/json, text/plain, */*",
"Origin": "https://claude.ai", "Accept-Language": "en-US,en;q=0.9",
"User-Agent": BROWSER_UA,
Referer: "https://claude.ai/",
Origin: "https://claude.ai",
"anthropic-client-platform": "web_claude_to_cc_migration",
}; };
} }
export async function fetchClaudeOrgs(sessionKey: string): Promise<ClaudeOrg[]> { 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`, { const response = await fetch(`${BASE_URL}/organizations`, {
headers: cookieHeader(sessionKey), headers: buildHeaders(auth),
credentials: "omit",
}); });
if (response.status === 401 || response.status === 403) {
throw new Error("TOKEN_EXPIRED");
}
if (!response.ok) { if (!response.ok) {
await response.text().catch(() => null);
if (response.status === 401 || response.status === 403) throw new Error("TOKEN_EXPIRED");
throw new Error(`HTTP_ERROR_${response.status}`); 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( export async function fetchClaudeUsage(
sessionKey: string, auth: ClaudeAuth,
orgUuid: string orgUuid: string
): Promise<ClaudeUsageResponse> { ): Promise<ClaudeUsageResponse> {
const response = await fetch(`${BASE_URL}/organizations/${orgUuid}/usage`, { const response = await fetch(`${BASE_URL}/organizations/${orgUuid}/usage`, {
headers: cookieHeader(sessionKey), headers: buildHeaders(auth),
credentials: "omit",
}); });
if (response.status === 401 || response.status === 403) {
throw new Error("TOKEN_EXPIRED");
}
if (!response.ok) { if (!response.ok) {
await response.text().catch(() => null);
if (response.status === 401 || response.status === 403) throw new Error("TOKEN_EXPIRED");
throw new Error(`HTTP_ERROR_${response.status}`); throw new Error(`HTTP_ERROR_${response.status}`);
} }
return response.json() as Promise<ClaudeUsageResponse>; const data: unknown = await response.json();
return parseClaudeUsageResponse(data);
} }
+192 -2
View File
@@ -1,8 +1,86 @@
import type { CodexAuth, CodexUsageResponse } from "@/types/codex"; import type { CodexAuth, CodexUsageResponse } from "@/types/codex";
const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"; 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> = { const headers: Record<string, string> = {
Authorization: `Bearer ${auth.accessToken}`, Authorization: `Bearer ${auth.accessToken}`,
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -12,14 +90,126 @@ export async function fetchCodexUsage(auth: CodexAuth): Promise<CodexUsageRespon
headers["ChatGPT-Account-Id"] = auth.accountId; 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 }); const response = await fetch(USAGE_URL, { headers });
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
throw new Error("TOKEN_EXPIRED"); throw new Error("TOKEN_EXPIRED");
} }
if (!response.ok) { if (!response.ok) {
await response.text().catch(() => null);
throw new Error(`HTTP_ERROR_${response.status}`); 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,
};
} }
+19
View File
@@ -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;
}
+23
View File
@@ -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;
}
+45 -9
View File
@@ -1,20 +1,51 @@
import * as DocumentPicker from "expo-document-picker"; import * as DocumentPicker from "expo-document-picker";
import { File } from "expo-file-system/next"; import * as LegacyFileSystem from "expo-file-system/legacy";
import type { CodexAuth } from "@/types/codex"; 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> { export async function pickAndReadCodexAuth(): Promise<CodexAuth> {
const result = await DocumentPicker.getDocumentAsync({ const result = await DocumentPicker.getDocumentAsync({
type: "application/json", type: "application/json",
copyToCacheDirectory: true, copyToCacheDirectory: true,
}); });
if (result.canceled) { if (result.canceled) throw new Error("PICKER_CANCELLED");
throw new Error("PICKER_CANCELLED");
}
const { uri } = result.assets[0]; const { uri, name } = result.assets[0];
const file = new File(uri); const text = await readPickedTextFile(uri, name);
const text = file.text();
let parsed: Record<string, unknown>; let parsed: Record<string, unknown>;
try { try {
@@ -23,8 +54,13 @@ export async function pickAndReadCodexAuth(): Promise<CodexAuth> {
throw new Error("INVALID_JSON"); throw new Error("INVALID_JSON");
} }
const accessToken = parsed["accessToken"] as string | undefined; const tokens = parsed["tokens"] as Record<string, unknown> | undefined;
const accountId = parsed["accountId"] as string | undefined; const accessToken = (tokens?.["access_token"] ?? parsed["accessToken"]) as string | undefined;
const accountId = (
tokens?.["account_id"] ??
parsed["accountId"] ??
parsed["account_id"]
) as string | undefined;
if (!accessToken || typeof accessToken !== "string") { if (!accessToken || typeof accessToken !== "string") {
throw new Error("MISSING_ACCESS_TOKEN"); throw new Error("MISSING_ACCESS_TOKEN");
+160
View File
@@ -0,0 +1,160 @@
import type * as NotificationsType from "expo-notifications";
import {
loadNotifSettings,
loadNotifThresholdLastFired,
saveNotifThresholdLastFired,
} from "@/lib/storage";
import type { ClaudeUsageResponse } from "@/types/claude";
import type { CodexUsageResponse } from "@/types/codex";
// expo-notifications push notifications were removed from Expo Go in SDK 53.
// Use require() so the initialization error is caught gracefully in Expo Go.
let Notifications: typeof NotificationsType | null = null;
try {
Notifications = require("expo-notifications") as typeof NotificationsType;
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
} catch {
// Running in Expo Go or native module unavailable — notifications disabled.
}
export async function requestPermissions(): Promise<boolean> {
if (!Notifications) return false;
const { status } = await Notifications.requestPermissionsAsync();
return status === "granted";
}
export async function getPermissionStatus(): Promise<string> {
if (!Notifications) return "undetermined";
const { status } = await Notifications.getPermissionsAsync();
return status;
}
const DAILY_ID = "codexbar-daily-digest";
export async function scheduleDailyDigest(
hour: number,
minute: number,
claudeUsage: ClaudeUsageResponse | null,
codexUsage: CodexUsageResponse | null
): Promise<void> {
if (!Notifications) return;
try {
await Notifications.cancelScheduledNotificationAsync(DAILY_ID);
} catch {}
const parts: string[] = [];
if (claudeUsage) {
parts.push(`Claude 7d: ${Math.round(claudeUsage.seven_day.utilization)}% used`);
}
if (codexUsage) {
parts.push(
`Codex: ${Math.round(codexUsage.secondary?.usedPercent ?? 0)}% used`
);
}
await Notifications.scheduleNotificationAsync({
identifier: DAILY_ID,
content: {
title: "Usage Digest",
body:
parts.length > 0
? parts.join(" · ")
: "Open to check your usage limits",
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.CALENDAR,
hour,
minute,
repeats: true,
},
});
}
export async function cancelDailyDigest(): Promise<void> {
if (!Notifications) return;
try {
await Notifications.cancelScheduledNotificationAsync(DAILY_ID);
} catch {}
}
interface ThresholdAlert {
service: string;
window: string;
remaining: number;
}
function buildThresholdAlerts(
thresholdPct: number,
claudeUsage: ClaudeUsageResponse | null,
codexUsage: CodexUsageResponse | null
): ThresholdAlert[] {
const alerts: ThresholdAlert[] = [];
if (claudeUsage) {
const remaining = 100 - claudeUsage.seven_day.utilization;
if (remaining <= thresholdPct) {
alerts.push({ service: "Claude", window: "7-day", remaining });
}
}
if (codexUsage) {
const remaining = 100 - (codexUsage.secondary?.usedPercent ?? 0);
if (remaining <= thresholdPct) {
alerts.push({ service: "Codex", window: "weekly", remaining });
}
}
return alerts;
}
/** Call this after every successful usage fetch. Reschedules daily digest and fires threshold alerts. */
export async function onUsageDataLoaded(
claudeUsage: ClaudeUsageResponse | null,
codexUsage: CodexUsageResponse | null
): Promise<void> {
const settings = await loadNotifSettings();
if (settings.dailyEnabled) {
await scheduleDailyDigest(
settings.dailyHour,
settings.dailyMinute,
claudeUsage,
codexUsage
);
}
if (settings.thresholdEnabled) {
const today = new Date().toISOString().slice(0, 10);
const lastFired = await loadNotifThresholdLastFired();
if (lastFired !== today) {
const alerts = buildThresholdAlerts(
settings.thresholdPct,
claudeUsage,
codexUsage
);
if (alerts.length > 0 && Notifications) {
await Notifications.scheduleNotificationAsync({
content: {
title: "Low quota warning",
body: alerts
.map(
(a) =>
`${a.service} ${a.window}: ${Math.round(a.remaining)}% remaining`
)
.join("\n"),
},
trigger: null,
});
await saveNotifThresholdLastFired(today);
}
}
}
}
+128 -7
View File
@@ -1,38 +1,159 @@
import * as SecureStore from "expo-secure-store"; import * as SecureStore from "expo-secure-store";
import type { ClaudeUsageResponse } from "@/types/claude";
import type { CodexUsageResponse } from "@/types/codex";
const KEYS = { const KEYS = {
CODEX_AUTH: "codexbar_codex_auth", CODEX_AUTH: "codexbar_codex_auth",
CLAUDE_SESSION_KEY: "codexbar_claude_session_key", 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",
NOTIF_THRESHOLD_ENABLED: "notifThresholdEnabled",
NOTIF_THRESHOLD_PCT: "notifThresholdPct",
NOTIF_THRESHOLD_LAST_FIRED: "notifThresholdLastFired",
} as const; } 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;
dailyMinute: number;
thresholdEnabled: boolean;
/** Alert when remaining quota falls below this % (e.g. 20 = fire when < 20% left) */
thresholdPct: number;
}
export async function loadNotifSettings(): Promise<NotifSettings> {
const [de, dh, dm, te, tp] = await Promise.all([
SecureStore.getItemAsync(KEYS.NOTIF_DAILY_ENABLED),
SecureStore.getItemAsync(KEYS.NOTIF_DAILY_HOUR),
SecureStore.getItemAsync(KEYS.NOTIF_DAILY_MINUTE),
SecureStore.getItemAsync(KEYS.NOTIF_THRESHOLD_ENABLED),
SecureStore.getItemAsync(KEYS.NOTIF_THRESHOLD_PCT),
]);
return {
dailyEnabled: de === "true",
dailyHour: dh !== null ? parseInt(dh, 10) : 9,
dailyMinute: dm !== null ? parseInt(dm, 10) : 0,
thresholdEnabled: te === "true",
thresholdPct: tp !== null ? parseInt(tp, 10) : 20,
};
}
export async function saveNotifSettings(s: NotifSettings): Promise<void> {
await Promise.all([
SecureStore.setItemAsync(KEYS.NOTIF_DAILY_ENABLED, String(s.dailyEnabled)),
SecureStore.setItemAsync(KEYS.NOTIF_DAILY_HOUR, String(s.dailyHour)),
SecureStore.setItemAsync(KEYS.NOTIF_DAILY_MINUTE, String(s.dailyMinute)),
SecureStore.setItemAsync(KEYS.NOTIF_THRESHOLD_ENABLED, String(s.thresholdEnabled)),
SecureStore.setItemAsync(KEYS.NOTIF_THRESHOLD_PCT, String(s.thresholdPct)),
]);
}
export async function loadNotifThresholdLastFired(): Promise<string | null> {
return SecureStore.getItemAsync(KEYS.NOTIF_THRESHOLD_LAST_FIRED);
}
export async function saveNotifThresholdLastFired(date: string): Promise<void> {
await SecureStore.setItemAsync(KEYS.NOTIF_THRESHOLD_LAST_FIRED, date);
}
export async function saveCodexAuth(auth: { export async function saveCodexAuth(auth: {
accessToken: string; accessToken: string;
accountId?: string; accountId?: string;
}): Promise<void> { }): Promise<void> {
await SecureStore.setItemAsync(KEYS.CODEX_AUTH, JSON.stringify(auth)); await saveJson(KEYS.CODEX_AUTH, auth);
} }
export async function loadCodexAuth(): Promise<{ export async function loadCodexAuth(): Promise<{
accessToken: string; accessToken: string;
accountId?: string; accountId?: string;
} | null> { } | null> {
const raw = await SecureStore.getItemAsync(KEYS.CODEX_AUTH); return loadJson(KEYS.CODEX_AUTH);
if (!raw) return null; }
return JSON.parse(raw) as { accessToken: string; accountId?: string };
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> { 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> { export async function loadClaudeSessionKey(): Promise<string | null> {
return SecureStore.getItemAsync(KEYS.CLAUDE_SESSION_KEY); return SecureStore.getItemAsync(KEYS.CLAUDE_SESSION_KEY);
} }
export async function saveClaudeLastActiveOrg(orgId: string): Promise<void> {
await SecureStore.setItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG, orgId.trim());
}
export async function loadClaudeLastActiveOrg(): Promise<string | null> {
return SecureStore.getItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG);
}
export async function clearClaudeLastActiveOrg(): Promise<void> {
await SecureStore.deleteItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG);
}
export async function clearCodexAuth(): 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> { export async function clearClaudeSessionKey(): Promise<void> {
await SecureStore.deleteItemAsync(KEYS.CLAUDE_SESSION_KEY); await Promise.all([
SecureStore.deleteItemAsync(KEYS.CLAUDE_SESSION_KEY),
SecureStore.deleteItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG),
clearClaudeUsageCache(),
]);
} }
+47 -6
View File
@@ -5,21 +5,62 @@ export function formatResetCountdown(resetAtSeconds: number): string {
if (diffSeconds <= 0) return "resetting now"; if (diffSeconds <= 0) return "resetting now";
if (diffSeconds < 60) return "resets in <1m"; if (diffSeconds < 60) return "resets in <1m";
const hours = Math.floor(diffSeconds / 3600); const totalHours = Math.floor(diffSeconds / 3600);
const minutes = Math.floor((diffSeconds % 3600) / 60); const minutes = Math.floor((diffSeconds % 3600) / 60);
if (hours > 0 && minutes > 0) return `resets in ${hours}h ${minutes}m`; if (totalHours >= 24) {
if (hours > 0) return `resets in ${hours}h`; const days = Math.floor(totalHours / 24);
const hours = totalHours % 24;
if (hours > 0) return `resets in ${days}d ${hours}h`;
return `resets in ${days}d`;
}
if (totalHours > 0 && minutes > 0) return `resets in ${totalHours}h ${minutes}m`;
if (totalHours > 0) return `resets in ${totalHours}h`;
return `resets in ${minutes}m`; 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 { export function formatResetCountdownISO(isoString: string): string {
const resetAtSeconds = Math.floor(new Date(isoString).getTime() / 1000); const resetAtSeconds = Math.floor(new Date(isoString).getTime() / 1000);
return formatResetCountdown(resetAtSeconds); return formatResetCountdown(resetAtSeconds);
} }
export function windowLabel(limitWindowSeconds: number): string { export function windowLabel(limitWindowSeconds: number): string {
if (limitWindowSeconds <= 3600) return "1h window"; const hours = limitWindowSeconds / 3600;
if (limitWindowSeconds <= 86400) return "24h window"; if (hours < 24) return `${Math.round(hours)}h`;
return `${Math.round(limitWindowSeconds / 3600)}h window`; const days = hours / 24;
const wholeDays = Math.floor(days);
const remainderHours = Math.round(hours - wholeDays * 24);
if (remainderHours === 0) return `${wholeDays}d`;
return `${wholeDays}d ${remainderHours}h`;
} }
+691 -417
View File
File diff suppressed because it is too large Load Diff
+15 -6
View File
@@ -3,21 +3,30 @@
"version": "1.0.0", "version": "1.0.0",
"main": "expo-router/entry", "main": "expo-router/entry",
"dependencies": { "dependencies": {
"@expo/metro-runtime": "~56.0.15",
"@expo/vector-icons": "^15.1.1", "@expo/vector-icons": "^15.1.1",
"babel-preset-expo": "~56.0.0", "babel-preset-expo": "~56.0.0",
"expo": "~56.0.12", "expo": "~56.0.14",
"expo-constants": "~56.0.18", "expo-constants": "~56.0.20",
"expo-document-picker": "~56.0.4", "expo-document-picker": "~56.0.4",
"expo-file-system": "~56.0.8", "expo-file-system": "~56.0.8",
"expo-linking": "~56.0.14", "expo-font": "~56.0.7",
"expo-router": "~56.2.11", "expo-linking": "~56.0.15",
"expo-notifications": "~56.0.19",
"expo-router": "~56.2.13",
"expo-secure-store": "~56.0.4", "expo-secure-store": "~56.0.4",
"expo-splash-screen": "~56.0.10",
"expo-status-bar": "~56.0.4", "expo-status-bar": "~56.0.4",
"expo-system-ui": "~56.0.5",
"nativewind": "^4.2.6", "nativewind": "^4.2.6",
"react": "^19.2.7", "react": "19.2.3",
"react-dom": "^19.2.3",
"react-native": "0.85.3", "react-native": "0.85.3",
"react-native-reanimated": "4.3.1",
"react-native-safe-area-context": "~5.7.0", "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": { "devDependencies": {
"@types/react": "~19.2.2", "@types/react": "~19.2.2",
+5
View File
@@ -1,3 +1,8 @@
export interface ClaudeAuth {
sessionKey: string;
lastActiveOrg?: string;
}
export interface ClaudeOrg { export interface ClaudeOrg {
uuid: string; uuid: string;
name: string; name: string;
+47 -13
View File
@@ -3,21 +3,55 @@ export interface CodexAuth {
accountId?: string; accountId?: string;
} }
export interface UsageWindow { export interface CodexUsageWindow {
used_percent: number; usedPercent: number;
reset_at: number; resetsAt: number;
limit_window_seconds: number; windowMinutes: number;
windowSeconds: number;
} }
export interface CodexUsageResponse { export interface CodexCreditsSummary {
plan_type: string; hasCredits: boolean;
rate_limit: {
primary_window: UsageWindow;
secondary_window: UsageWindow;
};
credits: {
has_credits: boolean;
unlimited: boolean; unlimited: boolean;
balance: number; 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;
} }