mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
feature/codex-cli
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user