diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 11088a3c..755569de 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -53,6 +53,8 @@ import { SettingsService } from './services/settings-service.js'; import { createSpecRegenerationRoutes } from './routes/app-spec/index.js'; import { createClaudeRoutes } from './routes/claude/index.js'; import { ClaudeUsageService } from './services/claude-usage-service.js'; +import { createCodexRoutes } from './routes/codex/index.js'; +import { CodexUsageService } from './services/codex-usage-service.js'; import { createGitHubRoutes } from './routes/github/index.js'; import { createContextRoutes } from './routes/context/index.js'; import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js'; @@ -166,6 +168,7 @@ const agentService = new AgentService(DATA_DIR, events, settingsService); const featureLoader = new FeatureLoader(); const autoModeService = new AutoModeService(events, settingsService); const claudeUsageService = new ClaudeUsageService(); +const codexUsageService = new CodexUsageService(); const mcpTestService = new MCPTestService(settingsService); const ideationService = new IdeationService(events, settingsService, featureLoader); @@ -216,6 +219,7 @@ app.use('/api/templates', createTemplatesRoutes()); app.use('/api/terminal', createTerminalRoutes()); app.use('/api/settings', createSettingsRoutes(settingsService)); app.use('/api/claude', createClaudeRoutes(claudeUsageService)); +app.use('/api/codex', createCodexRoutes(codexUsageService)); app.use('/api/github', createGitHubRoutes(events, settingsService)); app.use('/api/context', createContextRoutes(settingsService)); app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService)); diff --git a/apps/server/src/routes/codex/index.ts b/apps/server/src/routes/codex/index.ts new file mode 100644 index 00000000..34412256 --- /dev/null +++ b/apps/server/src/routes/codex/index.ts @@ -0,0 +1,52 @@ +import { Router, Request, Response } from 'express'; +import { CodexUsageService } from '../../services/codex-usage-service.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('Codex'); + +export function createCodexRoutes(service: CodexUsageService): Router { + const router = Router(); + + // Get current usage (attempts to fetch from Codex CLI) + router.get('/usage', async (req: Request, res: Response) => { + try { + // Check if Codex CLI is available first + const isAvailable = await service.isAvailable(); + if (!isAvailable) { + res.status(503).json({ + error: 'Codex CLI not found', + message: "Please install Codex CLI and run 'codex login' to authenticate", + }); + return; + } + + const usage = await service.fetchUsageData(); + res.json(usage); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + + if (message.includes('not authenticated') || message.includes('login')) { + res.status(401).json({ + error: 'Authentication required', + message: "Please run 'codex login' to authenticate", + }); + } else if (message.includes('not available') || message.includes('does not provide')) { + // This is the expected case - Codex doesn't provide usage stats + res.status(503).json({ + error: 'Usage statistics not available', + message: message, + }); + } else if (message.includes('timed out')) { + res.status(504).json({ + error: 'Command timed out', + message: 'The Codex CLI took too long to respond', + }); + } else { + logger.error('Error fetching usage:', error); + res.status(500).json({ error: message }); + } + } + }); + + return router; +} diff --git a/apps/server/src/services/codex-usage-service.ts b/apps/server/src/services/codex-usage-service.ts new file mode 100644 index 00000000..3697f5c9 --- /dev/null +++ b/apps/server/src/services/codex-usage-service.ts @@ -0,0 +1,112 @@ +import { spawn } from 'child_process'; +import * as os from 'os'; + +export interface CodexRateLimitWindow { + limit: number; + used: number; + remaining: number; + usedPercent: number; + windowDurationMins: number; + resetsAt: number; +} + +export interface CodexCreditsSnapshot { + balance?: string; + unlimited?: boolean; + hasCredits?: boolean; +} + +export type CodexPlanType = 'free' | 'plus' | 'pro' | 'team' | 'enterprise' | 'edu' | 'unknown'; + +export interface CodexUsageData { + rateLimits: { + primary?: CodexRateLimitWindow; + secondary?: CodexRateLimitWindow; + credits?: CodexCreditsSnapshot; + planType?: CodexPlanType; + } | null; + lastUpdated: string; +} + +/** + * Codex Usage Service + * + * Unlike Claude Code CLI which provides a `/usage` command, Codex CLI + * does not expose usage statistics directly. This service returns a + * clear message explaining this limitation. + * + * Future enhancement: Could query OpenAI API headers for rate limit info. + */ +export class CodexUsageService { + private codexBinary = 'codex'; + private isWindows = os.platform() === 'win32'; + + /** + * Check if Codex CLI is available on the system + */ + async isAvailable(): Promise { + return new Promise((resolve) => { + const checkCmd = this.isWindows ? 'where' : 'which'; + const proc = spawn(checkCmd, [this.codexBinary]); + proc.on('close', (code) => { + resolve(code === 0); + }); + proc.on('error', () => { + resolve(false); + }); + }); + } + + /** + * Attempt to fetch usage data + * + * Note: Codex CLI doesn't provide usage statistics like Claude Code does. + * This method returns an error explaining this limitation. + */ + async fetchUsageData(): Promise { + // Check authentication status first + const isAuthenticated = await this.checkAuthentication(); + + if (!isAuthenticated) { + throw new Error("Codex is not authenticated. Please run 'codex login' to authenticate."); + } + + // Codex CLI doesn't provide a usage command + // Return an error that will be caught and displayed + throw new Error( + 'Codex usage statistics are not available. Unlike Claude Code, the Codex CLI does not provide a built-in usage command. ' + + 'Usage limits are enforced by OpenAI but cannot be queried via the CLI. ' + + 'Check your OpenAI dashboard at https://platform.openai.com/usage for detailed usage information.' + ); + } + + /** + * Check if Codex is authenticated + */ + private async checkAuthentication(): Promise { + return new Promise((resolve) => { + const proc = spawn(this.codexBinary, ['login', 'status'], { + env: { + ...process.env, + TERM: 'dumb', // Avoid interactive output + }, + }); + + let output = ''; + + proc.stdout.on('data', (data) => { + output += data.toString(); + }); + + proc.on('close', (code) => { + // Check if output indicates logged in + const isLoggedIn = output.toLowerCase().includes('logged in'); + resolve(code === 0 && isLoggedIn); + }); + + proc.on('error', () => { + resolve(false); + }); + }); + } +} diff --git a/apps/ui/src/components/codex-usage-popover.tsx b/apps/ui/src/components/codex-usage-popover.tsx new file mode 100644 index 00000000..f6005b6a --- /dev/null +++ b/apps/ui/src/components/codex-usage-popover.tsx @@ -0,0 +1,405 @@ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Button } from '@/components/ui/button'; +import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { getElectronAPI } from '@/lib/electron'; +import { useAppStore } from '@/store/app-store'; +import { useSetupStore } from '@/store/setup-store'; + +// Error codes for distinguishing failure modes +const ERROR_CODES = { + API_BRIDGE_UNAVAILABLE: 'API_BRIDGE_UNAVAILABLE', + AUTH_ERROR: 'AUTH_ERROR', + NOT_AVAILABLE: 'NOT_AVAILABLE', + UNKNOWN: 'UNKNOWN', +} as const; + +type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; + +type UsageError = { + code: ErrorCode; + message: string; +}; + +// Fixed refresh interval (45 seconds) +const REFRESH_INTERVAL_SECONDS = 45; + +// Helper to format reset time +function formatResetTime(unixTimestamp: number): string { + const date = new Date(unixTimestamp * 1000); + const now = new Date(); + const diff = date.getTime() - now.getTime(); + + // If less than 1 hour, show minutes + if (diff < 3600000) { + const mins = Math.ceil(diff / 60000); + return `Resets in ${mins}m`; + } + + // If less than 24 hours, show hours and minutes + if (diff < 86400000) { + const hours = Math.floor(diff / 3600000); + const mins = Math.ceil((diff % 3600000) / 60000); + return `Resets in ${hours}h ${mins > 0 ? `${mins}m` : ''}`; + } + + // Otherwise show date + return `Resets ${date.toLocaleDateString()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; +} + +// Helper to format window duration +function getWindowLabel(durationMins: number): { title: string; subtitle: string } { + if (durationMins < 60) { + return { title: `${durationMins}min Window`, subtitle: 'Rate limit' }; + } + if (durationMins < 1440) { + const hours = Math.round(durationMins / 60); + return { title: `${hours}h Window`, subtitle: 'Rate limit' }; + } + const days = Math.round(durationMins / 1440); + return { title: `${days}d Window`, subtitle: 'Rate limit' }; +} + +export function CodexUsagePopover() { + const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore(); + const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Check if Codex is authenticated + const isCodexAuthenticated = codexAuthStatus?.authenticated; + + // Check if data is stale (older than 2 minutes) + const isStale = useMemo(() => { + return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000; + }, [codexUsageLastUpdated]); + + const fetchUsage = useCallback( + async (isAutoRefresh = false) => { + if (!isAutoRefresh) setLoading(true); + setError(null); + try { + const api = getElectronAPI(); + if (!api.codex) { + setError({ + code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, + message: 'Codex API bridge not available', + }); + return; + } + const data = await api.codex.getUsage(); + if ('error' in data) { + // Check if it's the "not available" error + if ( + data.message?.includes('not available') || + data.message?.includes('does not provide') + ) { + setError({ + code: ERROR_CODES.NOT_AVAILABLE, + message: data.message || data.error, + }); + } else { + setError({ + code: ERROR_CODES.AUTH_ERROR, + message: data.message || data.error, + }); + } + return; + } + setCodexUsage(data); + } catch (err) { + setError({ + code: ERROR_CODES.UNKNOWN, + message: err instanceof Error ? err.message : 'Failed to fetch usage', + }); + } finally { + if (!isAutoRefresh) setLoading(false); + } + }, + [setCodexUsage] + ); + + // Auto-fetch on mount if data is stale (only if authenticated) + useEffect(() => { + if (isStale && isCodexAuthenticated) { + fetchUsage(true); + } + }, [isStale, isCodexAuthenticated, fetchUsage]); + + useEffect(() => { + // Skip if not authenticated + if (!isCodexAuthenticated) return; + + // Initial fetch when opened + if (open) { + if (!codexUsage || isStale) { + fetchUsage(); + } + } + + // Auto-refresh interval (only when open) + let intervalId: NodeJS.Timeout | null = null; + if (open) { + intervalId = setInterval(() => { + fetchUsage(true); + }, REFRESH_INTERVAL_SECONDS * 1000); + } + + return () => { + if (intervalId) clearInterval(intervalId); + }; + }, [open, codexUsage, isStale, isCodexAuthenticated, fetchUsage]); + + // Derived status color/icon helper + const getStatusInfo = (percentage: number) => { + if (percentage >= 75) return { color: 'text-red-500', icon: XCircle, bg: 'bg-red-500' }; + if (percentage >= 50) + return { color: 'text-orange-500', icon: AlertTriangle, bg: 'bg-orange-500' }; + return { color: 'text-green-500', icon: CheckCircle, bg: 'bg-green-500' }; + }; + + // Helper component for the progress bar + const ProgressBar = ({ percentage, colorClass }: { percentage: number; colorClass: string }) => ( +
+
+
+ ); + + const UsageCard = ({ + title, + subtitle, + percentage, + resetText, + isPrimary = false, + stale = false, + }: { + title: string; + subtitle: string; + percentage: number; + resetText?: string; + isPrimary?: boolean; + stale?: boolean; + }) => { + const isValidPercentage = + typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage); + const safePercentage = isValidPercentage ? percentage : 0; + + const status = getStatusInfo(safePercentage); + const StatusIcon = status.icon; + + return ( +
+
+
+

{title}

+

{subtitle}

+
+ {isValidPercentage ? ( +
+ + + {Math.round(safePercentage)}% + +
+ ) : ( + N/A + )} +
+ + {resetText && ( +
+

+ + {resetText} +

+
+ )} +
+ ); + }; + + // Header Button + const maxPercentage = codexUsage?.rateLimits + ? Math.max( + codexUsage.rateLimits.primary?.usedPercent || 0, + codexUsage.rateLimits.secondary?.usedPercent || 0 + ) + : 0; + + const getProgressBarColor = (percentage: number) => { + if (percentage >= 80) return 'bg-red-500'; + if (percentage >= 50) return 'bg-yellow-500'; + return 'bg-green-500'; + }; + + const trigger = ( + + ); + + return ( + + {trigger} + + {/* Header */} +
+
+ Codex Usage +
+ {error && error.code !== ERROR_CODES.NOT_AVAILABLE && ( + + )} +
+ + {/* Content */} +
+ {error ? ( +
+ +
+

+ {error.code === ERROR_CODES.NOT_AVAILABLE ? 'Usage not available' : error.message} +

+

+ {error.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? ( + 'Ensure the Electron bridge is running or restart the app' + ) : error.code === ERROR_CODES.NOT_AVAILABLE ? ( + <> + Codex CLI doesn't provide usage statistics. Check{' '} + + OpenAI dashboard + {' '} + for usage details. + + ) : ( + <> + Make sure Codex CLI is installed and authenticated via{' '} + codex login + + )} +

+
+
+ ) : !codexUsage ? ( + // Loading state +
+ +

Loading usage data...

+
+ ) : codexUsage.rateLimits ? ( + <> + {/* Primary Window Card */} + {codexUsage.rateLimits.primary && ( + + )} + + {/* Secondary Window Card */} + {codexUsage.rateLimits.secondary && ( + + )} + + {/* Plan Type */} + {codexUsage.rateLimits.planType && ( +
+

+ Plan:{' '} + + {codexUsage.rateLimits.planType.charAt(0).toUpperCase() + + codexUsage.rateLimits.planType.slice(1)} + +

+
+ )} + + ) : ( +
+ +

No usage data available

+
+ )} +
+ + {/* Footer */} +
+ + OpenAI Dashboard + + + Updates every minute +
+
+
+ ); +} diff --git a/apps/ui/src/components/usage-popover.tsx b/apps/ui/src/components/usage-popover.tsx new file mode 100644 index 00000000..e772d48b --- /dev/null +++ b/apps/ui/src/components/usage-popover.tsx @@ -0,0 +1,612 @@ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Button } from '@/components/ui/button'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { getElectronAPI } from '@/lib/electron'; +import { useAppStore } from '@/store/app-store'; +import { useSetupStore } from '@/store/setup-store'; +import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon'; + +// Error codes for distinguishing failure modes +const ERROR_CODES = { + API_BRIDGE_UNAVAILABLE: 'API_BRIDGE_UNAVAILABLE', + AUTH_ERROR: 'AUTH_ERROR', + NOT_AVAILABLE: 'NOT_AVAILABLE', + UNKNOWN: 'UNKNOWN', +} as const; + +type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; + +type UsageError = { + code: ErrorCode; + message: string; +}; + +// Fixed refresh interval (45 seconds) +const REFRESH_INTERVAL_SECONDS = 45; + +// Helper to format reset time for Codex +function formatCodexResetTime(unixTimestamp: number): string { + const date = new Date(unixTimestamp * 1000); + const now = new Date(); + const diff = date.getTime() - now.getTime(); + + if (diff < 3600000) { + const mins = Math.ceil(diff / 60000); + return `Resets in ${mins}m`; + } + if (diff < 86400000) { + const hours = Math.floor(diff / 3600000); + const mins = Math.ceil((diff % 3600000) / 60000); + return `Resets in ${hours}h ${mins > 0 ? `${mins}m` : ''}`; + } + return `Resets ${date.toLocaleDateString()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; +} + +// Helper to format window duration for Codex +function getCodexWindowLabel(durationMins: number): { title: string; subtitle: string } { + if (durationMins < 60) { + return { title: `${durationMins}min Window`, subtitle: 'Rate limit' }; + } + if (durationMins < 1440) { + const hours = Math.round(durationMins / 60); + return { title: `${hours}h Window`, subtitle: 'Rate limit' }; + } + const days = Math.round(durationMins / 1440); + return { title: `${days}d Window`, subtitle: 'Rate limit' }; +} + +export function UsagePopover() { + const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore(); + const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore(); + const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); + const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); + + const [open, setOpen] = useState(false); + const [activeTab, setActiveTab] = useState<'claude' | 'codex'>('claude'); + const [claudeLoading, setClaudeLoading] = useState(false); + const [codexLoading, setCodexLoading] = useState(false); + const [claudeError, setClaudeError] = useState(null); + const [codexError, setCodexError] = useState(null); + + // Check authentication status + const isClaudeCliVerified = + claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated'; + const isCodexAuthenticated = codexAuthStatus?.authenticated; + + // Determine which tab to show by default + useEffect(() => { + if (isClaudeCliVerified) { + setActiveTab('claude'); + } else if (isCodexAuthenticated) { + setActiveTab('codex'); + } + }, [isClaudeCliVerified, isCodexAuthenticated]); + + // Check if data is stale (older than 2 minutes) + const isClaudeStale = useMemo(() => { + return !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000; + }, [claudeUsageLastUpdated]); + + const isCodexStale = useMemo(() => { + return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000; + }, [codexUsageLastUpdated]); + + const fetchClaudeUsage = useCallback( + async (isAutoRefresh = false) => { + if (!isAutoRefresh) setClaudeLoading(true); + setClaudeError(null); + try { + const api = getElectronAPI(); + if (!api.claude) { + setClaudeError({ + code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, + message: 'Claude API bridge not available', + }); + return; + } + const data = await api.claude.getUsage(); + if ('error' in data) { + setClaudeError({ + code: ERROR_CODES.AUTH_ERROR, + message: data.message || data.error, + }); + return; + } + setClaudeUsage(data); + } catch (err) { + setClaudeError({ + code: ERROR_CODES.UNKNOWN, + message: err instanceof Error ? err.message : 'Failed to fetch usage', + }); + } finally { + if (!isAutoRefresh) setClaudeLoading(false); + } + }, + [setClaudeUsage] + ); + + const fetchCodexUsage = useCallback( + async (isAutoRefresh = false) => { + if (!isAutoRefresh) setCodexLoading(true); + setCodexError(null); + try { + const api = getElectronAPI(); + if (!api.codex) { + setCodexError({ + code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, + message: 'Codex API bridge not available', + }); + return; + } + const data = await api.codex.getUsage(); + if ('error' in data) { + if ( + data.message?.includes('not available') || + data.message?.includes('does not provide') + ) { + setCodexError({ + code: ERROR_CODES.NOT_AVAILABLE, + message: data.message || data.error, + }); + } else { + setCodexError({ + code: ERROR_CODES.AUTH_ERROR, + message: data.message || data.error, + }); + } + return; + } + setCodexUsage(data); + } catch (err) { + setCodexError({ + code: ERROR_CODES.UNKNOWN, + message: err instanceof Error ? err.message : 'Failed to fetch usage', + }); + } finally { + if (!isAutoRefresh) setCodexLoading(false); + } + }, + [setCodexUsage] + ); + + // Auto-fetch on mount if data is stale + useEffect(() => { + if (isClaudeStale && isClaudeCliVerified) { + fetchClaudeUsage(true); + } + }, [isClaudeStale, isClaudeCliVerified, fetchClaudeUsage]); + + useEffect(() => { + if (isCodexStale && isCodexAuthenticated) { + fetchCodexUsage(true); + } + }, [isCodexStale, isCodexAuthenticated, fetchCodexUsage]); + + // Auto-refresh when popover is open + useEffect(() => { + if (!open) return; + + // Fetch based on active tab + if (activeTab === 'claude' && isClaudeCliVerified) { + if (!claudeUsage || isClaudeStale) { + fetchClaudeUsage(); + } + const intervalId = setInterval(() => { + fetchClaudeUsage(true); + }, REFRESH_INTERVAL_SECONDS * 1000); + return () => clearInterval(intervalId); + } + + if (activeTab === 'codex' && isCodexAuthenticated) { + if (!codexUsage || isCodexStale) { + fetchCodexUsage(); + } + const intervalId = setInterval(() => { + fetchCodexUsage(true); + }, REFRESH_INTERVAL_SECONDS * 1000); + return () => clearInterval(intervalId); + } + }, [ + open, + activeTab, + claudeUsage, + isClaudeStale, + isClaudeCliVerified, + codexUsage, + isCodexStale, + isCodexAuthenticated, + fetchClaudeUsage, + fetchCodexUsage, + ]); + + // Derived status color/icon helper + const getStatusInfo = (percentage: number) => { + if (percentage >= 75) return { color: 'text-red-500', icon: XCircle, bg: 'bg-red-500' }; + if (percentage >= 50) + return { color: 'text-orange-500', icon: AlertTriangle, bg: 'bg-orange-500' }; + return { color: 'text-green-500', icon: CheckCircle, bg: 'bg-green-500' }; + }; + + // Helper component for the progress bar + const ProgressBar = ({ percentage, colorClass }: { percentage: number; colorClass: string }) => ( +
+
+
+ ); + + const UsageCard = ({ + title, + subtitle, + percentage, + resetText, + isPrimary = false, + stale = false, + }: { + title: string; + subtitle: string; + percentage: number; + resetText?: string; + isPrimary?: boolean; + stale?: boolean; + }) => { + const isValidPercentage = + typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage); + const safePercentage = isValidPercentage ? percentage : 0; + + const status = getStatusInfo(safePercentage); + const StatusIcon = status.icon; + + return ( +
+
+
+

{title}

+

{subtitle}

+
+ {isValidPercentage ? ( +
+ + + {Math.round(safePercentage)}% + +
+ ) : ( + N/A + )} +
+ + {resetText && ( +
+

+ + {resetText} +

+
+ )} +
+ ); + }; + + // Calculate max percentage for header button + const claudeMaxPercentage = claudeUsage + ? Math.max(claudeUsage.sessionPercentage || 0, claudeUsage.weeklyPercentage || 0) + : 0; + + const codexMaxPercentage = codexUsage?.rateLimits + ? Math.max( + codexUsage.rateLimits.primary?.usedPercent || 0, + codexUsage.rateLimits.secondary?.usedPercent || 0 + ) + : 0; + + const maxPercentage = Math.max(claudeMaxPercentage, codexMaxPercentage); + const isStale = activeTab === 'claude' ? isClaudeStale : isCodexStale; + + const getProgressBarColor = (percentage: number) => { + if (percentage >= 80) return 'bg-red-500'; + if (percentage >= 50) return 'bg-yellow-500'; + return 'bg-green-500'; + }; + + const trigger = ( + + ); + + // Determine which tabs to show + const showClaudeTab = isClaudeCliVerified; + const showCodexTab = isCodexAuthenticated; + + return ( + + {trigger} + + setActiveTab(v as 'claude' | 'codex')}> + {/* Tabs Header */} + {showClaudeTab && showCodexTab && ( + + + + Claude + + + + Codex + + + )} + + {/* Claude Tab Content */} + + {/* Header */} +
+
+ + Claude Usage +
+ {claudeError && ( + + )} +
+ + {/* Content */} +
+ {claudeError ? ( +
+ +
+

{claudeError.message}

+

+ {claudeError.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? ( + 'Ensure the Electron bridge is running or restart the app' + ) : ( + <> + Make sure Claude CLI is installed and authenticated via{' '} + claude login + + )} +

+
+
+ ) : !claudeUsage ? ( +
+ +

Loading usage data...

+
+ ) : ( + <> + + +
+ + +
+ + {claudeUsage.costLimit && claudeUsage.costLimit > 0 && ( + 0 + ? ((claudeUsage.costUsed ?? 0) / claudeUsage.costLimit) * 100 + : 0 + } + stale={isClaudeStale} + /> + )} + + )} +
+ + {/* Footer */} +
+ + Claude Status + + Updates every minute +
+
+ + {/* Codex Tab Content */} + + {/* Header */} +
+
+ + Codex Usage +
+ {codexError && codexError.code !== ERROR_CODES.NOT_AVAILABLE && ( + + )} +
+ + {/* Content */} +
+ {codexError ? ( +
+ +
+

+ {codexError.code === ERROR_CODES.NOT_AVAILABLE + ? 'Usage not available' + : codexError.message} +

+

+ {codexError.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? ( + 'Ensure the Electron bridge is running or restart the app' + ) : codexError.code === ERROR_CODES.NOT_AVAILABLE ? ( + <> + Codex CLI doesn't provide usage statistics. Check{' '} + + OpenAI dashboard + {' '} + for usage details. + + ) : ( + <> + Make sure Codex CLI is installed and authenticated via{' '} + codex login + + )} +

+
+
+ ) : !codexUsage ? ( +
+ +

Loading usage data...

+
+ ) : codexUsage.rateLimits ? ( + <> + {codexUsage.rateLimits.primary && ( + + )} + + {codexUsage.rateLimits.secondary && ( + + )} + + {codexUsage.rateLimits.planType && ( +
+

+ Plan:{' '} + + {codexUsage.rateLimits.planType.charAt(0).toUpperCase() + + codexUsage.rateLimits.planType.slice(1)} + +

+
+ )} + + ) : ( +
+ +

No usage data available

+
+ )} +
+ + {/* Footer */} +
+ + OpenAI Dashboard + + Updates every minute +
+
+
+
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index 884cf495..21f30bf2 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -5,7 +5,7 @@ import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; import { Plus, Bot, Wand2 } from 'lucide-react'; import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts'; -import { ClaudeUsagePopover } from '@/components/claude-usage-popover'; +import { UsagePopover } from '@/components/usage-popover'; import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; @@ -40,17 +40,21 @@ export function BoardHeader({ }: BoardHeaderProps) { const apiKeys = useAppStore((state) => state.apiKeys); const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); + const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); - // Hide usage tracking when using API key (only show for Claude Code CLI users) - // Check both user-entered API key and environment variable ANTHROPIC_API_KEY + // Claude usage tracking visibility logic + // Hide when using API key (only show for Claude Code CLI users) // Also hide on Windows for now (CLI usage command not supported) - // Only show if CLI has been verified/authenticated const isWindows = typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win'); - const hasApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey; - const isCliVerified = + const hasClaudeApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey; + const isClaudeCliVerified = claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated'; - const showUsageTracking = !hasApiKey && !isWindows && isCliVerified; + const showClaudeUsage = !hasClaudeApiKey && !isWindows && isClaudeCliVerified; + + // Codex usage tracking visibility logic + // Show if Codex is authenticated (CLI or API key) + const showCodexUsage = !!codexAuthStatus?.authenticated; return (
@@ -59,8 +63,8 @@ export function BoardHeader({

{projectName}

- {/* Usage Popover - only show for CLI users (not API key users) */} - {isMounted && showUsageTracking && } + {/* Usage Popover - show if either provider is authenticated */} + {isMounted && (showClaudeUsage || showCodexUsage) && } {/* Concurrency Slider - only show after mount to prevent hydration issues */} {isMounted && ( diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index d1e51992..ffb8aabc 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -36,7 +36,7 @@ import type { ConvertToFeatureOptions, } from './electron'; import type { Message, SessionListItem } from '@/types/electron'; -import type { Feature, ClaudeUsageResponse } from '@/store/app-store'; +import type { Feature, ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store'; import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron'; import { getGlobalFileBrowser } from '@/contexts/file-browser-context'; @@ -1834,6 +1834,11 @@ export class HttpApiClient implements ElectronAPI { getUsage: (): Promise => this.get('/api/claude/usage'), }; + // Codex API + codex = { + getUsage: (): Promise => this.get('/api/codex/usage'), + }; + // Context API context = { describeImage: ( diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 960348c0..8abca72e 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -646,20 +646,24 @@ export interface CodexRateLimitWindow { limit: number; used: number; remaining: number; - window: number; // Duration in minutes + usedPercent: number; // Percentage used (0-100) + windowDurationMins: number; // Duration in minutes resetsAt: number; // Unix timestamp in seconds } export interface CodexUsage { - planType: CodexPlanType | null; - credits: CodexCreditsSnapshot | null; rateLimits: { - session?: CodexRateLimitWindow; - weekly?: CodexRateLimitWindow; + primary?: CodexRateLimitWindow; + secondary?: CodexRateLimitWindow; + credits?: CodexCreditsSnapshot; + planType?: CodexPlanType; } | null; lastUpdated: string; } +// Response type for Codex usage API (can be success or error) +export type CodexUsageResponse = CodexUsage | { error: string; message?: string }; + /** * Check if Claude usage is at its limit (any of: session >= 100%, weekly >= 100%, OR cost >= limit) * Returns true if any limit is reached, meaning auto mode should pause feature pickup.