feat: add unified usage popover with Claude and Codex tabs

- Created combined UsagePopover component with tab switching between providers
- Added Codex usage API endpoint and service (returns not available message)
- Updated BoardHeader to show single usage button for both providers
- Enhanced type definitions for Codex usage with primary/secondary rate limits
- Wired up Codex usage API in HTTP client

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
DhanushSantosh
2026-01-08 03:37:37 +05:30
parent 9c3ba34b51
commit 30a2a1c921
8 changed files with 1213 additions and 15 deletions

View File

@@ -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));

View File

@@ -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;
}

View File

@@ -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<boolean> {
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<CodexUsageData> {
// 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<boolean> {
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);
});
});
}
}

View File

@@ -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<UsageError | null>(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 }) => (
<div className="h-2 w-full bg-secondary/50 rounded-full overflow-hidden">
<div
className={cn('h-full transition-all duration-500', colorClass)}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
);
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 (
<div
className={cn(
'rounded-xl border bg-card/50 p-4 transition-opacity',
isPrimary ? 'border-border/60 shadow-sm' : 'border-border/40',
(stale || !isValidPercentage) && 'opacity-50'
)}
>
<div className="flex items-start justify-between mb-3">
<div>
<h4 className={cn('font-semibold', isPrimary ? 'text-sm' : 'text-xs')}>{title}</h4>
<p className="text-[10px] text-muted-foreground">{subtitle}</p>
</div>
{isValidPercentage ? (
<div className="flex items-center gap-1.5">
<StatusIcon className={cn('w-3.5 h-3.5', status.color)} />
<span
className={cn(
'font-mono font-bold',
status.color,
isPrimary ? 'text-base' : 'text-sm'
)}
>
{Math.round(safePercentage)}%
</span>
</div>
) : (
<span className="text-xs text-muted-foreground">N/A</span>
)}
</div>
<ProgressBar
percentage={safePercentage}
colorClass={isValidPercentage ? status.bg : 'bg-muted-foreground/30'}
/>
{resetText && (
<div className="mt-2 flex justify-end">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="w-3 h-3" />
{resetText}
</p>
</div>
)}
</div>
);
};
// 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 = (
<Button variant="ghost" size="sm" className="h-9 gap-3 bg-secondary border border-border px-3">
<span className="text-sm font-medium">Codex</span>
{codexUsage && codexUsage.rateLimits && (
<div
className={cn(
'h-1.5 w-16 bg-muted-foreground/20 rounded-full overflow-hidden transition-opacity',
isStale && 'opacity-60'
)}
>
<div
className={cn('h-full transition-all duration-500', getProgressBarColor(maxPercentage))}
style={{ width: `${Math.min(maxPercentage, 100)}%` }}
/>
</div>
)}
</Button>
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
className="w-80 p-0 overflow-hidden bg-background/95 backdrop-blur-xl border-border shadow-2xl"
align="end"
sideOffset={8}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-secondary/10">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">Codex Usage</span>
</div>
{error && error.code !== ERROR_CODES.NOT_AVAILABLE && (
<Button
variant="ghost"
size="icon"
className={cn('h-6 w-6', loading && 'opacity-80')}
onClick={() => !loading && fetchUsage(false)}
>
<RefreshCw className="w-3.5 h-3.5" />
</Button>
)}
</div>
{/* Content */}
<div className="p-4 space-y-4">
{error ? (
<div className="flex flex-col items-center justify-center py-6 text-center space-y-3">
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
<div className="space-y-1 flex flex-col items-center">
<p className="text-sm font-medium">
{error.code === ERROR_CODES.NOT_AVAILABLE ? 'Usage not available' : error.message}
</p>
<p className="text-xs text-muted-foreground">
{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{' '}
<a
href="https://platform.openai.com/usage"
target="_blank"
rel="noreferrer"
className="underline hover:text-foreground"
>
OpenAI dashboard
</a>{' '}
for usage details.
</>
) : (
<>
Make sure Codex CLI is installed and authenticated via{' '}
<code className="font-mono bg-muted px-1 rounded">codex login</code>
</>
)}
</p>
</div>
</div>
) : !codexUsage ? (
// Loading state
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>
</div>
) : codexUsage.rateLimits ? (
<>
{/* Primary Window Card */}
{codexUsage.rateLimits.primary && (
<UsageCard
title={getWindowLabel(codexUsage.rateLimits.primary.windowDurationMins).title}
subtitle={
getWindowLabel(codexUsage.rateLimits.primary.windowDurationMins).subtitle
}
percentage={codexUsage.rateLimits.primary.usedPercent}
resetText={formatResetTime(codexUsage.rateLimits.primary.resetsAt)}
isPrimary={true}
stale={isStale}
/>
)}
{/* Secondary Window Card */}
{codexUsage.rateLimits.secondary && (
<UsageCard
title={getWindowLabel(codexUsage.rateLimits.secondary.windowDurationMins).title}
subtitle={
getWindowLabel(codexUsage.rateLimits.secondary.windowDurationMins).subtitle
}
percentage={codexUsage.rateLimits.secondary.usedPercent}
resetText={formatResetTime(codexUsage.rateLimits.secondary.resetsAt)}
stale={isStale}
/>
)}
{/* Plan Type */}
{codexUsage.rateLimits.planType && (
<div className="rounded-xl border border-border/40 bg-secondary/20 p-3">
<p className="text-xs text-muted-foreground">
Plan:{' '}
<span className="text-foreground font-medium">
{codexUsage.rateLimits.planType.charAt(0).toUpperCase() +
codexUsage.rateLimits.planType.slice(1)}
</span>
</p>
</div>
)}
</>
) : (
<div className="flex flex-col items-center justify-center py-6 text-center">
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
<p className="text-sm font-medium mt-3">No usage data available</p>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-2 bg-secondary/10 border-t border-border/50">
<a
href="https://platform.openai.com/usage"
target="_blank"
rel="noreferrer"
className="text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
>
OpenAI Dashboard <ExternalLink className="w-2.5 h-2.5" />
</a>
<span className="text-[10px] text-muted-foreground">Updates every minute</span>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -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<UsageError | null>(null);
const [codexError, setCodexError] = useState<UsageError | null>(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 }) => (
<div className="h-2 w-full bg-secondary/50 rounded-full overflow-hidden">
<div
className={cn('h-full transition-all duration-500', colorClass)}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
);
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 (
<div
className={cn(
'rounded-xl border bg-card/50 p-4 transition-opacity',
isPrimary ? 'border-border/60 shadow-sm' : 'border-border/40',
(stale || !isValidPercentage) && 'opacity-50'
)}
>
<div className="flex items-start justify-between mb-3">
<div>
<h4 className={cn('font-semibold', isPrimary ? 'text-sm' : 'text-xs')}>{title}</h4>
<p className="text-[10px] text-muted-foreground">{subtitle}</p>
</div>
{isValidPercentage ? (
<div className="flex items-center gap-1.5">
<StatusIcon className={cn('w-3.5 h-3.5', status.color)} />
<span
className={cn(
'font-mono font-bold',
status.color,
isPrimary ? 'text-base' : 'text-sm'
)}
>
{Math.round(safePercentage)}%
</span>
</div>
) : (
<span className="text-xs text-muted-foreground">N/A</span>
)}
</div>
<ProgressBar
percentage={safePercentage}
colorClass={isValidPercentage ? status.bg : 'bg-muted-foreground/30'}
/>
{resetText && (
<div className="mt-2 flex justify-end">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="w-3 h-3" />
{resetText}
</p>
</div>
)}
</div>
);
};
// 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 = (
<Button variant="ghost" size="sm" className="h-9 gap-3 bg-secondary border border-border px-3">
<span className="text-sm font-medium">Usage</span>
{(claudeUsage || codexUsage) && (
<div
className={cn(
'h-1.5 w-16 bg-muted-foreground/20 rounded-full overflow-hidden transition-opacity',
isStale && 'opacity-60'
)}
>
<div
className={cn('h-full transition-all duration-500', getProgressBarColor(maxPercentage))}
style={{ width: `${Math.min(maxPercentage, 100)}%` }}
/>
</div>
)}
</Button>
);
// Determine which tabs to show
const showClaudeTab = isClaudeCliVerified;
const showCodexTab = isCodexAuthenticated;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
className="w-80 p-0 overflow-hidden bg-background/95 backdrop-blur-xl border-border shadow-2xl"
align="end"
sideOffset={8}
>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'claude' | 'codex')}>
{/* Tabs Header */}
{showClaudeTab && showCodexTab && (
<TabsList className="grid w-full grid-cols-2 rounded-none border-b border-border/50">
<TabsTrigger value="claude" className="gap-2">
<AnthropicIcon className="w-3.5 h-3.5" />
Claude
</TabsTrigger>
<TabsTrigger value="codex" className="gap-2">
<OpenAIIcon className="w-3.5 h-3.5" />
Codex
</TabsTrigger>
</TabsList>
)}
{/* Claude Tab Content */}
<TabsContent value="claude" className="m-0">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-secondary/10">
<div className="flex items-center gap-2">
<AnthropicIcon className="w-4 h-4" />
<span className="text-sm font-semibold">Claude Usage</span>
</div>
{claudeError && (
<Button
variant="ghost"
size="icon"
className={cn('h-6 w-6', claudeLoading && 'opacity-80')}
onClick={() => !claudeLoading && fetchClaudeUsage(false)}
>
<RefreshCw className="w-3.5 h-3.5" />
</Button>
)}
</div>
{/* Content */}
<div className="p-4 space-y-4">
{claudeError ? (
<div className="flex flex-col items-center justify-center py-6 text-center space-y-3">
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
<div className="space-y-1 flex flex-col items-center">
<p className="text-sm font-medium">{claudeError.message}</p>
<p className="text-xs text-muted-foreground">
{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{' '}
<code className="font-mono bg-muted px-1 rounded">claude login</code>
</>
)}
</p>
</div>
</div>
) : !claudeUsage ? (
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>
</div>
) : (
<>
<UsageCard
title="Session Usage"
subtitle="5-hour rolling window"
percentage={claudeUsage.sessionPercentage}
resetText={claudeUsage.sessionResetText}
isPrimary={true}
stale={isClaudeStale}
/>
<div className="grid grid-cols-2 gap-3">
<UsageCard
title="Weekly"
subtitle="All models"
percentage={claudeUsage.weeklyPercentage}
resetText={claudeUsage.weeklyResetText}
stale={isClaudeStale}
/>
<UsageCard
title="Sonnet"
subtitle="Weekly"
percentage={claudeUsage.sonnetWeeklyPercentage}
resetText={claudeUsage.sonnetResetText}
stale={isClaudeStale}
/>
</div>
{claudeUsage.costLimit && claudeUsage.costLimit > 0 && (
<UsageCard
title="Extra Usage"
subtitle={`${claudeUsage.costUsed ?? 0} / ${claudeUsage.costLimit} ${claudeUsage.costCurrency ?? ''}`}
percentage={
claudeUsage.costLimit > 0
? ((claudeUsage.costUsed ?? 0) / claudeUsage.costLimit) * 100
: 0
}
stale={isClaudeStale}
/>
)}
</>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-2 bg-secondary/10 border-t border-border/50">
<a
href="https://status.claude.com"
target="_blank"
rel="noreferrer"
className="text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
>
Claude Status <ExternalLink className="w-2.5 h-2.5" />
</a>
<span className="text-[10px] text-muted-foreground">Updates every minute</span>
</div>
</TabsContent>
{/* Codex Tab Content */}
<TabsContent value="codex" className="m-0">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-secondary/10">
<div className="flex items-center gap-2">
<OpenAIIcon className="w-4 h-4" />
<span className="text-sm font-semibold">Codex Usage</span>
</div>
{codexError && codexError.code !== ERROR_CODES.NOT_AVAILABLE && (
<Button
variant="ghost"
size="icon"
className={cn('h-6 w-6', codexLoading && 'opacity-80')}
onClick={() => !codexLoading && fetchCodexUsage(false)}
>
<RefreshCw className="w-3.5 h-3.5" />
</Button>
)}
</div>
{/* Content */}
<div className="p-4 space-y-4">
{codexError ? (
<div className="flex flex-col items-center justify-center py-6 text-center space-y-3">
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
<div className="space-y-1 flex flex-col items-center">
<p className="text-sm font-medium">
{codexError.code === ERROR_CODES.NOT_AVAILABLE
? 'Usage not available'
: codexError.message}
</p>
<p className="text-xs text-muted-foreground">
{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{' '}
<a
href="https://platform.openai.com/usage"
target="_blank"
rel="noreferrer"
className="underline hover:text-foreground"
>
OpenAI dashboard
</a>{' '}
for usage details.
</>
) : (
<>
Make sure Codex CLI is installed and authenticated via{' '}
<code className="font-mono bg-muted px-1 rounded">codex login</code>
</>
)}
</p>
</div>
</div>
) : !codexUsage ? (
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>
</div>
) : codexUsage.rateLimits ? (
<>
{codexUsage.rateLimits.primary && (
<UsageCard
title={
getCodexWindowLabel(codexUsage.rateLimits.primary.windowDurationMins).title
}
subtitle={
getCodexWindowLabel(codexUsage.rateLimits.primary.windowDurationMins)
.subtitle
}
percentage={codexUsage.rateLimits.primary.usedPercent}
resetText={formatCodexResetTime(codexUsage.rateLimits.primary.resetsAt)}
isPrimary={true}
stale={isCodexStale}
/>
)}
{codexUsage.rateLimits.secondary && (
<UsageCard
title={
getCodexWindowLabel(codexUsage.rateLimits.secondary.windowDurationMins)
.title
}
subtitle={
getCodexWindowLabel(codexUsage.rateLimits.secondary.windowDurationMins)
.subtitle
}
percentage={codexUsage.rateLimits.secondary.usedPercent}
resetText={formatCodexResetTime(codexUsage.rateLimits.secondary.resetsAt)}
stale={isCodexStale}
/>
)}
{codexUsage.rateLimits.planType && (
<div className="rounded-xl border border-border/40 bg-secondary/20 p-3">
<p className="text-xs text-muted-foreground">
Plan:{' '}
<span className="text-foreground font-medium">
{codexUsage.rateLimits.planType.charAt(0).toUpperCase() +
codexUsage.rateLimits.planType.slice(1)}
</span>
</p>
</div>
)}
</>
) : (
<div className="flex flex-col items-center justify-center py-6 text-center">
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
<p className="text-sm font-medium mt-3">No usage data available</p>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-2 bg-secondary/10 border-t border-border/50">
<a
href="https://platform.openai.com/usage"
target="_blank"
rel="noreferrer"
className="text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
>
OpenAI Dashboard <ExternalLink className="w-2.5 h-2.5" />
</a>
<span className="text-[10px] text-muted-foreground">Updates every minute</span>
</div>
</TabsContent>
</Tabs>
</PopoverContent>
</Popover>
);
}

View File

@@ -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 (
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
@@ -59,8 +63,8 @@ export function BoardHeader({
<p className="text-sm text-muted-foreground">{projectName}</p>
</div>
<div className="flex gap-2 items-center">
{/* Usage Popover - only show for CLI users (not API key users) */}
{isMounted && showUsageTracking && <ClaudeUsagePopover />}
{/* Usage Popover - show if either provider is authenticated */}
{isMounted && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
{isMounted && (

View File

@@ -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<ClaudeUsageResponse> => this.get('/api/claude/usage'),
};
// Codex API
codex = {
getUsage: (): Promise<CodexUsageResponse> => this.get('/api/codex/usage'),
};
// Context API
context = {
describeImage: (

View File

@@ -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.