mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
feature/codex-cli
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { ModelAlias, ThinkingLevel } from '@/store/app-store';
|
||||
import type { ModelProvider } from '@automaker/types';
|
||||
import { CURSOR_MODEL_MAP } from '@automaker/types';
|
||||
import type { ModelAlias } from '@/store/app-store';
|
||||
import type { ModelProvider, ThinkingLevel, ReasoningEffort } from '@automaker/types';
|
||||
import { CURSOR_MODEL_MAP, CODEX_MODEL_MAP } from '@automaker/types';
|
||||
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
|
||||
|
||||
export type ModelOption = {
|
||||
@@ -51,9 +51,64 @@ export const CURSOR_MODELS: ModelOption[] = Object.entries(CURSOR_MODEL_MAP).map
|
||||
);
|
||||
|
||||
/**
|
||||
* All available models (Claude + Cursor)
|
||||
* Codex/OpenAI models
|
||||
* Official models from https://developers.openai.com/codex/models/
|
||||
*/
|
||||
export const ALL_MODELS: ModelOption[] = [...CLAUDE_MODELS, ...CURSOR_MODELS];
|
||||
export const CODEX_MODELS: ModelOption[] = [
|
||||
{
|
||||
id: CODEX_MODEL_MAP.gpt52Codex,
|
||||
label: 'GPT-5.2-Codex',
|
||||
description: 'Most advanced agentic coding model (default for ChatGPT users).',
|
||||
badge: 'Premium',
|
||||
provider: 'codex',
|
||||
hasThinking: true,
|
||||
},
|
||||
{
|
||||
id: CODEX_MODEL_MAP.gpt5Codex,
|
||||
label: 'GPT-5-Codex',
|
||||
description: 'Purpose-built for Codex CLI (default for CLI users).',
|
||||
badge: 'Balanced',
|
||||
provider: 'codex',
|
||||
hasThinking: true,
|
||||
},
|
||||
{
|
||||
id: CODEX_MODEL_MAP.gpt5CodexMini,
|
||||
label: 'GPT-5-Codex-Mini',
|
||||
description: 'Faster workflows for code Q&A and editing.',
|
||||
badge: 'Speed',
|
||||
provider: 'codex',
|
||||
hasThinking: false,
|
||||
},
|
||||
{
|
||||
id: CODEX_MODEL_MAP.codex1,
|
||||
label: 'Codex-1',
|
||||
description: 'o3-based model optimized for software engineering.',
|
||||
badge: 'Premium',
|
||||
provider: 'codex',
|
||||
hasThinking: true,
|
||||
},
|
||||
{
|
||||
id: CODEX_MODEL_MAP.codexMiniLatest,
|
||||
label: 'Codex-Mini-Latest',
|
||||
description: 'o4-mini-based model for faster workflows.',
|
||||
badge: 'Balanced',
|
||||
provider: 'codex',
|
||||
hasThinking: false,
|
||||
},
|
||||
{
|
||||
id: CODEX_MODEL_MAP.gpt5,
|
||||
label: 'GPT-5',
|
||||
description: 'GPT-5 base flagship model.',
|
||||
badge: 'Balanced',
|
||||
provider: 'codex',
|
||||
hasThinking: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* All available models (Claude + Cursor + Codex)
|
||||
*/
|
||||
export const ALL_MODELS: ModelOption[] = [...CLAUDE_MODELS, ...CURSOR_MODELS, ...CODEX_MODELS];
|
||||
|
||||
export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink'];
|
||||
|
||||
@@ -65,6 +120,28 @@ export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
|
||||
ultrathink: 'Ultra',
|
||||
};
|
||||
|
||||
/**
|
||||
* Reasoning effort levels for Codex/OpenAI models
|
||||
* All models support reasoning effort levels
|
||||
*/
|
||||
export const REASONING_EFFORT_LEVELS: ReasoningEffort[] = [
|
||||
'none',
|
||||
'minimal',
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'xhigh',
|
||||
];
|
||||
|
||||
export const REASONING_EFFORT_LABELS: Record<ReasoningEffort, string> = {
|
||||
none: 'None',
|
||||
minimal: 'Min',
|
||||
low: 'Low',
|
||||
medium: 'Med',
|
||||
high: 'High',
|
||||
xhigh: 'XHigh',
|
||||
};
|
||||
|
||||
// Profile icon mapping
|
||||
export const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Brain,
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Brain, Bot, Terminal, AlertTriangle } from 'lucide-react';
|
||||
import { Brain, AlertTriangle } from 'lucide-react';
|
||||
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ModelAlias } from '@/store/app-store';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { getModelProvider, PROVIDER_PREFIXES, stripProviderPrefix } from '@automaker/types';
|
||||
import type { ModelProvider } from '@automaker/types';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, ModelOption } from './model-constants';
|
||||
|
||||
interface ModelSelectorProps {
|
||||
selectedModel: string; // Can be ModelAlias or "cursor-{id}"
|
||||
@@ -21,13 +22,16 @@ export function ModelSelector({
|
||||
testIdPrefix = 'model-select',
|
||||
}: ModelSelectorProps) {
|
||||
const { enabledCursorModels, cursorDefaultModel } = useAppStore();
|
||||
const { cursorCliStatus } = useSetupStore();
|
||||
const { cursorCliStatus, codexCliStatus } = useSetupStore();
|
||||
|
||||
const selectedProvider = getModelProvider(selectedModel);
|
||||
|
||||
// Check if Cursor CLI is available
|
||||
const isCursorAvailable = cursorCliStatus?.installed && cursorCliStatus?.auth?.authenticated;
|
||||
|
||||
// Check if Codex CLI is available
|
||||
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.auth?.authenticated;
|
||||
|
||||
// Filter Cursor models based on enabled models from global settings
|
||||
const filteredCursorModels = CURSOR_MODELS.filter((model) => {
|
||||
// Extract the cursor model ID from the prefixed ID (e.g., "cursor-auto" -> "auto")
|
||||
@@ -39,6 +43,9 @@ export function ModelSelector({
|
||||
if (provider === 'cursor' && selectedProvider !== 'cursor') {
|
||||
// Switch to Cursor's default model (from global settings)
|
||||
onModelSelect(`${PROVIDER_PREFIXES.cursor}${cursorDefaultModel}`);
|
||||
} else if (provider === 'codex' && selectedProvider !== 'codex') {
|
||||
// Switch to Codex's default model (gpt-5.2)
|
||||
onModelSelect('gpt-5.2');
|
||||
} else if (provider === 'claude' && selectedProvider !== 'claude') {
|
||||
// Switch to Claude's default model
|
||||
onModelSelect('sonnet');
|
||||
@@ -62,7 +69,7 @@ export function ModelSelector({
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-provider-claude`}
|
||||
>
|
||||
<Bot className="w-4 h-4" />
|
||||
<AnthropicIcon className="w-4 h-4" />
|
||||
Claude
|
||||
</button>
|
||||
<button
|
||||
@@ -76,9 +83,23 @@ export function ModelSelector({
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-provider-cursor`}
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
<CursorIcon className="w-4 h-4" />
|
||||
Cursor CLI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('codex')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
selectedProvider === 'codex'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-provider-codex`}
|
||||
>
|
||||
<OpenAIIcon className="w-4 h-4" />
|
||||
Codex CLI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -136,7 +157,7 @@ export function ModelSelector({
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4 text-primary" />
|
||||
<CursorIcon className="w-4 h-4 text-primary" />
|
||||
Cursor Model
|
||||
</Label>
|
||||
<span className="text-[11px] px-2 py-0.5 rounded-full border border-amber-500/40 text-amber-600 dark:text-amber-400">
|
||||
@@ -188,6 +209,67 @@ export function ModelSelector({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Codex Models */}
|
||||
{selectedProvider === 'codex' && (
|
||||
<div className="space-y-3">
|
||||
{/* Warning when Codex CLI is not available */}
|
||||
{!isCodexAvailable && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400 mt-0.5 shrink-0" />
|
||||
<div className="text-sm text-amber-400">
|
||||
Codex CLI is not installed or authenticated. Configure it in Settings → AI
|
||||
Providers.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="flex items-center gap-2">
|
||||
<OpenAIIcon className="w-4 h-4 text-primary" />
|
||||
Codex Model
|
||||
</Label>
|
||||
<span className="text-[11px] px-2 py-0.5 rounded-full border border-emerald-500/40 text-emerald-600 dark:text-emerald-400">
|
||||
CLI
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{CODEX_MODELS.map((option) => {
|
||||
const isSelected = selectedModel === option.id;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onModelSelect(option.id)}
|
||||
title={option.description}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
|
||||
isSelected
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-${option.id}`}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{option.badge && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-xs',
|
||||
isSelected
|
||||
? 'border-primary-foreground/50 text-primary-foreground'
|
||||
: 'border-muted-foreground/50 text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{option.badge}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn, modelSupportsThinking } from '@/lib/utils';
|
||||
import { DialogFooter } from '@/components/ui/dialog';
|
||||
import { Brain, Bot, Terminal } from 'lucide-react';
|
||||
import { Brain } from 'lucide-react';
|
||||
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||
import { toast } from 'sonner';
|
||||
import type {
|
||||
AIProfile,
|
||||
@@ -15,8 +16,9 @@ import type {
|
||||
ThinkingLevel,
|
||||
ModelProvider,
|
||||
CursorModelId,
|
||||
CodexModelId,
|
||||
} from '@automaker/types';
|
||||
import { CURSOR_MODEL_MAP, cursorModelHasThinking } from '@automaker/types';
|
||||
import { CURSOR_MODEL_MAP, cursorModelHasThinking, CODEX_MODEL_MAP } from '@automaker/types';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { CLAUDE_MODELS, THINKING_LEVELS, ICON_OPTIONS } from '../constants';
|
||||
|
||||
@@ -46,6 +48,8 @@ export function ProfileForm({
|
||||
thinkingLevel: profile.thinkingLevel || ('none' as ThinkingLevel),
|
||||
// Cursor-specific
|
||||
cursorModel: profile.cursorModel || ('auto' as CursorModelId),
|
||||
// Codex-specific
|
||||
codexModel: profile.codexModel || ('gpt-5.2' as CodexModelId),
|
||||
icon: profile.icon || 'Brain',
|
||||
});
|
||||
|
||||
@@ -59,6 +63,7 @@ export function ProfileForm({
|
||||
model: provider === 'claude' ? 'sonnet' : formData.model,
|
||||
thinkingLevel: provider === 'claude' ? 'none' : formData.thinkingLevel,
|
||||
cursorModel: provider === 'cursor' ? 'auto' : formData.cursorModel,
|
||||
codexModel: provider === 'codex' ? 'gpt-5.2' : formData.codexModel,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -76,6 +81,13 @@ export function ProfileForm({
|
||||
});
|
||||
};
|
||||
|
||||
const handleCodexModelChange = (codexModel: CodexModelId) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
codexModel,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formData.name.trim()) {
|
||||
toast.error('Please enter a profile name');
|
||||
@@ -95,6 +107,11 @@ export function ProfileForm({
|
||||
...baseProfile,
|
||||
cursorModel: formData.cursorModel,
|
||||
});
|
||||
} else if (formData.provider === 'codex') {
|
||||
onSave({
|
||||
...baseProfile,
|
||||
codexModel: formData.codexModel,
|
||||
});
|
||||
} else {
|
||||
onSave({
|
||||
...baseProfile,
|
||||
@@ -158,34 +175,48 @@ export function ProfileForm({
|
||||
{/* Provider Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>AI Provider</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('claude')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
'px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
formData.provider === 'claude'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
)}
|
||||
data-testid="provider-select-claude"
|
||||
>
|
||||
<Bot className="w-4 h-4" />
|
||||
<AnthropicIcon className="w-4 h-4" />
|
||||
Claude
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('cursor')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
'px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
formData.provider === 'cursor'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
)}
|
||||
data-testid="provider-select-cursor"
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
Cursor CLI
|
||||
<CursorIcon className="w-4 h-4" />
|
||||
Cursor
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('codex')}
|
||||
className={cn(
|
||||
'px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
formData.provider === 'codex'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
)}
|
||||
data-testid="provider-select-codex"
|
||||
>
|
||||
<OpenAIIcon className="w-4 h-4" />
|
||||
Codex
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -222,7 +253,7 @@ export function ProfileForm({
|
||||
{formData.provider === 'cursor' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4 text-primary" />
|
||||
<CursorIcon className="w-4 h-4 text-primary" />
|
||||
Cursor Model
|
||||
</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -283,6 +314,77 @@ export function ProfileForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Codex Model Selection */}
|
||||
{formData.provider === 'codex' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<OpenAIIcon className="w-4 h-4 text-primary" />
|
||||
Codex Model
|
||||
</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{Object.entries(CODEX_MODEL_MAP).map(([key, modelId]) => {
|
||||
const modelConfig = {
|
||||
gpt52Codex: { label: 'GPT-5.2-Codex', badge: 'Premium', hasReasoning: true },
|
||||
gpt52: { label: 'GPT-5.2', badge: 'Premium', hasReasoning: true },
|
||||
gpt51CodexMax: {
|
||||
label: 'GPT-5.1-Codex-Max',
|
||||
badge: 'Premium',
|
||||
hasReasoning: true,
|
||||
},
|
||||
gpt51Codex: { label: 'GPT-5.1-Codex', badge: 'Balanced' },
|
||||
gpt51CodexMini: { label: 'GPT-5.1-Codex-Mini', badge: 'Speed' },
|
||||
gpt51: { label: 'GPT-5.1', badge: 'Standard' },
|
||||
o3Mini: { label: 'o3-mini', badge: 'Reasoning', hasReasoning: true },
|
||||
o4Mini: { label: 'o4-mini', badge: 'Reasoning', hasReasoning: true },
|
||||
}[key as keyof typeof CODEX_MODEL_MAP] || { label: modelId, badge: 'Standard' };
|
||||
|
||||
return (
|
||||
<button
|
||||
key={modelId}
|
||||
type="button"
|
||||
onClick={() => handleCodexModelChange(modelId)}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
|
||||
formData.codexModel === modelId
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
)}
|
||||
data-testid={`codex-model-select-${modelId}`}
|
||||
>
|
||||
<span>{modelConfig.label}</span>
|
||||
<div className="flex gap-1">
|
||||
{modelConfig.hasReasoning && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-xs',
|
||||
formData.codexModel === modelId
|
||||
? 'border-primary-foreground/50 text-primary-foreground'
|
||||
: 'border-amber-500/50 text-amber-600 dark:text-amber-400'
|
||||
)}
|
||||
>
|
||||
Reasoning
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-xs',
|
||||
formData.codexModel === modelId
|
||||
? 'border-primary-foreground/50 text-primary-foreground'
|
||||
: 'border-muted-foreground/50 text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{modelConfig.badge}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Claude Thinking Level */}
|
||||
{formData.provider === 'claude' && supportsThinking && (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Terminal, CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
|
||||
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CliStatus } from '../shared/types';
|
||||
import type { ClaudeAuthStatus } from '@/store/setup-store';
|
||||
import { AnthropicIcon } from '@/components/ui/provider-icon';
|
||||
|
||||
interface CliStatusProps {
|
||||
status: CliStatus | null;
|
||||
@@ -95,7 +96,7 @@ export function ClaudeCliStatus({ status, authStatus, isChecking, onRefresh }: C
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<Terminal className="w-5 h-5 text-brand-500" />
|
||||
<AnthropicIcon className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Claude Code CLI
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CheckCircle2, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CliStatus } from '../shared/types';
|
||||
|
||||
interface CliStatusCardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
status: CliStatus | null;
|
||||
isChecking: boolean;
|
||||
onRefresh: () => void;
|
||||
refreshTestId: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
fallbackRecommendation: string;
|
||||
}
|
||||
|
||||
export function CliStatusCard({
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
isChecking,
|
||||
onRefresh,
|
||||
refreshTestId,
|
||||
icon: Icon,
|
||||
fallbackRecommendation,
|
||||
}: CliStatusCardProps) {
|
||||
if (!status) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<Icon className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">{title}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRefresh}
|
||||
disabled={isChecking}
|
||||
data-testid={refreshTestId}
|
||||
title={`Refresh ${title} detection`}
|
||||
className={cn(
|
||||
'h-9 w-9 rounded-lg',
|
||||
'hover:bg-accent/50 hover:scale-105',
|
||||
'transition-all duration-200'
|
||||
)}
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4', isChecking && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">{description}</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{status.success && status.status === 'installed' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-emerald-400">{title} Installed</p>
|
||||
<div className="text-xs text-emerald-400/70 mt-1.5 space-y-0.5">
|
||||
{status.method && (
|
||||
<p>
|
||||
Method: <span className="font-mono">{status.method}</span>
|
||||
</p>
|
||||
)}
|
||||
{status.version && (
|
||||
<p>
|
||||
Version: <span className="font-mono">{status.version}</span>
|
||||
</p>
|
||||
)}
|
||||
{status.path && (
|
||||
<p className="truncate" title={status.path}>
|
||||
Path: <span className="font-mono text-[10px]">{status.path}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{status.recommendation && (
|
||||
<p className="text-xs text-muted-foreground/70 ml-1">{status.recommendation}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
|
||||
<AlertCircle className="w-5 h-5 text-amber-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-amber-400">{title} Not Detected</p>
|
||||
<p className="text-xs text-amber-400/70 mt-1">
|
||||
{status.recommendation || fallbackRecommendation}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{status.installCommands && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-medium text-foreground/80">Installation Commands:</p>
|
||||
<div className="space-y-2">
|
||||
{status.installCommands.npm && (
|
||||
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
|
||||
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
|
||||
npm
|
||||
</p>
|
||||
<code className="text-xs text-foreground/80 font-mono break-all">
|
||||
{status.installCommands.npm}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{status.installCommands.macos && (
|
||||
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
|
||||
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
|
||||
macOS/Linux
|
||||
</p>
|
||||
<code className="text-xs text-foreground/80 font-mono break-all">
|
||||
{status.installCommands.macos}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{status.installCommands.windows && (
|
||||
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
|
||||
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
|
||||
Windows (PowerShell)
|
||||
</p>
|
||||
<code className="text-xs text-foreground/80 font-mono break-all">
|
||||
{status.installCommands.windows}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { CliStatus } from '../shared/types';
|
||||
import { CliStatusCard } from './cli-status-card';
|
||||
import { OpenAIIcon } from '@/components/ui/provider-icon';
|
||||
|
||||
interface CliStatusProps {
|
||||
status: CliStatus | null;
|
||||
isChecking: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function CodexCliStatus({ status, isChecking, onRefresh }: CliStatusProps) {
|
||||
return (
|
||||
<CliStatusCard
|
||||
title="Codex CLI"
|
||||
description="Codex CLI powers OpenAI models for coding and automation workflows."
|
||||
status={status}
|
||||
isChecking={isChecking}
|
||||
onRefresh={onRefresh}
|
||||
refreshTestId="refresh-codex-cli"
|
||||
icon={OpenAIIcon}
|
||||
fallbackRecommendation="Install Codex CLI to unlock OpenAI models with tool support."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Terminal, CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
|
||||
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CursorIcon } from '@/components/ui/provider-icon';
|
||||
|
||||
interface CursorStatus {
|
||||
installed: boolean;
|
||||
@@ -215,7 +216,7 @@ export function CursorCliStatus({ status, isChecking, onRefresh }: CursorCliStat
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<Terminal className="w-5 h-5 text-brand-500" />
|
||||
<CursorIcon className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Cursor CLI</h2>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { FileCode, ShieldCheck, Globe, ImageIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { CodexApprovalPolicy, CodexSandboxMode } from '@automaker/types';
|
||||
import { OpenAIIcon } from '@/components/ui/provider-icon';
|
||||
|
||||
interface CodexSettingsProps {
|
||||
autoLoadCodexAgents: boolean;
|
||||
codexSandboxMode: CodexSandboxMode;
|
||||
codexApprovalPolicy: CodexApprovalPolicy;
|
||||
codexEnableWebSearch: boolean;
|
||||
codexEnableImages: boolean;
|
||||
onAutoLoadCodexAgentsChange: (enabled: boolean) => void;
|
||||
onCodexSandboxModeChange: (mode: CodexSandboxMode) => void;
|
||||
onCodexApprovalPolicyChange: (policy: CodexApprovalPolicy) => void;
|
||||
onCodexEnableWebSearchChange: (enabled: boolean) => void;
|
||||
onCodexEnableImagesChange: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const CARD_TITLE = 'Codex CLI Settings';
|
||||
const CARD_SUBTITLE = 'Configure Codex instructions, capabilities, and execution safety defaults.';
|
||||
const AGENTS_TITLE = 'Auto-load AGENTS.md Instructions';
|
||||
const AGENTS_DESCRIPTION = 'Automatically inject project instructions from';
|
||||
const AGENTS_PATH = '.codex/AGENTS.md';
|
||||
const AGENTS_SUFFIX = 'on each Codex run.';
|
||||
const WEB_SEARCH_TITLE = 'Enable Web Search';
|
||||
const WEB_SEARCH_DESCRIPTION =
|
||||
'Allow Codex to search the web for current information using --search flag.';
|
||||
const IMAGES_TITLE = 'Enable Image Support';
|
||||
const IMAGES_DESCRIPTION = 'Allow Codex to process images attached to prompts using -i flag.';
|
||||
const SANDBOX_TITLE = 'Sandbox Policy';
|
||||
const APPROVAL_TITLE = 'Approval Policy';
|
||||
const SANDBOX_SELECT_LABEL = 'Select sandbox policy';
|
||||
const APPROVAL_SELECT_LABEL = 'Select approval policy';
|
||||
|
||||
const SANDBOX_OPTIONS: Array<{
|
||||
value: CodexSandboxMode;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
value: 'read-only',
|
||||
label: 'Read-only',
|
||||
description: 'Only allow safe, non-mutating commands.',
|
||||
},
|
||||
{
|
||||
value: 'workspace-write',
|
||||
label: 'Workspace write',
|
||||
description: 'Allow file edits inside the project workspace.',
|
||||
},
|
||||
{
|
||||
value: 'danger-full-access',
|
||||
label: 'Full access',
|
||||
description: 'Allow unrestricted commands (use with care).',
|
||||
},
|
||||
];
|
||||
|
||||
const APPROVAL_OPTIONS: Array<{
|
||||
value: CodexApprovalPolicy;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
value: 'untrusted',
|
||||
label: 'Untrusted',
|
||||
description: 'Ask for approval for most commands.',
|
||||
},
|
||||
{
|
||||
value: 'on-failure',
|
||||
label: 'On failure',
|
||||
description: 'Ask only if a command fails in the sandbox.',
|
||||
},
|
||||
{
|
||||
value: 'on-request',
|
||||
label: 'On request',
|
||||
description: 'Let the agent decide when to ask.',
|
||||
},
|
||||
{
|
||||
value: 'never',
|
||||
label: 'Never',
|
||||
description: 'Never ask for approval (least restrictive).',
|
||||
},
|
||||
];
|
||||
|
||||
export function CodexSettings({
|
||||
autoLoadCodexAgents,
|
||||
codexSandboxMode,
|
||||
codexApprovalPolicy,
|
||||
codexEnableWebSearch,
|
||||
codexEnableImages,
|
||||
onAutoLoadCodexAgentsChange,
|
||||
onCodexSandboxModeChange,
|
||||
onCodexApprovalPolicyChange,
|
||||
onCodexEnableWebSearchChange,
|
||||
onCodexEnableImagesChange,
|
||||
}: CodexSettingsProps) {
|
||||
const sandboxOption = SANDBOX_OPTIONS.find((option) => option.value === codexSandboxMode);
|
||||
const approvalOption = APPROVAL_OPTIONS.find((option) => option.value === codexApprovalPolicy);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<OpenAIIcon className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">{CARD_TITLE}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">{CARD_SUBTITLE}</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-5">
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
id="auto-load-codex-agents"
|
||||
checked={autoLoadCodexAgents}
|
||||
onCheckedChange={(checked) => onAutoLoadCodexAgentsChange(checked === true)}
|
||||
className="mt-1"
|
||||
data-testid="auto-load-codex-agents-checkbox"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="auto-load-codex-agents"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<FileCode className="w-4 h-4 text-brand-500" />
|
||||
{AGENTS_TITLE}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
{AGENTS_DESCRIPTION}{' '}
|
||||
<code className="text-[10px] px-1 py-0.5 rounded bg-accent/50">{AGENTS_PATH}</code>{' '}
|
||||
{AGENTS_SUFFIX}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
id="codex-enable-web-search"
|
||||
checked={codexEnableWebSearch}
|
||||
onCheckedChange={(checked) => onCodexEnableWebSearchChange(checked === true)}
|
||||
className="mt-1"
|
||||
data-testid="codex-enable-web-search-checkbox"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="codex-enable-web-search"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<Globe className="w-4 h-4 text-brand-500" />
|
||||
{WEB_SEARCH_TITLE}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
{WEB_SEARCH_DESCRIPTION}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
id="codex-enable-images"
|
||||
checked={codexEnableImages}
|
||||
onCheckedChange={(checked) => onCodexEnableImagesChange(checked === true)}
|
||||
className="mt-1"
|
||||
data-testid="codex-enable-images-checkbox"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="codex-enable-images"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4 text-brand-500" />
|
||||
{IMAGES_TITLE}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">{IMAGES_DESCRIPTION}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<div className="w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0 bg-brand-500/10">
|
||||
<ShieldCheck className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<Label className="text-foreground font-medium">{SANDBOX_TITLE}</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
{sandboxOption?.description}
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={codexSandboxMode}
|
||||
onValueChange={(value) => onCodexSandboxModeChange(value as CodexSandboxMode)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] h-8" data-testid="codex-sandbox-select">
|
||||
<SelectValue aria-label={SANDBOX_SELECT_LABEL} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SANDBOX_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<Label className="text-foreground font-medium">{APPROVAL_TITLE}</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
{approvalOption?.description}
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={codexApprovalPolicy}
|
||||
onValueChange={(value) => onCodexApprovalPolicyChange(value as CodexApprovalPolicy)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] h-8" data-testid="codex-approval-select">
|
||||
<SelectValue aria-label={APPROVAL_SELECT_LABEL} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{APPROVAL_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, AlertCircle } from 'lucide-react';
|
||||
import { OpenAIIcon } from '@/components/ui/provider-icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import {
|
||||
formatCodexCredits,
|
||||
formatCodexPlanType,
|
||||
formatCodexResetTime,
|
||||
getCodexWindowLabel,
|
||||
} from '@/lib/codex-usage-format';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { useAppStore, type CodexRateLimitWindow } from '@/store/app-store';
|
||||
|
||||
const ERROR_NO_API = 'Codex usage API not available';
|
||||
const CODEX_USAGE_TITLE = 'Codex Usage';
|
||||
const CODEX_USAGE_SUBTITLE = 'Shows usage limits reported by the Codex CLI.';
|
||||
const CODEX_AUTH_WARNING = 'Authenticate Codex CLI to view usage limits.';
|
||||
const CODEX_LOGIN_COMMAND = 'codex login';
|
||||
const CODEX_NO_USAGE_MESSAGE =
|
||||
'Usage limits are not available yet. Try refreshing if this persists.';
|
||||
const UPDATED_LABEL = 'Updated';
|
||||
const CODEX_FETCH_ERROR = 'Failed to fetch usage';
|
||||
const CODEX_REFRESH_LABEL = 'Refresh Codex usage';
|
||||
const PLAN_LABEL = 'Plan';
|
||||
const CREDITS_LABEL = 'Credits';
|
||||
const WARNING_THRESHOLD = 75;
|
||||
const CAUTION_THRESHOLD = 50;
|
||||
const MAX_PERCENTAGE = 100;
|
||||
const REFRESH_INTERVAL_MS = 60_000;
|
||||
const STALE_THRESHOLD_MS = 2 * 60_000;
|
||||
const USAGE_COLOR_CRITICAL = 'bg-red-500';
|
||||
const USAGE_COLOR_WARNING = 'bg-amber-500';
|
||||
const USAGE_COLOR_OK = 'bg-emerald-500';
|
||||
|
||||
const isRateLimitWindow = (
|
||||
limitWindow: CodexRateLimitWindow | null
|
||||
): limitWindow is CodexRateLimitWindow => Boolean(limitWindow);
|
||||
|
||||
export function CodexUsageSection() {
|
||||
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
||||
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const canFetchUsage = !!codexAuthStatus?.authenticated;
|
||||
const rateLimits = codexUsage?.rateLimits ?? null;
|
||||
const primary = rateLimits?.primary ?? null;
|
||||
const secondary = rateLimits?.secondary ?? null;
|
||||
const credits = rateLimits?.credits ?? null;
|
||||
const planType = rateLimits?.planType ?? null;
|
||||
const rateLimitWindows = [primary, secondary].filter(isRateLimitWindow);
|
||||
const hasMetrics = rateLimitWindows.length > 0;
|
||||
const lastUpdatedLabel = codexUsage?.lastUpdated
|
||||
? new Date(codexUsage.lastUpdated).toLocaleString()
|
||||
: null;
|
||||
const showAuthWarning = !canFetchUsage && !codexUsage && !isLoading;
|
||||
const isStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > STALE_THRESHOLD_MS;
|
||||
|
||||
const fetchUsage = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.codex) {
|
||||
setError(ERROR_NO_API);
|
||||
return;
|
||||
}
|
||||
const result = await api.codex.getUsage();
|
||||
if ('error' in result) {
|
||||
setError(result.message || result.error);
|
||||
return;
|
||||
}
|
||||
setCodexUsage(result);
|
||||
} catch (fetchError) {
|
||||
const message = fetchError instanceof Error ? fetchError.message : CODEX_FETCH_ERROR;
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [setCodexUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canFetchUsage && isStale) {
|
||||
void fetchUsage();
|
||||
}
|
||||
}, [fetchUsage, canFetchUsage, isStale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canFetchUsage) return undefined;
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
void fetchUsage();
|
||||
}, REFRESH_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [fetchUsage, canFetchUsage]);
|
||||
|
||||
const getUsageColor = (percentage: number) => {
|
||||
if (percentage >= WARNING_THRESHOLD) {
|
||||
return USAGE_COLOR_CRITICAL;
|
||||
}
|
||||
if (percentage >= CAUTION_THRESHOLD) {
|
||||
return USAGE_COLOR_WARNING;
|
||||
}
|
||||
return USAGE_COLOR_OK;
|
||||
};
|
||||
|
||||
const RateLimitCard = ({
|
||||
title,
|
||||
subtitle,
|
||||
window: limitWindow,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
window: CodexRateLimitWindow;
|
||||
}) => {
|
||||
const safePercentage = Math.min(Math.max(limitWindow.usedPercent, 0), MAX_PERCENTAGE);
|
||||
const resetLabel = formatCodexResetTime(limitWindow.resetsAt);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/60 bg-card/50 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">{title}</p>
|
||||
<p className="text-xs text-muted-foreground">{subtitle}</p>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{Math.round(safePercentage)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 h-2 w-full rounded-full bg-secondary/60">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-300',
|
||||
getUsageColor(safePercentage)
|
||||
)}
|
||||
style={{ width: `${safePercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
{resetLabel && <p className="mt-2 text-xs text-muted-foreground">{resetLabel}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<OpenAIIcon className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
{CODEX_USAGE_TITLE}
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={fetchUsage}
|
||||
disabled={isLoading}
|
||||
className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50"
|
||||
data-testid="refresh-codex-usage"
|
||||
title={CODEX_REFRESH_LABEL}
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">{CODEX_USAGE_SUBTITLE}</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{showAuthWarning && (
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
|
||||
<AlertCircle className="w-5 h-5 text-amber-500 mt-0.5" />
|
||||
<div className="text-sm text-amber-400">
|
||||
{CODEX_AUTH_WARNING} Run <span className="font-mono">{CODEX_LOGIN_COMMAND}</span>.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
|
||||
<AlertCircle className="w-5 h-5 text-red-500 mt-0.5" />
|
||||
<div className="text-sm text-red-400">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
{hasMetrics && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{rateLimitWindows.map((limitWindow, index) => {
|
||||
const { title, subtitle } = getCodexWindowLabel(limitWindow.windowDurationMins);
|
||||
return (
|
||||
<RateLimitCard
|
||||
key={`${title}-${index}`}
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
window={limitWindow}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{(planType || credits) && (
|
||||
<div className="rounded-xl border border-border/60 bg-secondary/20 p-4 text-xs text-muted-foreground">
|
||||
{planType && (
|
||||
<div>
|
||||
{PLAN_LABEL}:{' '}
|
||||
<span className="text-foreground">{formatCodexPlanType(planType)}</span>
|
||||
</div>
|
||||
)}
|
||||
{credits && (
|
||||
<div>
|
||||
{CREDITS_LABEL}:{' '}
|
||||
<span className="text-foreground">{formatCodexCredits(credits)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!hasMetrics && !error && canFetchUsage && !isLoading && (
|
||||
<div className="rounded-xl border border-border/60 bg-secondary/20 p-4 text-xs text-muted-foreground">
|
||||
{CODEX_NO_USAGE_MESSAGE}
|
||||
</div>
|
||||
)}
|
||||
{lastUpdatedLabel && (
|
||||
<div className="text-[10px] text-muted-foreground text-right">
|
||||
{UPDATED_LABEL} {lastUpdatedLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,10 +19,12 @@ import {
|
||||
import {
|
||||
CLAUDE_MODELS,
|
||||
CURSOR_MODELS,
|
||||
CODEX_MODELS,
|
||||
THINKING_LEVELS,
|
||||
THINKING_LEVEL_LABELS,
|
||||
} from '@/components/views/board-view/shared/model-constants';
|
||||
import { Check, ChevronsUpDown, Star, Brain, Sparkles, ChevronRight } from 'lucide-react';
|
||||
import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react';
|
||||
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
@@ -140,14 +142,14 @@ export function PhaseModelSelector({
|
||||
return {
|
||||
...claudeModel,
|
||||
label: `${claudeModel.label}${thinkingLabel}`,
|
||||
icon: Brain,
|
||||
icon: AnthropicIcon,
|
||||
};
|
||||
}
|
||||
|
||||
const cursorModel = availableCursorModels.find(
|
||||
(m) => stripProviderPrefix(m.id) === selectedModel
|
||||
);
|
||||
if (cursorModel) return { ...cursorModel, icon: Sparkles };
|
||||
if (cursorModel) return { ...cursorModel, icon: CursorIcon };
|
||||
|
||||
// Check if selectedModel is part of a grouped model
|
||||
const group = getModelGroup(selectedModel as CursorModelId);
|
||||
@@ -158,10 +160,14 @@ export function PhaseModelSelector({
|
||||
label: `${group.label} (${variant?.label || 'Unknown'})`,
|
||||
description: group.description,
|
||||
provider: 'cursor' as const,
|
||||
icon: Sparkles,
|
||||
icon: CursorIcon,
|
||||
};
|
||||
}
|
||||
|
||||
// Check Codex models
|
||||
const codexModel = CODEX_MODELS.find((m) => m.id === selectedModel);
|
||||
if (codexModel) return { ...codexModel, icon: OpenAIIcon };
|
||||
|
||||
return null;
|
||||
}, [selectedModel, selectedThinkingLevel, availableCursorModels]);
|
||||
|
||||
@@ -199,10 +205,11 @@ export function PhaseModelSelector({
|
||||
}, [availableCursorModels, enabledCursorModels]);
|
||||
|
||||
// Group models
|
||||
const { favorites, claude, cursor } = React.useMemo(() => {
|
||||
const { favorites, claude, cursor, codex } = React.useMemo(() => {
|
||||
const favs: typeof CLAUDE_MODELS = [];
|
||||
const cModels: typeof CLAUDE_MODELS = [];
|
||||
const curModels: typeof CURSOR_MODELS = [];
|
||||
const codModels: typeof CODEX_MODELS = [];
|
||||
|
||||
// Process Claude Models
|
||||
CLAUDE_MODELS.forEach((model) => {
|
||||
@@ -222,9 +229,71 @@ export function PhaseModelSelector({
|
||||
}
|
||||
});
|
||||
|
||||
return { favorites: favs, claude: cModels, cursor: curModels };
|
||||
// Process Codex Models
|
||||
CODEX_MODELS.forEach((model) => {
|
||||
if (favoriteModels.includes(model.id)) {
|
||||
favs.push(model);
|
||||
} else {
|
||||
codModels.push(model);
|
||||
}
|
||||
});
|
||||
|
||||
return { favorites: favs, claude: cModels, cursor: curModels, codex: codModels };
|
||||
}, [favoriteModels, availableCursorModels]);
|
||||
|
||||
// Render Codex model item (no thinking level needed)
|
||||
const renderCodexModelItem = (model: (typeof CODEX_MODELS)[0]) => {
|
||||
const isSelected = selectedModel === model.id;
|
||||
const isFavorite = favoriteModels.includes(model.id);
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={model.id}
|
||||
value={model.label}
|
||||
onSelect={() => {
|
||||
onChange({ model: model.id });
|
||||
setOpen(false);
|
||||
}}
|
||||
className="group flex items-center justify-between py-2"
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<OpenAIIcon
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
isSelected ? 'text-primary' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col truncate">
|
||||
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
|
||||
{model.label}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{model.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-6 w-6 hover:bg-transparent hover:text-yellow-500 focus:ring-0',
|
||||
isFavorite
|
||||
? 'text-yellow-500 opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100 text-muted-foreground'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFavoriteModel(model.id);
|
||||
}}
|
||||
>
|
||||
<Star className={cn('h-3.5 w-3.5', isFavorite && 'fill-current')} />
|
||||
</Button>
|
||||
{isSelected && <Check className="h-4 w-4 text-primary shrink-0" />}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
};
|
||||
|
||||
// Render Cursor model item (no thinking level needed)
|
||||
const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => {
|
||||
const modelValue = stripProviderPrefix(model.id);
|
||||
@@ -242,7 +311,7 @@ export function PhaseModelSelector({
|
||||
className="group flex items-center justify-between py-2"
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<Sparkles
|
||||
<CursorIcon
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
isSelected ? 'text-primary' : 'text-muted-foreground'
|
||||
@@ -311,7 +380,7 @@ export function PhaseModelSelector({
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<Brain
|
||||
<AnthropicIcon
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
isSelected ? 'text-primary' : 'text-muted-foreground'
|
||||
@@ -445,7 +514,7 @@ export function PhaseModelSelector({
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<Sparkles
|
||||
<CursorIcon
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
groupIsSelected ? 'text-primary' : 'text-muted-foreground'
|
||||
@@ -603,6 +672,10 @@ export function PhaseModelSelector({
|
||||
// Standalone Cursor model
|
||||
return renderCursorModelItem(model);
|
||||
}
|
||||
// Codex model
|
||||
if (model.provider === 'codex') {
|
||||
return renderCodexModelItem(model);
|
||||
}
|
||||
// Claude model
|
||||
return renderClaudeModelItem(model);
|
||||
});
|
||||
@@ -626,6 +699,12 @@ export function PhaseModelSelector({
|
||||
{standaloneCursorModels.map((model) => renderCursorModelItem(model))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{codex.length > 0 && (
|
||||
<CommandGroup heading="Codex Models">
|
||||
{codex.map((model) => renderCodexModelItem(model))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { CodexCliStatus } from '../cli-status/codex-cli-status';
|
||||
import { CodexSettings } from '../codex/codex-settings';
|
||||
import { CodexUsageSection } from '../codex/codex-usage-section';
|
||||
import { Info } from 'lucide-react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
|
||||
const logger = createLogger('CodexSettings');
|
||||
|
||||
export function CodexSettingsTab() {
|
||||
const {
|
||||
codexAutoLoadAgents,
|
||||
setCodexAutoLoadAgents,
|
||||
codexSandboxMode,
|
||||
setCodexSandboxMode,
|
||||
codexApprovalPolicy,
|
||||
setCodexApprovalPolicy,
|
||||
} = useAppStore();
|
||||
const { codexAuthStatus, codexCliStatus, setCodexCliStatus, setCodexAuthStatus } =
|
||||
useSetupStore();
|
||||
|
||||
const [isCheckingCodexCli, setIsCheckingCodexCli] = useState(false);
|
||||
|
||||
const handleRefreshCodexCli = useCallback(async () => {
|
||||
setIsCheckingCodexCli(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api?.setup?.getCodexStatus) {
|
||||
const result = await api.setup.getCodexStatus();
|
||||
if (result.success) {
|
||||
setCodexCliStatus({
|
||||
installed: result.installed,
|
||||
version: result.version,
|
||||
path: result.path,
|
||||
method: result.method,
|
||||
});
|
||||
if (result.auth) {
|
||||
setCodexAuthStatus({
|
||||
authenticated: result.auth.authenticated,
|
||||
method: result.auth.method,
|
||||
hasAuthFile: result.auth.hasAuthFile,
|
||||
hasOAuthToken: result.auth.hasOAuthToken,
|
||||
hasApiKey: result.auth.hasApiKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh Codex CLI status:', error);
|
||||
} finally {
|
||||
setIsCheckingCodexCli(false);
|
||||
}
|
||||
}, [setCodexCliStatus, setCodexAuthStatus]);
|
||||
|
||||
// Show usage tracking when CLI is authenticated
|
||||
const showUsageTracking = codexAuthStatus?.authenticated ?? false;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Usage Info */}
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
|
||||
<Info className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-emerald-400/90">
|
||||
<span className="font-medium">OpenAI via Codex CLI</span>
|
||||
<p className="text-xs text-emerald-400/70 mt-1">
|
||||
Access GPT models with tool support for advanced coding workflows.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CodexCliStatus
|
||||
status={codexCliStatus}
|
||||
isChecking={isCheckingCodexCli}
|
||||
onRefresh={handleRefreshCodexCli}
|
||||
/>
|
||||
<CodexSettings
|
||||
autoLoadCodexAgents={codexAutoLoadAgents}
|
||||
codexSandboxMode={codexSandboxMode}
|
||||
codexApprovalPolicy={codexApprovalPolicy}
|
||||
onAutoLoadCodexAgentsChange={setCodexAutoLoadAgents}
|
||||
onCodexSandboxModeChange={setCodexSandboxMode}
|
||||
onCodexApprovalPolicyChange={setCodexApprovalPolicy}
|
||||
/>
|
||||
{showUsageTracking && <CodexUsageSection />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CodexSettingsTab;
|
||||
@@ -1,3 +1,4 @@
|
||||
export { ProviderTabs } from './provider-tabs';
|
||||
export { ClaudeSettingsTab } from './claude-settings-tab';
|
||||
export { CursorSettingsTab } from './cursor-settings-tab';
|
||||
export { CodexSettingsTab } from './codex-settings-tab';
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import React from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Bot, Terminal } from 'lucide-react';
|
||||
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||
import { CursorSettingsTab } from './cursor-settings-tab';
|
||||
import { ClaudeSettingsTab } from './claude-settings-tab';
|
||||
import { CodexSettingsTab } from './codex-settings-tab';
|
||||
|
||||
interface ProviderTabsProps {
|
||||
defaultTab?: 'claude' | 'cursor';
|
||||
defaultTab?: 'claude' | 'cursor' | 'codex';
|
||||
}
|
||||
|
||||
export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
|
||||
return (
|
||||
<Tabs defaultValue={defaultTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsList className="grid w-full grid-cols-3 mb-6">
|
||||
<TabsTrigger value="claude" className="flex items-center gap-2">
|
||||
<Bot className="w-4 h-4" />
|
||||
<AnthropicIcon className="w-4 h-4" />
|
||||
Claude
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="cursor" className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4" />
|
||||
<CursorIcon className="w-4 h-4" />
|
||||
Cursor
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="codex" className="flex items-center gap-2">
|
||||
<OpenAIIcon className="w-4 h-4" />
|
||||
Codex
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="claude">
|
||||
@@ -29,6 +34,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
|
||||
<TabsContent value="cursor">
|
||||
<CursorSettingsTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="codex">
|
||||
<CodexSettingsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
CompleteStep,
|
||||
ClaudeSetupStep,
|
||||
CursorSetupStep,
|
||||
CodexSetupStep,
|
||||
GitHubSetupStep,
|
||||
} from './setup-view/steps';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
@@ -18,13 +19,14 @@ export function SetupView() {
|
||||
const { currentStep, setCurrentStep, completeSetup, setSkipClaudeSetup } = useSetupStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const steps = ['welcome', 'theme', 'claude', 'cursor', 'github', 'complete'] as const;
|
||||
const steps = ['welcome', 'theme', 'claude', 'cursor', 'codex', 'github', 'complete'] as const;
|
||||
type StepName = (typeof steps)[number];
|
||||
const getStepName = (): StepName => {
|
||||
if (currentStep === 'claude_detect' || currentStep === 'claude_auth') return 'claude';
|
||||
if (currentStep === 'welcome') return 'welcome';
|
||||
if (currentStep === 'theme') return 'theme';
|
||||
if (currentStep === 'cursor') return 'cursor';
|
||||
if (currentStep === 'codex') return 'codex';
|
||||
if (currentStep === 'github') return 'github';
|
||||
return 'complete';
|
||||
};
|
||||
@@ -46,6 +48,10 @@ export function SetupView() {
|
||||
setCurrentStep('cursor');
|
||||
break;
|
||||
case 'cursor':
|
||||
logger.debug('[Setup Flow] Moving to codex step');
|
||||
setCurrentStep('codex');
|
||||
break;
|
||||
case 'codex':
|
||||
logger.debug('[Setup Flow] Moving to github step');
|
||||
setCurrentStep('github');
|
||||
break;
|
||||
@@ -68,9 +74,12 @@ export function SetupView() {
|
||||
case 'cursor':
|
||||
setCurrentStep('claude_detect');
|
||||
break;
|
||||
case 'github':
|
||||
case 'codex':
|
||||
setCurrentStep('cursor');
|
||||
break;
|
||||
case 'github':
|
||||
setCurrentStep('codex');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,6 +91,11 @@ export function SetupView() {
|
||||
|
||||
const handleSkipCursor = () => {
|
||||
logger.debug('[Setup Flow] Skipping Cursor setup');
|
||||
setCurrentStep('codex');
|
||||
};
|
||||
|
||||
const handleSkipCodex = () => {
|
||||
logger.debug('[Setup Flow] Skipping Codex setup');
|
||||
setCurrentStep('github');
|
||||
};
|
||||
|
||||
@@ -139,6 +153,14 @@ export function SetupView() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'codex' && (
|
||||
<CodexSetupStep
|
||||
onNext={() => handleNext('codex')}
|
||||
onBack={() => handleBack('codex')}
|
||||
onSkip={handleSkipCodex}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'github' && (
|
||||
<GitHubSetupStep
|
||||
onNext={() => handleNext('github')}
|
||||
|
||||
@@ -2,13 +2,26 @@ import { useState, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
|
||||
interface UseCliStatusOptions {
|
||||
cliType: 'claude';
|
||||
cliType: 'claude' | 'codex';
|
||||
statusApi: () => Promise<any>;
|
||||
setCliStatus: (status: any) => void;
|
||||
setAuthStatus: (status: any) => void;
|
||||
}
|
||||
|
||||
// Create logger once outside the hook to prevent infinite re-renders
|
||||
const VALID_AUTH_METHODS = {
|
||||
claude: [
|
||||
'oauth_token_env',
|
||||
'oauth_token',
|
||||
'api_key',
|
||||
'api_key_env',
|
||||
'credentials_file',
|
||||
'cli_authenticated',
|
||||
'none',
|
||||
],
|
||||
codex: ['cli_authenticated', 'api_key', 'api_key_env', 'none'],
|
||||
} as const;
|
||||
|
||||
// Create logger outside of the hook to avoid re-creating it on every render
|
||||
const logger = createLogger('CliStatus');
|
||||
|
||||
export function useCliStatus({
|
||||
@@ -38,29 +51,31 @@ export function useCliStatus({
|
||||
|
||||
if (result.auth) {
|
||||
// Validate method is one of the expected values, default to "none"
|
||||
const validMethods = [
|
||||
'oauth_token_env',
|
||||
'oauth_token',
|
||||
'api_key',
|
||||
'api_key_env',
|
||||
'credentials_file',
|
||||
'cli_authenticated',
|
||||
'none',
|
||||
] as const;
|
||||
const validMethods = VALID_AUTH_METHODS[cliType] ?? ['none'] as const;
|
||||
type AuthMethod = (typeof validMethods)[number];
|
||||
const method: AuthMethod = validMethods.includes(result.auth.method as AuthMethod)
|
||||
? (result.auth.method as AuthMethod)
|
||||
: 'none';
|
||||
const authStatus = {
|
||||
authenticated: result.auth.authenticated,
|
||||
method,
|
||||
hasCredentialsFile: false,
|
||||
oauthTokenValid: result.auth.hasStoredOAuthToken || result.auth.hasEnvOAuthToken,
|
||||
apiKeyValid: result.auth.hasStoredApiKey || result.auth.hasEnvApiKey,
|
||||
hasEnvOAuthToken: result.auth.hasEnvOAuthToken,
|
||||
hasEnvApiKey: result.auth.hasEnvApiKey,
|
||||
};
|
||||
setAuthStatus(authStatus);
|
||||
|
||||
if (cliType === 'claude') {
|
||||
setAuthStatus({
|
||||
authenticated: result.auth.authenticated,
|
||||
method,
|
||||
hasCredentialsFile: false,
|
||||
oauthTokenValid: result.auth.hasStoredOAuthToken || result.auth.hasEnvOAuthToken,
|
||||
apiKeyValid: result.auth.hasStoredApiKey || result.auth.hasEnvApiKey,
|
||||
hasEnvOAuthToken: result.auth.hasEnvOAuthToken,
|
||||
hasEnvApiKey: result.auth.hasEnvApiKey,
|
||||
});
|
||||
} else {
|
||||
setAuthStatus({
|
||||
authenticated: result.auth.authenticated,
|
||||
method,
|
||||
hasAuthFile: result.auth.hasAuthFile ?? false,
|
||||
hasApiKey: result.auth.hasApiKey ?? false,
|
||||
hasEnvApiKey: result.auth.hasEnvApiKey ?? false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
809
apps/ui/src/components/views/setup-view/steps/cli-setup-step.tsx
Normal file
809
apps/ui/src/components/views/setup-view/steps/cli-setup-step.tsx
Normal file
@@ -0,0 +1,809 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import {
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Key,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Info,
|
||||
ShieldCheck,
|
||||
XCircle,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { StatusBadge, TerminalOutput } from '../components';
|
||||
import { useCliStatus, useCliInstallation, useTokenSave } from '../hooks';
|
||||
import type { ApiKeys } from '@/store/app-store';
|
||||
import type { ModelProvider } from '@/store/app-store';
|
||||
import type { ProviderKey } from '@/config/api-providers';
|
||||
import type {
|
||||
CliStatus,
|
||||
InstallProgress,
|
||||
ClaudeAuthStatus,
|
||||
CodexAuthStatus,
|
||||
} from '@/store/setup-store';
|
||||
import { PROVIDER_ICON_COMPONENTS } from '@/components/ui/provider-icon';
|
||||
|
||||
type VerificationStatus = 'idle' | 'verifying' | 'verified' | 'error';
|
||||
|
||||
type CliSetupAuthStatus = ClaudeAuthStatus | CodexAuthStatus;
|
||||
|
||||
interface CliSetupConfig {
|
||||
cliType: ModelProvider;
|
||||
displayName: string;
|
||||
cliLabel: string;
|
||||
cliDescription: string;
|
||||
apiKeyLabel: string;
|
||||
apiKeyDescription: string;
|
||||
apiKeyProvider: ProviderKey;
|
||||
apiKeyPlaceholder: string;
|
||||
apiKeyDocsUrl: string;
|
||||
apiKeyDocsLabel: string;
|
||||
installCommands: {
|
||||
macos: string;
|
||||
windows: string;
|
||||
};
|
||||
cliLoginCommand: string;
|
||||
testIds: {
|
||||
installButton: string;
|
||||
verifyCliButton: string;
|
||||
verifyApiKeyButton: string;
|
||||
apiKeyInput: string;
|
||||
saveApiKeyButton: string;
|
||||
deleteApiKeyButton: string;
|
||||
nextButton: string;
|
||||
};
|
||||
buildCliAuthStatus: (previous: CliSetupAuthStatus | null) => CliSetupAuthStatus;
|
||||
buildApiKeyAuthStatus: (previous: CliSetupAuthStatus | null) => CliSetupAuthStatus;
|
||||
buildClearedAuthStatus: (previous: CliSetupAuthStatus | null) => CliSetupAuthStatus;
|
||||
statusApi: () => Promise<any>;
|
||||
installApi: () => Promise<any>;
|
||||
verifyAuthApi: (method: 'cli' | 'api_key') => Promise<{
|
||||
success: boolean;
|
||||
authenticated: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
apiKeyHelpText: string;
|
||||
}
|
||||
|
||||
interface CliSetupStateHandlers {
|
||||
cliStatus: CliStatus | null;
|
||||
authStatus: CliSetupAuthStatus | null;
|
||||
setCliStatus: (status: CliStatus | null) => void;
|
||||
setAuthStatus: (status: CliSetupAuthStatus | null) => void;
|
||||
setInstallProgress: (progress: Partial<InstallProgress>) => void;
|
||||
getStoreState: () => CliStatus | null;
|
||||
}
|
||||
|
||||
interface CliSetupStepProps {
|
||||
config: CliSetupConfig;
|
||||
state: CliSetupStateHandlers;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetupStepProps) {
|
||||
const { apiKeys, setApiKeys } = useAppStore();
|
||||
const { cliStatus, authStatus, setCliStatus, setAuthStatus, setInstallProgress, getStoreState } =
|
||||
state;
|
||||
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
|
||||
const [cliVerificationStatus, setCliVerificationStatus] = useState<VerificationStatus>('idle');
|
||||
const [cliVerificationError, setCliVerificationError] = useState<string | null>(null);
|
||||
|
||||
const [apiKeyVerificationStatus, setApiKeyVerificationStatus] =
|
||||
useState<VerificationStatus>('idle');
|
||||
const [apiKeyVerificationError, setApiKeyVerificationError] = useState<string | null>(null);
|
||||
|
||||
const [isDeletingApiKey, setIsDeletingApiKey] = useState(false);
|
||||
|
||||
const statusApi = useCallback(() => config.statusApi(), [config]);
|
||||
const installApi = useCallback(() => config.installApi(), [config]);
|
||||
|
||||
const { isChecking, checkStatus } = useCliStatus({
|
||||
cliType: config.cliType,
|
||||
statusApi,
|
||||
setCliStatus,
|
||||
setAuthStatus,
|
||||
});
|
||||
|
||||
const onInstallSuccess = useCallback(() => {
|
||||
checkStatus();
|
||||
}, [checkStatus]);
|
||||
|
||||
const { isInstalling, installProgress, install } = useCliInstallation({
|
||||
cliType: config.cliType,
|
||||
installApi,
|
||||
onProgressEvent: getElectronAPI().setup?.onInstallProgress,
|
||||
onSuccess: onInstallSuccess,
|
||||
getStoreState,
|
||||
});
|
||||
|
||||
const { isSaving: isSavingApiKey, saveToken: saveApiKeyToken } = useTokenSave({
|
||||
provider: config.apiKeyProvider,
|
||||
onSuccess: () => {
|
||||
setAuthStatus(config.buildApiKeyAuthStatus(authStatus));
|
||||
setApiKeys({ ...apiKeys, [config.apiKeyProvider]: apiKey });
|
||||
toast.success('API key saved successfully!');
|
||||
},
|
||||
});
|
||||
|
||||
const verifyCliAuth = useCallback(async () => {
|
||||
setCliVerificationStatus('verifying');
|
||||
setCliVerificationError(null);
|
||||
|
||||
try {
|
||||
const result = await config.verifyAuthApi('cli');
|
||||
|
||||
const hasLimitOrBillingError =
|
||||
result.error?.toLowerCase().includes('limit reached') ||
|
||||
result.error?.toLowerCase().includes('rate limit') ||
|
||||
result.error?.toLowerCase().includes('credit balance') ||
|
||||
result.error?.toLowerCase().includes('billing');
|
||||
|
||||
if (result.authenticated) {
|
||||
// Auth succeeded - even if rate limited or billing issue
|
||||
setCliVerificationStatus('verified');
|
||||
setAuthStatus(config.buildCliAuthStatus(authStatus));
|
||||
|
||||
if (hasLimitOrBillingError) {
|
||||
// Show warning but keep auth verified
|
||||
toast.warning(result.error || 'Rate limit or billing issue');
|
||||
} else {
|
||||
toast.success(`${config.displayName} CLI authentication verified!`);
|
||||
}
|
||||
} else {
|
||||
// Actual auth failure
|
||||
setCliVerificationStatus('error');
|
||||
// Include detailed error if available
|
||||
const errorDisplay = result.details
|
||||
? `${result.error}\n\nDetails: ${result.details}`
|
||||
: result.error || 'Authentication failed';
|
||||
setCliVerificationError(errorDisplay);
|
||||
setAuthStatus(config.buildClearedAuthStatus(authStatus));
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Verification failed';
|
||||
setCliVerificationStatus('error');
|
||||
setCliVerificationError(errorMessage);
|
||||
}
|
||||
}, [authStatus, config, setAuthStatus]);
|
||||
|
||||
const verifyApiKeyAuth = useCallback(async () => {
|
||||
setApiKeyVerificationStatus('verifying');
|
||||
setApiKeyVerificationError(null);
|
||||
|
||||
try {
|
||||
const result = await config.verifyAuthApi('api_key');
|
||||
|
||||
const hasLimitOrBillingError =
|
||||
result.error?.toLowerCase().includes('limit reached') ||
|
||||
result.error?.toLowerCase().includes('rate limit') ||
|
||||
result.error?.toLowerCase().includes('credit balance') ||
|
||||
result.error?.toLowerCase().includes('billing');
|
||||
|
||||
if (result.authenticated) {
|
||||
// Auth succeeded - even if rate limited or billing issue
|
||||
setApiKeyVerificationStatus('verified');
|
||||
setAuthStatus(config.buildApiKeyAuthStatus(authStatus));
|
||||
|
||||
if (hasLimitOrBillingError) {
|
||||
// Show warning but keep auth verified
|
||||
toast.warning(result.error || 'Rate limit or billing issue');
|
||||
} else {
|
||||
toast.success('API key authentication verified!');
|
||||
}
|
||||
} else {
|
||||
// Actual auth failure
|
||||
setApiKeyVerificationStatus('error');
|
||||
// Include detailed error if available
|
||||
const errorDisplay = result.details
|
||||
? `${result.error}\n\nDetails: ${result.details}`
|
||||
: result.error || 'Authentication failed';
|
||||
setApiKeyVerificationError(errorDisplay);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Verification failed';
|
||||
setApiKeyVerificationStatus('error');
|
||||
setApiKeyVerificationError(errorMessage);
|
||||
}
|
||||
}, [authStatus, config, setAuthStatus]);
|
||||
|
||||
const deleteApiKey = useCallback(async () => {
|
||||
setIsDeletingApiKey(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.setup?.deleteApiKey) {
|
||||
toast.error('Delete API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.setup.deleteApiKey(config.apiKeyProvider);
|
||||
if (result.success) {
|
||||
setApiKey('');
|
||||
setApiKeys({ ...apiKeys, [config.apiKeyProvider]: '' });
|
||||
setApiKeyVerificationStatus('idle');
|
||||
setApiKeyVerificationError(null);
|
||||
setAuthStatus(config.buildClearedAuthStatus(authStatus));
|
||||
toast.success('API key deleted successfully');
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to delete API key');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to delete API key';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsDeletingApiKey(false);
|
||||
}
|
||||
}, [apiKeys, authStatus, config, setApiKeys, setAuthStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
setInstallProgress({
|
||||
isInstalling,
|
||||
output: installProgress.output,
|
||||
});
|
||||
}, [isInstalling, installProgress, setInstallProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
checkStatus();
|
||||
}, [checkStatus]);
|
||||
|
||||
const copyCommand = (command: string) => {
|
||||
navigator.clipboard.writeText(command);
|
||||
toast.success('Command copied to clipboard');
|
||||
};
|
||||
|
||||
const hasApiKey =
|
||||
!!(apiKeys as ApiKeys)[config.apiKeyProvider] ||
|
||||
authStatus?.method === 'api_key' ||
|
||||
authStatus?.method === 'api_key_env';
|
||||
const isCliVerified = cliVerificationStatus === 'verified';
|
||||
const isApiKeyVerified = apiKeyVerificationStatus === 'verified';
|
||||
const isReady = isCliVerified || isApiKeyVerified;
|
||||
const ProviderIcon = PROVIDER_ICON_COMPONENTS[config.cliType];
|
||||
|
||||
const getCliStatusBadge = () => {
|
||||
if (cliVerificationStatus === 'verified') {
|
||||
return <StatusBadge status="authenticated" label="Verified" />;
|
||||
}
|
||||
if (cliVerificationStatus === 'error') {
|
||||
return <StatusBadge status="error" label="Error" />;
|
||||
}
|
||||
if (isChecking) {
|
||||
return <StatusBadge status="checking" label="Checking..." />;
|
||||
}
|
||||
if (cliStatus?.installed) {
|
||||
return <StatusBadge status="unverified" label="Unverified" />;
|
||||
}
|
||||
return <StatusBadge status="not_installed" label="Not Installed" />;
|
||||
};
|
||||
|
||||
const getApiKeyStatusBadge = () => {
|
||||
if (apiKeyVerificationStatus === 'verified') {
|
||||
return <StatusBadge status="authenticated" label="Verified" />;
|
||||
}
|
||||
if (apiKeyVerificationStatus === 'error') {
|
||||
return <StatusBadge status="error" label="Error" />;
|
||||
}
|
||||
if (hasApiKey) {
|
||||
return <StatusBadge status="unverified" label="Unverified" />;
|
||||
}
|
||||
return <StatusBadge status="not_authenticated" label="Not Set" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 rounded-xl bg-brand-500/10 flex items-center justify-center mx-auto mb-4">
|
||||
<ProviderIcon className="w-8 h-8 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">{config.displayName} Setup</h2>
|
||||
<p className="text-muted-foreground">Configure authentication for code generation</p>
|
||||
</div>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Info className="w-5 h-5" />
|
||||
Authentication Methods
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" onClick={checkStatus} disabled={isChecking}>
|
||||
<RefreshCw className={`w-4 h-4 ${isChecking ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>Choose one of the following methods to authenticate:</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="cli" className="border-border">
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex items-center justify-between w-full pr-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<ProviderIcon
|
||||
className={`w-5 h-5 ${
|
||||
cliVerificationStatus === 'verified'
|
||||
? 'text-green-500'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
/>
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-foreground">{config.cliLabel}</p>
|
||||
<p className="text-sm text-muted-foreground">{config.cliDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
{getCliStatusBadge()}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pt-4 space-y-4">
|
||||
{!cliStatus?.installed && (
|
||||
<div className="space-y-4 p-4 rounded-lg bg-muted/30 border border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="w-4 h-4 text-muted-foreground" />
|
||||
<p className="font-medium text-foreground">Install {config.cliLabel}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">macOS / Linux</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
||||
{config.installCommands.macos}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyCommand(config.installCommands.macos)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">Windows</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
||||
{config.installCommands.windows}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyCommand(config.installCommands.windows)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isInstalling && <TerminalOutput lines={installProgress.output} />}
|
||||
|
||||
<Button
|
||||
onClick={install}
|
||||
disabled={isInstalling}
|
||||
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
|
||||
data-testid={config.testIds.installButton}
|
||||
>
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Installing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Auto Install
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cliStatus?.installed && cliStatus?.version && (
|
||||
<p className="text-sm text-muted-foreground">Version: {cliStatus.version}</p>
|
||||
)}
|
||||
|
||||
{cliVerificationStatus === 'verifying' && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Verifying CLI authentication...</p>
|
||||
<p className="text-sm text-muted-foreground">Running a test query</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cliVerificationStatus === 'verified' && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">CLI Authentication verified!</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your {config.displayName} CLI is working correctly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cliVerificationStatus === 'error' && cliVerificationError && (
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<XCircle className="w-5 h-5 text-red-500 shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="font-medium text-foreground">Verification failed</p>
|
||||
{(() => {
|
||||
const parts = cliVerificationError.split('\n\nDetails: ');
|
||||
const mainError = parts[0];
|
||||
const details = parts[1];
|
||||
const errorLower = cliVerificationError.toLowerCase();
|
||||
|
||||
// Check if this is actually a usage limit issue, not an auth problem
|
||||
const isUsageLimitIssue =
|
||||
errorLower.includes('usage limit') ||
|
||||
errorLower.includes('rate limit') ||
|
||||
errorLower.includes('limit reached') ||
|
||||
errorLower.includes('too many requests') ||
|
||||
errorLower.includes('credit balance') ||
|
||||
errorLower.includes('billing') ||
|
||||
errorLower.includes('insufficient credits') ||
|
||||
errorLower.includes('upgrade to pro');
|
||||
|
||||
// Categorize error and provide helpful suggestions
|
||||
// IMPORTANT: Don't suggest re-authentication for usage limits!
|
||||
const getHelpfulSuggestion = () => {
|
||||
// Usage limit issue - NOT an authentication problem
|
||||
if (isUsageLimitIssue) {
|
||||
return {
|
||||
title: 'Usage limit issue (not authentication)',
|
||||
message:
|
||||
'Your login credentials are working fine. This is a rate limit or billing error.',
|
||||
action: 'Wait a few minutes and try again, or check your billing',
|
||||
};
|
||||
}
|
||||
|
||||
// Token refresh failures
|
||||
if (
|
||||
errorLower.includes('tokenrefresh') ||
|
||||
errorLower.includes('token refresh')
|
||||
) {
|
||||
return {
|
||||
title: 'Token refresh failed',
|
||||
message: 'Your OAuth token needs to be refreshed.',
|
||||
action: 'Re-authenticate',
|
||||
command: config.cliLoginCommand,
|
||||
};
|
||||
}
|
||||
|
||||
// Connection/transport issues
|
||||
if (errorLower.includes('transport channel closed')) {
|
||||
return {
|
||||
title: 'Connection issue',
|
||||
message:
|
||||
'The connection to the authentication server was interrupted.',
|
||||
action: 'Try again or re-authenticate',
|
||||
command: config.cliLoginCommand,
|
||||
};
|
||||
}
|
||||
|
||||
// Invalid API key
|
||||
if (errorLower.includes('invalid') && errorLower.includes('api key')) {
|
||||
return {
|
||||
title: 'Invalid API key',
|
||||
message: 'Your API key is incorrect or has been revoked.',
|
||||
action: 'Check your API key or get a new one',
|
||||
};
|
||||
}
|
||||
|
||||
// Expired token
|
||||
if (errorLower.includes('expired')) {
|
||||
return {
|
||||
title: 'Token expired',
|
||||
message: 'Your authentication token has expired.',
|
||||
action: 'Re-authenticate',
|
||||
command: config.cliLoginCommand,
|
||||
};
|
||||
}
|
||||
|
||||
// Authentication required
|
||||
if (errorLower.includes('login') || errorLower.includes('authenticate')) {
|
||||
return {
|
||||
title: 'Authentication required',
|
||||
message: 'You need to authenticate with your account.',
|
||||
action: 'Run the login command',
|
||||
command: config.cliLoginCommand,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const suggestion = getHelpfulSuggestion();
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-red-400">{mainError}</p>
|
||||
{details && (
|
||||
<div className="mt-2 p-3 rounded bg-black/20 border border-red-500/20">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||
Technical details:
|
||||
</p>
|
||||
<pre className="text-xs text-red-300 whitespace-pre-wrap font-mono">
|
||||
{details}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{suggestion && (
|
||||
<div className="mt-3 p-3 rounded bg-muted/50 border border-border">
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
💡 {suggestion.title}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{suggestion.message}
|
||||
</p>
|
||||
{suggestion.command && (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{suggestion.action}:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
||||
{suggestion.command}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyCommand(suggestion.command)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!suggestion.command && (
|
||||
<p className="text-xs font-medium text-brand-500">
|
||||
→ {suggestion.action}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cliVerificationStatus !== 'verified' && (
|
||||
<Button
|
||||
onClick={verifyCliAuth}
|
||||
disabled={cliVerificationStatus === 'verifying' || !cliStatus?.installed}
|
||||
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
|
||||
data-testid={config.testIds.verifyCliButton}
|
||||
>
|
||||
{cliVerificationStatus === 'verifying' ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : cliVerificationStatus === 'error' ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Retry Verification
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldCheck className="w-4 h-4 mr-2" />
|
||||
Verify CLI Authentication
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="api-key" className="border-border">
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex items-center justify-between w-full pr-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Key
|
||||
className={`w-5 h-5 ${
|
||||
apiKeyVerificationStatus === 'verified'
|
||||
? 'text-green-500'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
/>
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-foreground">{config.apiKeyLabel}</p>
|
||||
<p className="text-sm text-muted-foreground">{config.apiKeyDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
{getApiKeyStatusBadge()}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pt-4 space-y-4">
|
||||
<div className="space-y-4 p-4 rounded-lg bg-muted/30 border border-border">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={config.testIds.apiKeyInput} className="text-foreground">
|
||||
{config.apiKeyLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id={config.testIds.apiKeyInput}
|
||||
type="password"
|
||||
placeholder={config.apiKeyPlaceholder}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className="bg-input border-border text-foreground"
|
||||
data-testid={config.testIds.apiKeyInput}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{config.apiKeyHelpText}{' '}
|
||||
<a
|
||||
href={config.apiKeyDocsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-brand-500 hover:underline"
|
||||
>
|
||||
{config.apiKeyDocsLabel}
|
||||
<ExternalLink className="w-3 h-3 inline ml-1" />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => saveApiKeyToken(apiKey)}
|
||||
disabled={isSavingApiKey || !apiKey.trim()}
|
||||
className="flex-1 bg-brand-500 hover:bg-brand-600 text-white"
|
||||
data-testid={config.testIds.saveApiKeyButton}
|
||||
>
|
||||
{isSavingApiKey ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save API Key'
|
||||
)}
|
||||
</Button>
|
||||
{hasApiKey && (
|
||||
<Button
|
||||
onClick={deleteApiKey}
|
||||
disabled={isDeletingApiKey}
|
||||
variant="outline"
|
||||
className="border-red-500/50 text-red-500 hover:bg-red-500/10 hover:text-red-400"
|
||||
data-testid={config.testIds.deleteApiKeyButton}
|
||||
>
|
||||
{isDeletingApiKey ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{apiKeyVerificationStatus === 'verifying' && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Verifying API key...</p>
|
||||
<p className="text-sm text-muted-foreground">Running a test query</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{apiKeyVerificationStatus === 'verified' && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">API Key verified!</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your API key is working correctly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{apiKeyVerificationStatus === 'error' && apiKeyVerificationError && (
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<XCircle className="w-5 h-5 text-red-500 shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="font-medium text-foreground">Verification failed</p>
|
||||
{(() => {
|
||||
const parts = apiKeyVerificationError.split('\n\nDetails: ');
|
||||
const mainError = parts[0];
|
||||
const details = parts[1];
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-red-400">{mainError}</p>
|
||||
{details && (
|
||||
<div className="mt-2 p-3 rounded bg-black/20 border border-red-500/20">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||
Technical details:
|
||||
</p>
|
||||
<pre className="text-xs text-red-300 whitespace-pre-wrap font-mono">
|
||||
{details}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{apiKeyVerificationStatus !== 'verified' && (
|
||||
<Button
|
||||
onClick={verifyApiKeyAuth}
|
||||
disabled={apiKeyVerificationStatus === 'verifying' || !hasApiKey}
|
||||
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
|
||||
data-testid={config.testIds.verifyApiKeyButton}
|
||||
>
|
||||
{apiKeyVerificationStatus === 'verifying' ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : apiKeyVerificationStatus === 'error' ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Retry Verification
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldCheck className="w-4 h-4 mr-2" />
|
||||
Verify API Key
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="ghost" onClick={onBack} className="text-muted-foreground">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onSkip} className="text-muted-foreground">
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNext}
|
||||
disabled={!isReady}
|
||||
className="bg-brand-500 hover:bg-brand-600 text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
data-testid={config.testIds.nextButton}
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { CliSetupStep } from './cli-setup-step';
|
||||
import type { CodexAuthStatus } from '@/store/setup-store';
|
||||
|
||||
interface CodexSetupStepProps {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
export function CodexSetupStep({ onNext, onBack, onSkip }: CodexSetupStepProps) {
|
||||
const {
|
||||
codexCliStatus,
|
||||
codexAuthStatus,
|
||||
setCodexCliStatus,
|
||||
setCodexAuthStatus,
|
||||
setCodexInstallProgress,
|
||||
} = useSetupStore();
|
||||
|
||||
const statusApi = useCallback(
|
||||
() => getElectronAPI().setup?.getCodexStatus() || Promise.reject(),
|
||||
[]
|
||||
);
|
||||
|
||||
const installApi = useCallback(
|
||||
() => getElectronAPI().setup?.installCodex() || Promise.reject(),
|
||||
[]
|
||||
);
|
||||
|
||||
const verifyAuthApi = useCallback(
|
||||
(method: 'cli' | 'api_key') =>
|
||||
getElectronAPI().setup?.verifyCodexAuth(method) || Promise.reject(),
|
||||
[]
|
||||
);
|
||||
|
||||
const config = useMemo(
|
||||
() => ({
|
||||
cliType: 'codex' as const,
|
||||
displayName: 'Codex',
|
||||
cliLabel: 'Codex CLI',
|
||||
cliDescription: 'Use Codex CLI login',
|
||||
apiKeyLabel: 'OpenAI API Key',
|
||||
apiKeyDescription: 'Optional API key for Codex',
|
||||
apiKeyProvider: 'openai' as const,
|
||||
apiKeyPlaceholder: 'sk-...',
|
||||
apiKeyDocsUrl: 'https://platform.openai.com/api-keys',
|
||||
apiKeyDocsLabel: 'Get one from OpenAI',
|
||||
apiKeyHelpText: "Don't have an API key?",
|
||||
installCommands: {
|
||||
macos: 'npm install -g @openai/codex',
|
||||
windows: 'npm install -g @openai/codex',
|
||||
},
|
||||
cliLoginCommand: 'codex login',
|
||||
testIds: {
|
||||
installButton: 'install-codex-button',
|
||||
verifyCliButton: 'verify-codex-cli-button',
|
||||
verifyApiKeyButton: 'verify-codex-api-key-button',
|
||||
apiKeyInput: 'openai-api-key-input',
|
||||
saveApiKeyButton: 'save-openai-key-button',
|
||||
deleteApiKeyButton: 'delete-openai-key-button',
|
||||
nextButton: 'codex-next-button',
|
||||
},
|
||||
buildCliAuthStatus: (_previous: CodexAuthStatus | null) => ({
|
||||
authenticated: true,
|
||||
method: 'cli_authenticated',
|
||||
hasAuthFile: true,
|
||||
}),
|
||||
buildApiKeyAuthStatus: (_previous: CodexAuthStatus | null) => ({
|
||||
authenticated: true,
|
||||
method: 'api_key',
|
||||
hasApiKey: true,
|
||||
}),
|
||||
buildClearedAuthStatus: (_previous: CodexAuthStatus | null) => ({
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
}),
|
||||
statusApi,
|
||||
installApi,
|
||||
verifyAuthApi,
|
||||
}),
|
||||
[installApi, statusApi, verifyAuthApi]
|
||||
);
|
||||
|
||||
return (
|
||||
<CliSetupStep
|
||||
config={config}
|
||||
state={{
|
||||
cliStatus: codexCliStatus,
|
||||
authStatus: codexAuthStatus,
|
||||
setCliStatus: setCodexCliStatus,
|
||||
setAuthStatus: setCodexAuthStatus,
|
||||
setInstallProgress: setCodexInstallProgress,
|
||||
getStoreState: () => useSetupStore.getState().codexCliStatus,
|
||||
}}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
onSkip={onSkip}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,4 +4,5 @@ export { ThemeStep } from './theme-step';
|
||||
export { CompleteStep } from './complete-step';
|
||||
export { ClaudeSetupStep } from './claude-setup-step';
|
||||
export { CursorSetupStep } from './cursor-setup-step';
|
||||
export { CodexSetupStep } from './codex-setup-step';
|
||||
export { GitHubSetupStep } from './github-setup-step';
|
||||
|
||||
Reference in New Issue
Block a user