diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index 6b7beea..2fbe37a 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -1,16 +1,39 @@
import { Tabs } from "expo-router";
+import { useColorScheme } from "react-native";
import { MaterialIcons } from "@expo/vector-icons";
export default function TabLayout() {
+ const colorScheme = useColorScheme();
+ const isDark = colorScheme === "dark";
+
return (
+ (
+
+ ),
+ }}
+ />
+ (
+
+ ),
+ }}
+ />
);
}
diff --git a/app/(tabs)/claude.tsx b/app/(tabs)/claude.tsx
index 20e83c9..00dfc4d 100644
--- a/app/(tabs)/claude.tsx
+++ b/app/(tabs)/claude.tsx
@@ -10,8 +10,8 @@ import {
Platform,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
+import { MaterialIcons } from "@expo/vector-icons";
import { useClaudeUsage } from "@/hooks/useClaudeUsage";
-import { SectionCard } from "@/components/SectionCard";
import { UsageStat } from "@/components/UsageStat";
import { ErrorMessage } from "@/components/ErrorMessage";
@@ -31,13 +31,17 @@ export default function ClaudeTab() {
const canSave = pendingKey.trim().startsWith("sk-ant-");
return (
-
+
}
- contentContainerStyle={{ paddingBottom: 32 }}
>
- {/* Configure */}
-
+ {/* Header */}
+
+
+
+
+
+
+ Claude.ai
+
+
+ Usage windows
+
+
+
+
+ {/* Configuration card */}
+
+
+ Configuration
+
{sessionKey ? (
-
+
-
- Configured
+
+ Connected
+
+
+ {sessionKey.slice(0, 10)}…
-
+
Clear
@@ -79,39 +106,53 @@ export default function ClaudeTab() {
placeholderTextColor="#a3a3a3"
autoCapitalize="none"
autoCorrect={false}
- className="border border-neutral-200 dark:border-neutral-700 rounded-xl px-3 py-2.5 text-sm text-neutral-900 dark:text-white bg-white dark:bg-neutral-800 mb-3"
+ 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-3"
/>
Save Session Key
-
- Chrome DevTools → Application → Cookies → claude.ai → sessionKey
+
+
+ Where to find it
+
+
+ Chrome DevTools → Application → Cookies → claude.ai →{" "}
+
+ sessionKey
+
+
+
+
+ )}
+
+
+ {/* Usage card */}
+
+
+ Usage
+
+
+ {status === "idle" && !sessionKey && (
+
+
+
+ Enter your session key to see usage windows
)}
-
-
- {/* Usage */}
-
- {status === "idle" && !sessionKey && (
-
- Enter your Claude session key to see usage
-
- )}
{status === "loading" && (
-
+
)}
{status === "error" && error && }
{status === "success" && usage && (
@@ -121,7 +162,6 @@ export default function ClaudeTab() {
percent={usage.five_hour.utilization}
resetAtISO={usage.five_hour.resets_at}
/>
-
{(usage.seven_day_sonnet || usage.seven_day_opus) && (
-
+
By Model (7-day)
@@ -149,7 +189,7 @@ export default function ClaudeTab() {
)}
)}
-
+
diff --git a/app/(tabs)/codex.tsx b/app/(tabs)/codex.tsx
index fc4f6af..9b6405f 100644
--- a/app/(tabs)/codex.tsx
+++ b/app/(tabs)/codex.tsx
@@ -7,8 +7,8 @@ import {
RefreshControl,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
+import { MaterialIcons } from "@expo/vector-icons";
import { useCodexUsage } from "@/hooks/useCodexUsage";
-import { SectionCard } from "@/components/SectionCard";
import { UsageStat } from "@/components/UsageStat";
import { ErrorMessage } from "@/components/ErrorMessage";
import { windowLabel } from "@/lib/timeUtils";
@@ -18,9 +18,13 @@ export default function CodexTab() {
useCodexUsage();
return (
-
+
}
- contentContainerStyle={{ paddingBottom: 32 }}
>
- {/* Configure */}
-
+ {/* Header */}
+
+
+
+
+
+
+ Codex CLI
+
+
+ Rate limits & credits
+
+
+
+
+ {/* Configuration card */}
+
+
+ Configuration
+
{auth ? (
-
+
-
- Configured
+
+ Connected
{auth.accountId && (
-
- ({auth.accountId.slice(0, 8)}…)
+
+ {auth.accountId.slice(0, 10)}…
)}
@@ -50,7 +74,7 @@ export default function CodexTab() {
onPress={clearAuth}
className="px-3 py-1.5 rounded-lg bg-neutral-100 dark:bg-neutral-800"
>
-
+
Clear
@@ -59,8 +83,10 @@ export default function CodexTab() {
+
Import auth.json
@@ -70,25 +96,30 @@ export default function CodexTab() {
)}
-
+
+
+ {/* Usage card */}
+
+
+ Usage
+
- {/* Usage */}
-
{status === "idle" && !auth && (
-
- Import your auth.json to see usage
-
+
+
+
+ Import auth.json to see your rate limits
+
+
)}
{status === "loading" && (
-
- )}
- {status === "error" && error && (
-
+
)}
+ {status === "error" && error && }
{status === "success" && usage && (
{/* Plan badge */}
-
+
{usage.plan_type}
@@ -101,7 +132,6 @@ export default function CodexTab() {
percent={usage.rate_limit.primary_window.used_percent}
resetAtSeconds={usage.rate_limit.primary_window.reset_at}
/>
-
{/* Credits */}
-
+
Credits
{usage.credits.unlimited ? (
-
- Unlimited
-
+
+
+
+ Unlimited
+
+
) : usage.credits.has_credits ? (
${Number(usage.credits.balance).toFixed(2)} remaining
@@ -127,7 +160,7 @@ export default function CodexTab() {
)}
-
+
);
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
new file mode 100644
index 0000000..d293015
--- /dev/null
+++ b/app/(tabs)/index.tsx
@@ -0,0 +1,148 @@
+import {
+ ScrollView,
+ View,
+ Text,
+ TouchableOpacity,
+ RefreshControl,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { router, useFocusEffect } from "expo-router";
+import { useCallback } from "react";
+import { MaterialIcons } from "@expo/vector-icons";
+import { useCodexUsage } from "@/hooks/useCodexUsage";
+import { useClaudeUsage } from "@/hooks/useClaudeUsage";
+import { ServiceStatusCard } from "@/components/ServiceStatusCard";
+import { windowLabel } from "@/lib/timeUtils";
+
+export default function DashboardTab() {
+ const codex = useCodexUsage();
+ const claude = useClaudeUsage();
+
+ useFocusEffect(
+ useCallback(() => {
+ codex.reload();
+ claude.reload();
+ }, [])
+ );
+
+ const isRefreshing =
+ codex.status === "loading" || claude.status === "loading";
+
+ const handleRefresh = () => {
+ codex.refresh();
+ claude.refresh();
+ };
+
+ const codexRows =
+ 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 (
+
+
+ }
+ >
+ {/* Header */}
+
+
+
+ Codexbar
+
+
+ AI usage monitor
+
+
+
+
+
+
+
+ {/* Codex card */}
+ router.navigate("/(tabs)/codex")}
+ footer={
+ codex.usage && !codex.usage.credits.unlimited && codex.usage.credits.has_credits
+ ? (
+
+
+
+ ${Number(codex.usage.credits.balance).toFixed(2)} credits remaining
+
+
+ )
+ : codex.usage?.credits.unlimited
+ ? (
+
+
+
+ Unlimited credits
+
+
+ )
+ : null
+ }
+ />
+
+ {/* Claude card */}
+ router.navigate("/(tabs)/claude")}
+ />
+
+
+ );
+}
diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx
new file mode 100644
index 0000000..479b8d9
--- /dev/null
+++ b/app/(tabs)/settings.tsx
@@ -0,0 +1,266 @@
+import { useEffect, useState, useCallback } from "react";
+import { View, Text, TouchableOpacity, Alert, ScrollView } from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { router, useFocusEffect } from "expo-router";
+import { MaterialIcons } from "@expo/vector-icons";
+import {
+ loadCodexAuth,
+ clearCodexAuth,
+ loadClaudeSessionKey,
+ clearClaudeSessionKey,
+} from "@/lib/storage";
+import { resetOnboarding } from "@/lib/setupState";
+import type { CodexAuth } from "@/types/codex";
+import Constants from "expo-constants";
+
+function SettingRow({
+ icon,
+ label,
+ value,
+ onPress,
+ destructive,
+ disabled,
+}: {
+ icon: React.ComponentProps["name"];
+ label: string;
+ value?: string;
+ onPress?: () => void;
+ destructive?: boolean;
+ disabled?: boolean;
+}) {
+ return (
+
+
+
+
+
+
+ {label}
+
+ {value && (
+
+ {value}
+
+ )}
+
+ {onPress && !disabled && (
+
+ )}
+
+ );
+}
+
+function SectionHeader({ title }: { title: string }) {
+ return (
+
+ {title}
+
+ );
+}
+
+function SectionCard({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function Divider() {
+ return (
+
+ );
+}
+
+export default function SettingsTab() {
+ const [codexAuth, setCodexAuth] = useState(null);
+ const [claudeKey, setClaudeKey] = useState(null);
+
+ const reload = useCallback(() => {
+ Promise.all([loadCodexAuth(), loadClaudeSessionKey()]).then(
+ ([codex, claude]) => {
+ setCodexAuth(codex);
+ setClaudeKey(claude);
+ }
+ );
+ }, []);
+
+ useEffect(() => {
+ reload();
+ }, [reload]);
+
+ useFocusEffect(useCallback(() => {
+ reload();
+ }, [reload]));
+
+ const handleClearCodex = () => {
+ Alert.alert(
+ "Clear Codex credentials",
+ "This will remove your stored auth.json data. You can re-import it anytime.",
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Clear",
+ style: "destructive",
+ onPress: async () => {
+ await clearCodexAuth();
+ setCodexAuth(null);
+ },
+ },
+ ]
+ );
+ };
+
+ const handleClearClaude = () => {
+ Alert.alert(
+ "Clear Claude session key",
+ "Your session key will be removed. You can re-enter it anytime.",
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Clear",
+ style: "destructive",
+ onPress: async () => {
+ await clearClaudeSessionKey();
+ setClaudeKey(null);
+ },
+ },
+ ]
+ );
+ };
+
+ const handleResetSetup = () => {
+ Alert.alert(
+ "Re-run Setup Wizard",
+ "This will clear all credentials and restart the setup flow.",
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Reset & Re-run",
+ style: "destructive",
+ onPress: async () => {
+ await Promise.all([
+ clearCodexAuth(),
+ clearClaudeSessionKey(),
+ resetOnboarding(),
+ ]);
+ router.replace("/onboarding");
+ },
+ },
+ ]
+ );
+ };
+
+ const appVersion = Constants.expoConfig?.version ?? "1.0.0";
+
+ return (
+
+
+ {/* Header */}
+
+
+ Settings
+
+
+
+ {/* Codex CLI section */}
+
+
+ {codexAuth ? (
+ <>
+
+
+
+ >
+ ) : (
+ router.navigate("/(tabs)/codex")}
+ />
+ )}
+
+
+ {/* Claude.ai section */}
+
+
+ {claudeKey ? (
+ <>
+
+
+
+ >
+ ) : (
+ router.navigate("/(tabs)/claude")}
+ />
+ )}
+
+
+ {/* App section */}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/index.tsx b/app/index.tsx
index fac780b..facf87e 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -1,5 +1,18 @@
-import { Redirect } from "expo-router";
+import { useEffect } from "react";
+import { View, ActivityIndicator } from "react-native";
+import { router } from "expo-router";
+import { hasCompletedOnboarding } from "@/lib/setupState";
export default function Index() {
- return ;
+ useEffect(() => {
+ hasCompletedOnboarding().then((done) => {
+ router.replace(done ? "/(tabs)" : "/onboarding");
+ });
+ }, []);
+
+ return (
+
+
+
+ );
}
diff --git a/app/onboarding/_layout.tsx b/app/onboarding/_layout.tsx
new file mode 100644
index 0000000..e30c6af
--- /dev/null
+++ b/app/onboarding/_layout.tsx
@@ -0,0 +1,12 @@
+import { Stack } from "expo-router";
+
+export default function OnboardingLayout() {
+ return (
+
+ );
+}
diff --git a/app/onboarding/claude.tsx b/app/onboarding/claude.tsx
new file mode 100644
index 0000000..e50cc90
--- /dev/null
+++ b/app/onboarding/claude.tsx
@@ -0,0 +1,189 @@
+import { useState, useCallback, useEffect } from "react";
+import {
+ View,
+ Text,
+ TouchableOpacity,
+ TextInput,
+ KeyboardAvoidingView,
+ Platform,
+ ScrollView,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { router } from "expo-router";
+import { MaterialIcons } from "@expo/vector-icons";
+import { saveClaudeSessionKey, loadClaudeSessionKey } from "@/lib/storage";
+
+export default function OnboardingClaudeScreen() {
+ const [savedKey, setSavedKey] = useState(null);
+ const [pendingKey, setPendingKey] = useState("");
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ loadClaudeSessionKey().then((k) => {
+ if (k) setSavedKey(k);
+ });
+ }, []);
+
+ const canSave = pendingKey.trim().startsWith("sk-ant-");
+
+ const handleSave = useCallback(async () => {
+ const trimmed = pendingKey.trim();
+ if (!trimmed.startsWith("sk-ant-")) {
+ setError("Session key must start with sk-ant-");
+ return;
+ }
+ await saveClaudeSessionKey(trimmed);
+ setSavedKey(trimmed);
+ setPendingKey("");
+ setError(null);
+ }, [pendingKey]);
+
+ return (
+
+
+
+
+ {/* Top: back + step indicator */}
+
+ router.back()}
+ className="w-9 h-9 rounded-xl bg-neutral-900 items-center justify-center"
+ >
+
+
+
+
+
+
+
+
+ 2
+
+
+
+
+
+ {/* Content */}
+
+
+
+
+
+ Connect Claude.ai
+
+
+ Enter your session key to monitor your Claude.ai usage windows.
+
+
+ {savedKey ? (
+
+
+
+
+
+ Connected
+
+ {savedKey.slice(0, 16)}…
+
+
+ {
+ setSavedKey(null);
+ setPendingKey("");
+ }}
+ >
+ Change
+
+
+ ) : (
+
+ {error && (
+
+ {error}
+
+ )}
+ {
+ setPendingKey(t);
+ setError(null);
+ }}
+ placeholder="sk-ant-..."
+ placeholderTextColor="#525252"
+ autoCapitalize="none"
+ autoCorrect={false}
+ secureTextEntry
+ className="border border-neutral-800 rounded-xl px-4 py-3.5 text-sm text-white bg-neutral-900 mb-3"
+ />
+
+
+ Save Session Key
+
+
+
+
+ How to find your session key
+
+
+ Open Claude.ai in Chrome → DevTools (F12) → Application → Cookies → claude.ai → find{" "}
+ sessionKey
+
+
+
+ )}
+
+
+ {/* Bottom actions */}
+
+ router.push("/onboarding/done")}
+ className="rounded-2xl py-4 items-center"
+ style={{ backgroundColor: savedKey ? "#d97706" : "#1a1a1a" }}
+ >
+
+ Continue
+
+
+ router.push("/onboarding/done")}
+ className="py-3 items-center"
+ >
+ Skip for now
+
+
+
+
+
+
+ );
+}
diff --git a/app/onboarding/codex.tsx b/app/onboarding/codex.tsx
new file mode 100644
index 0000000..f35648f
--- /dev/null
+++ b/app/onboarding/codex.tsx
@@ -0,0 +1,154 @@
+import { useState, useCallback, useEffect } from "react";
+import { View, Text, TouchableOpacity, ActivityIndicator } from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { router } from "expo-router";
+import { MaterialIcons } from "@expo/vector-icons";
+import { pickAndReadCodexAuth } from "@/lib/fileReader";
+import { saveCodexAuth, loadCodexAuth } from "@/lib/storage";
+import type { CodexAuth } from "@/types/codex";
+
+export default function OnboardingCodexScreen() {
+ const [auth, setAuth] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ loadCodexAuth().then((stored) => {
+ if (stored) setAuth(stored);
+ });
+ }, []);
+
+ const importFile = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const parsed = await pickAndReadCodexAuth();
+ await saveCodexAuth(parsed);
+ setAuth(parsed);
+ } catch (e: unknown) {
+ const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
+ if (msg !== "PICKER_CANCELLED") setError(msg);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ return (
+
+
+ {/* Top: back + step indicator */}
+
+ router.back()}
+ className="w-9 h-9 rounded-xl bg-neutral-900 items-center justify-center"
+ >
+
+
+
+
+ 1
+
+
+
+ 2
+
+
+
+
+
+ {/* Content */}
+
+
+
+
+
+ Connect Codex CLI
+
+
+ Import your{" "}
+
+ ~/.codex/auth.json
+ {" "}
+ file to track your rate limits and credits.
+
+
+ {/* Status / action */}
+ {auth ? (
+
+
+
+
+
+ Connected
+ {auth.accountId && (
+
+ {auth.accountId.slice(0, 12)}…
+
+ )}
+
+
+ Re-import
+
+
+ ) : (
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {loading ? (
+
+ ) : (
+
+
+
+ Import auth.json
+
+
+ )}
+
+
+ Located at ~/.codex/auth.json on your Mac
+
+
+ )}
+
+
+ {/* Bottom actions */}
+
+ router.push("/onboarding/claude")}
+ className="rounded-2xl py-4 items-center"
+ style={{ backgroundColor: auth ? "#10a37f" : "#1a1a1a" }}
+ >
+
+ Continue
+
+
+ router.push("/onboarding/claude")}
+ className="py-3 items-center"
+ >
+ Skip for now
+
+
+
+
+ );
+}
diff --git a/app/onboarding/done.tsx b/app/onboarding/done.tsx
new file mode 100644
index 0000000..a18d4de
--- /dev/null
+++ b/app/onboarding/done.tsx
@@ -0,0 +1,120 @@
+import { useEffect, useState } from "react";
+import { View, Text, TouchableOpacity } from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { router } from "expo-router";
+import { MaterialIcons } from "@expo/vector-icons";
+import { markOnboardingComplete } from "@/lib/setupState";
+import { loadCodexAuth, loadClaudeSessionKey } from "@/lib/storage";
+
+export default function OnboardingDoneScreen() {
+ const [codexConfigured, setCodexConfigured] = useState(false);
+ const [claudeConfigured, setClaudeConfigured] = useState(false);
+
+ useEffect(() => {
+ Promise.all([loadCodexAuth(), loadClaudeSessionKey()]).then(
+ ([codex, claude]) => {
+ setCodexConfigured(!!codex);
+ setClaudeConfigured(!!claude);
+ }
+ );
+ }, []);
+
+ const handleEnterApp = async () => {
+ await markOnboardingComplete();
+ router.replace("/(tabs)");
+ };
+
+ const noneConfigured = !codexConfigured && !claudeConfigured;
+
+ return (
+
+
+ {/* Icon */}
+
+
+
+
+ {/* Title */}
+
+ {noneConfigured ? "Almost there" : "You're all set!"}
+
+
+ {noneConfigured
+ ? "No services configured yet. You can set them up anytime in Settings."
+ : "Your services are connected and ready to monitor."}
+
+
+ {/* Summary cards */}
+
+
+
+
+
+
+
+ Codex CLI
+
+
+ {codexConfigured ? "Connected" : "Not configured"}
+
+
+
+
+
+
+
+
+
+
+ Claude.ai
+
+
+ {claudeConfigured ? "Connected" : "Not configured"}
+
+
+
+
+
+ {/* CTA */}
+
+ Open App
+
+
+
+ );
+}
diff --git a/app/onboarding/index.tsx b/app/onboarding/index.tsx
new file mode 100644
index 0000000..51f4321
--- /dev/null
+++ b/app/onboarding/index.tsx
@@ -0,0 +1,82 @@
+import { View, Text, TouchableOpacity } from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { router } from "expo-router";
+import { MaterialIcons } from "@expo/vector-icons";
+
+export default function WelcomeScreen() {
+ return (
+
+
+ {/* Logo + title */}
+
+
+
+
+
+
+
+
+
+
+ Codexbar
+
+
+ Monitor your Codex CLI and Claude.ai{"\n"}usage limits at a glance.
+
+
+
+ {/* Feature pills */}
+
+
+
+
+
+
+ Codex CLI
+
+ Rate limits · Credits · Windows
+
+
+
+
+
+
+
+
+ Claude.ai
+
+ 5-hour · 7-day · Model usage
+
+
+
+
+
+ {/* CTA */}
+
+ router.push("/onboarding/codex")}
+ className="rounded-2xl py-4 items-center"
+ style={{ backgroundColor: "#10a37f" }}
+ >
+ Get Started
+
+
+ You can configure services at any time in Settings
+
+
+
+
+ );
+}
diff --git a/components/ServiceStatusCard.tsx b/components/ServiceStatusCard.tsx
new file mode 100644
index 0000000..323d4b9
--- /dev/null
+++ b/components/ServiceStatusCard.tsx
@@ -0,0 +1,124 @@
+import { View, Text, TouchableOpacity, ActivityIndicator } from "react-native";
+import { MaterialIcons } from "@expo/vector-icons";
+import { ProgressBar } from "./ProgressBar";
+import { ResetCountdown } from "./ResetCountdown";
+
+interface UsageRow {
+ label: string;
+ percent: number;
+ resetAtSeconds?: number;
+ resetAtISO?: string;
+}
+
+interface ServiceStatusCardProps {
+ title: string;
+ icon: React.ComponentProps["name"];
+ accentColor: string;
+ status: "idle" | "loading" | "success" | "error";
+ badge?: string;
+ rows?: UsageRow[];
+ footer?: React.ReactNode;
+ unconfiguredLabel?: string;
+ onConfigurePress?: () => void;
+}
+
+export function ServiceStatusCard({
+ title,
+ icon,
+ accentColor,
+ status,
+ badge,
+ rows,
+ footer,
+ unconfiguredLabel,
+ onConfigurePress,
+}: ServiceStatusCardProps) {
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+ {title}
+
+
+ {badge && (
+
+
+ {badge}
+
+
+ )}
+ {status === "success" && !badge && (
+
+ )}
+ {status === "error" && (
+
+ )}
+
+
+ {/* Body */}
+ {status === "loading" && (
+
+ )}
+
+ {status === "idle" && (
+
+
+ {unconfiguredLabel ?? "Not configured"}
+
+ {onConfigurePress && (
+
+
+ Configure →
+
+
+ )}
+
+ )}
+
+ {status === "error" && (
+
+ Failed to load usage data
+
+ )}
+
+ {status === "success" && rows && (
+
+ {rows.map((row, i) => (
+
+
+
+ {row.label}
+
+
+ {Math.round(row.percent)}%
+
+
+
+
+
+ ))}
+
+ )}
+
+ {footer && (
+
+ {footer}
+
+ )}
+
+ );
+}
diff --git a/hooks/useClaudeUsage.ts b/hooks/useClaudeUsage.ts
index f5867be..9e93eb2 100644
--- a/hooks/useClaudeUsage.ts
+++ b/hooks/useClaudeUsage.ts
@@ -9,6 +9,35 @@ import type { ClaudeUsageResponse } from "@/types/claude";
type Status = "idle" | "loading" | "success" | "error";
+async function doFetch(
+ sessionKey: string,
+ set: {
+ sessionKey: (v: string | null) => void;
+ usage: (v: ClaudeUsageResponse | null) => void;
+ status: (v: Status) => void;
+ error: (v: string | null) => void;
+ }
+) {
+ set.status("loading");
+ set.error(null);
+ try {
+ const orgs = await fetchClaudeOrgs(sessionKey);
+ if (!orgs.length) throw new Error("NO_ORGS_FOUND");
+ const orgUuid = orgs[0].uuid;
+ const data = await fetchClaudeUsage(sessionKey, 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);
+ }
+ }
+}
+
export function useClaudeUsage() {
const [sessionKey, setSessionKey] = useState(null);
const [pendingKey, setPendingKey] = useState("");
@@ -16,12 +45,18 @@ export function useClaudeUsage() {
const [status, setStatus] = useState("idle");
const [error, setError] = useState(null);
+ const setters = { sessionKey: setSessionKey, usage: setUsage, status: setStatus, error: setError };
+
useEffect(() => {
loadClaudeSessionKey().then((stored) => {
if (stored) setSessionKey(stored);
});
}, []);
+ useEffect(() => {
+ if (sessionKey) void doFetch(sessionKey, setters);
+ }, [sessionKey]);
+
const saveKey = useCallback(async () => {
const trimmed = pendingKey.trim();
if (!trimmed.startsWith("sk-ant-")) {
@@ -36,29 +71,26 @@ export function useClaudeUsage() {
const refresh = useCallback(async () => {
if (!sessionKey) return;
- setStatus("loading");
- setError(null);
- try {
- const orgs = await fetchClaudeOrgs(sessionKey);
- if (!orgs.length) throw new Error("NO_ORGS_FOUND");
- const orgUuid = orgs[0].uuid;
- const data = await fetchClaudeUsage(sessionKey, orgUuid);
- setUsage(data);
- setStatus("success");
- } catch (e: unknown) {
- const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
- setError(msg);
- setStatus("error");
- if (msg === "TOKEN_EXPIRED") {
- await clearClaudeSessionKey();
- setSessionKey(null);
- }
- }
+ await doFetch(sessionKey, setters);
}, [sessionKey]);
- useEffect(() => {
- if (sessionKey) void refresh();
- }, [sessionKey]);
+ // Re-reads the session key from storage and fetches fresh data. Call from
+ // useFocusEffect so the dashboard stays current when the key is saved from
+ // another tab. If the string value is unchanged, React bails out on setState
+ // so we trigger the fetch directly.
+ const reload = useCallback(async () => {
+ const stored = await loadClaudeSessionKey();
+ if (!stored) {
+ setSessionKey(null);
+ setUsage(null);
+ setStatus("idle");
+ return;
+ }
+ // If already set to the same string, setState bails out and [sessionKey]
+ // effect won't fire, so call the fetch directly.
+ setSessionKey(stored);
+ void doFetch(stored, setters);
+ }, []);
const clearKey = useCallback(async () => {
await clearClaudeSessionKey();
@@ -77,6 +109,7 @@ export function useClaudeUsage() {
error,
saveKey,
refresh,
+ reload,
clearKey,
};
}
diff --git a/hooks/useCodexUsage.ts b/hooks/useCodexUsage.ts
index 9c5b607..87079a1 100644
--- a/hooks/useCodexUsage.ts
+++ b/hooks/useCodexUsage.ts
@@ -6,18 +6,50 @@ import type { CodexUsageResponse, CodexAuth } from "@/types/codex";
type Status = "idle" | "loading" | "success" | "error";
+async function doFetch(
+ auth: CodexAuth,
+ set: {
+ auth: (v: CodexAuth | null) => void;
+ usage: (v: CodexUsageResponse | null) => void;
+ status: (v: Status) => void;
+ error: (v: string | null) => void;
+ }
+) {
+ set.status("loading");
+ set.error(null);
+ try {
+ const data = await fetchCodexUsage(auth);
+ set.usage(data);
+ set.status("success");
+ } catch (e: unknown) {
+ const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
+ set.error(msg);
+ set.status("error");
+ if (msg === "TOKEN_EXPIRED") {
+ await clearCodexAuth();
+ set.auth(null);
+ }
+ }
+}
+
export function useCodexUsage() {
const [auth, setAuth] = useState(null);
const [usage, setUsage] = useState(null);
const [status, setStatus] = useState("idle");
const [error, setError] = useState(null);
+ const setters = { auth: setAuth, usage: setUsage, status: setStatus, error: setError };
+
useEffect(() => {
loadCodexAuth().then((stored) => {
if (stored) setAuth(stored);
});
}, []);
+ useEffect(() => {
+ if (auth) void doFetch(auth, setters);
+ }, [auth]);
+
const importAuthFile = useCallback(async () => {
try {
const parsed = await pickAndReadCodexAuth();
@@ -32,26 +64,23 @@ export function useCodexUsage() {
const refresh = useCallback(async () => {
if (!auth) return;
- setStatus("loading");
- setError(null);
- try {
- const data = await fetchCodexUsage(auth);
- setUsage(data);
- setStatus("success");
- } catch (e: unknown) {
- const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
- setError(msg);
- setStatus("error");
- if (msg === "TOKEN_EXPIRED") {
- await clearCodexAuth();
- setAuth(null);
- }
- }
+ await doFetch(auth, setters);
}, [auth]);
- useEffect(() => {
- if (auth) void refresh();
- }, [auth]);
+ // Re-reads auth from storage and fetches fresh data. Call from useFocusEffect
+ // so the dashboard stays current when credentials are saved from another tab.
+ const reload = useCallback(async () => {
+ const stored = await loadCodexAuth();
+ if (!stored) {
+ setAuth(null);
+ setUsage(null);
+ setStatus("idle");
+ return;
+ }
+ // Always pass a fresh object so the [auth] effect fires even if the content
+ // is identical (JSON.parse returns a new reference each time, which is fine).
+ setAuth(stored);
+ }, []);
const clearAuth = useCallback(async () => {
await clearCodexAuth();
@@ -61,5 +90,5 @@ export function useCodexUsage() {
setError(null);
}, []);
- return { auth, usage, status, error, importAuthFile, refresh, clearAuth };
+ return { auth, usage, status, error, importAuthFile, refresh, reload, clearAuth };
}
diff --git a/lib/setupState.ts b/lib/setupState.ts
new file mode 100644
index 0000000..f1e05f4
--- /dev/null
+++ b/lib/setupState.ts
@@ -0,0 +1,16 @@
+import * as SecureStore from "expo-secure-store";
+
+const KEY = "codexbar_onboarding_v1";
+
+export async function hasCompletedOnboarding(): Promise {
+ const val = await SecureStore.getItemAsync(KEY);
+ return val === "done";
+}
+
+export async function markOnboardingComplete(): Promise {
+ await SecureStore.setItemAsync(KEY, "done");
+}
+
+export async function resetOnboarding(): Promise {
+ await SecureStore.deleteItemAsync(KEY);
+}