diff --git a/src/cliproxy/accounts/email-account-identity.ts b/src/cliproxy/accounts/email-account-identity.ts index 35c36bd1..d03f2337 100644 --- a/src/cliproxy/accounts/email-account-identity.ts +++ b/src/cliproxy/accounts/email-account-identity.ts @@ -1,6 +1,9 @@ import type { CLIProxyProvider } from '../types'; const DUPLICATE_EMAIL_ACCOUNT_PROVIDERS = new Set(['codex']); +const FREE_PLAN_PARTS = new Set(['free']); +const PERSONAL_PLAN_PARTS = new Set(['plus', 'pro']); +const BUSINESS_PLAN_PARTS = new Set(['team']); // Keep variant parsing aligned with ui/src/lib/account-identity.ts. The UI copy is // separate because the browser bundle cannot import this server module directly. @@ -49,6 +52,20 @@ function formatVariantPart(value: string): string { } } +function formatWorkspaceLabel(parts: string[]): string | null { + const workspaceId = parts.find((part) => /^[a-f0-9]{8}$/i.test(part)); + if (workspaceId) { + return `Workspace ${workspaceId.toLowerCase()}`; + } + + return parts.map(formatVariantPart).filter(Boolean).join(' · ') || null; +} + +function formatAudienceDetail(parts: string[]): string | null { + const label = parts.map(formatVariantPart).filter(Boolean).join(' · '); + return label || null; +} + export function supportsDuplicateEmailAccounts(provider: CLIProxyProvider | string): boolean { return DUPLICATE_EMAIL_ACCOUNT_PROVIDERS.has(normalizeProvider(provider)); } @@ -136,8 +153,16 @@ export function formatAccountVariantLabel(accountId: string, email?: string): st } const suffix = parts[parts.length - 1]?.toLowerCase(); - if (suffix && ['team', 'free', 'plus', 'pro'].includes(suffix)) { - return [formatVariantPart(suffix), ...parts.slice(0, -1).map(formatVariantPart)] + if (suffix && BUSINESS_PLAN_PARTS.has(suffix)) { + return ['Business', formatWorkspaceLabel(parts.slice(0, -1))].filter(Boolean).join(' · '); + } + + if (suffix && FREE_PLAN_PARTS.has(suffix)) { + return ['Free', formatAudienceDetail(parts.slice(0, -1))].filter(Boolean).join(' · '); + } + + if (suffix && PERSONAL_PLAN_PARTS.has(suffix)) { + return ['Personal', formatVariantPart(suffix), formatAudienceDetail(parts.slice(0, -1))] .filter(Boolean) .join(' · '); } diff --git a/src/cliproxy/quota-fetcher-codex.ts b/src/cliproxy/quota-fetcher-codex.ts index 43fdd606..811be609 100644 --- a/src/cliproxy/quota-fetcher-codex.ts +++ b/src/cliproxy/quota-fetcher-codex.ts @@ -624,11 +624,12 @@ export async function fetchCodexQuota( // Extract plan type const planTypeRaw = data.plan_type || data.planType; - let planType: 'free' | 'plus' | 'team' | null = null; + let planType: 'free' | 'plus' | 'pro' | 'team' | null = null; if (planTypeRaw) { const normalized = planTypeRaw.toLowerCase(); if (normalized === 'free') planType = 'free'; else if (normalized === 'plus') planType = 'plus'; + else if (normalized === 'pro') planType = 'pro'; else if (normalized === 'team') planType = 'team'; } diff --git a/src/cliproxy/quota-types.ts b/src/cliproxy/quota-types.ts index a1f0b313..e768ec9b 100644 --- a/src/cliproxy/quota-types.ts +++ b/src/cliproxy/quota-types.ts @@ -74,8 +74,8 @@ export interface CodexQuotaResult extends QuotaErrorMetadata { windows: CodexQuotaWindow[]; /** Explicit core usage windows (5h + weekly) for easier reset display */ coreUsage?: CodexCoreUsageSummary; - /** Plan type: free, plus, team, or null if unknown */ - planType: 'free' | 'plus' | 'team' | null; + /** Plan type: free, plus, pro, team, or null if unknown */ + planType: 'free' | 'plus' | 'pro' | 'team' | null; /** Timestamp of fetch */ lastUpdated: number; /** Error message if fetch failed */ diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index e1ecf92f..c018aaaf 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -6,6 +6,7 @@ import { AccountSurfaceCard } from '@/components/account/shared/account-surface- import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { getCodexIdentityBadge, type CodexIdentityBadge } from '@/lib/account-identity'; import { cn, formatQuotaPercent, @@ -13,7 +14,6 @@ import { getProviderResetTime, getQuotaFailureInfo, } from '@/lib/utils'; -import { formatAccountVariantPart } from '@/lib/account-identity'; import { GripVertical, Loader2, Pause, Play } from 'lucide-react'; import { useAccountQuota, @@ -28,6 +28,7 @@ import { AccountCardStats } from './account-card-stats'; import { cleanEmail } from './utils'; type Zone = 'left' | 'right' | 'top' | 'bottom'; +type AccountAudience = 'business' | 'free' | 'personal' | 'unknown'; const QUOTA_PROVIDER_ALIASES = [ 'antigravity', @@ -88,46 +89,124 @@ function getCompactQuotaColor(percentage: number) { return 'bg-red-500'; } -function getCodexPlanDetailLabel(quota: unknown): string | null { +function getCodexQuotaBadge(quota: unknown): CodexIdentityBadge { if (!quota || typeof quota !== 'object' || !('planType' in quota)) { - return null; + return { audience: 'unknown', label: null }; } const planType = (quota as { planType?: unknown }).planType; if (typeof planType !== 'string' || planType.trim().length === 0) { - return null; + return { audience: 'unknown', label: null }; } - return formatAccountVariantPart(planType); + if (planType === 'team') { + return { audience: 'business', label: 'Business' }; + } + + if (planType === 'free') { + return { audience: 'free', label: 'Free' }; + } + + if (planType === 'plus') { + return { audience: 'personal', label: 'Plus' }; + } + + if (planType === 'pro') { + return { audience: 'personal', label: 'Pro' }; + } + + return { audience: 'unknown', label: null }; +} + +function resolveCodexBadge( + identityBadge: CodexIdentityBadge, + quotaBadge: CodexIdentityBadge +): CodexIdentityBadge { + if (!quotaBadge.label) { + return identityBadge; + } + + if ( + quotaBadge.label === 'Business' || + quotaBadge.label === 'Plus' || + quotaBadge.label === 'Pro' + ) { + return quotaBadge; + } + + if (quotaBadge.label === 'Free' && identityBadge.label && identityBadge.label !== 'Free') { + return identityBadge; + } + + return quotaBadge; } function getVariantDetailLabel( variant: { - audience: string; + audience: AccountAudience; detailLabel?: string | null; compactDetailLabel?: string | null; }, quota?: unknown ) { - const codexPlanDetail = getCodexPlanDetailLabel(quota); - return variant.detailLabel ?? variant.compactDetailLabel ?? codexPlanDetail; + return resolveCodexBadge( + getCodexIdentityBadge({ + audience: variant.audience, + detailLabel: variant.detailLabel ?? null, + compactDetailLabel: variant.compactDetailLabel ?? null, + }), + getCodexQuotaBadge(quota) + ).label; +} + +function getVariantBadgeAudience( + variant: { + audience: AccountAudience; + detailLabel?: string | null; + compactDetailLabel?: string | null; + }, + quota?: unknown +) { + return resolveCodexBadge( + getCodexIdentityBadge({ + audience: variant.audience, + detailLabel: variant.detailLabel ?? null, + compactDetailLabel: variant.compactDetailLabel ?? null, + }), + getCodexQuotaBadge(quota) + ).audience; } function getVariantCompactDetailLabel( variant: { - audience: string; + audience: AccountAudience; compactDetailLabel?: string | null; detailLabel?: string | null; }, quota?: unknown ) { - const codexPlanDetail = getCodexPlanDetailLabel(quota); - return variant.compactDetailLabel ?? variant.detailLabel ?? codexPlanDetail; + return getVariantDetailLabel(variant, quota); +} + +function getDetailedAudienceLabel(audience: AccountAudience): string | null { + if (audience === 'business') return 'Business'; + if (audience === 'free') return 'Free'; + if (audience === 'personal') return 'Personal'; + return null; +} + +function getVariantAudience( + variant: { + audience: AccountAudience; + }, + quota?: unknown +) { + return getVariantBadgeAudience(variant, quota); } function getVariantInlineLabel( variant: { - audience: string; + audience: AccountAudience; audienceLabel?: string | null; detailLabel?: string | null; compactDetailLabel?: string | null; @@ -135,48 +214,49 @@ function getVariantInlineLabel( }, quota?: unknown ) { - const detailLabel = getVariantDetailLabel(variant, quota); - const composedLabel = [variant.audienceLabel, detailLabel].filter(Boolean).join(' · '); - return composedLabel || variant.inlineLabel || null; + return getVariantDetailLabel(variant, quota) || variant.inlineLabel || null; } function getVariantMarkerLabel( variant: { - audience: string; + audience: AccountAudience; audienceLabel?: string | null; detailLabel?: string | null; compactDetailLabel?: string | null; }, - audienceCounts: Map, quota?: unknown ) { + const audience = getVariantAudience(variant, quota); const compactDetailLabel = getVariantCompactDetailLabel(variant, quota); - if (variant.audience === 'business') { - const businessVariantCount = audienceCounts.get('business') ?? 0; - return businessVariantCount > 1 && compactDetailLabel ? compactDetailLabel : 'Biz'; + if (audience === 'business') { + return 'Biz'; } - if (variant.audience === 'personal') { + if (audience === 'free') { + return compactDetailLabel ?? 'Free'; + } + if (audience === 'personal') { return compactDetailLabel ?? 'Pers'; } - const normalizedFallback = - compactDetailLabel?.trim() || variant.audienceLabel?.trim() || variant.detailLabel?.trim(); + const normalizedFallback = compactDetailLabel?.trim() || getDetailedAudienceLabel(audience); return normalizedFallback?.[0]?.toUpperCase() ?? '?'; } function getGroupedVariantSummaryLabel( variants: Array<{ - audience: string; + audience: AccountAudience; audienceLabel?: string | null; detailLabel?: string | null; compactDetailLabel?: string | null; }>, - quotas: Array, - audienceCounts: Map + quotas: Array ) { const audiences = new Set(variants.map((variant) => variant.audience)); const hasDistinctDetails = variants.some((variant, index) => - Boolean(getVariantCompactDetailLabel(variant, quotas[index])) + Boolean( + getVariantCompactDetailLabel(variant, quotas[index]) || + getDetailedAudienceLabel(getVariantAudience(variant, quotas[index])) + ) ); if ( @@ -191,7 +271,7 @@ function getGroupedVariantSummaryLabel( if (variants.length === 1) { const [variant] = variants; - return getVariantMarkerLabel(variant, audienceCounts, quotas[0]); + return getVariantMarkerLabel(variant, quotas[0]); } return null; @@ -239,14 +319,9 @@ export function AccountCard({ const groupedVariantQuotas = groupedHeaderVariants.map( (_, index) => variantQuotaQueries[index]?.data ); - const groupedVariantAudienceCounts = groupedHeaderVariants.reduce((counts, variant) => { - counts.set(variant.audience, (counts.get(variant.audience) ?? 0) + 1); - return counts; - }, new Map()); const groupedVariantSummaryLabel = getGroupedVariantSummaryLabel( groupedHeaderVariants, - groupedVariantQuotas, - groupedVariantAudienceCounts + groupedVariantQuotas ); const compactMetaBadges = hasGroupedVariants ? ( @@ -274,16 +349,14 @@ export function AccountCard({ index > 0 && 'border-l border-border/50', variant.audience === 'business' ? 'bg-sky-500/12 text-sky-700 dark:bg-sky-500/20 dark:text-sky-300' - : variant.audience === 'personal' - ? 'bg-emerald-500/12 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300' - : 'bg-muted text-muted-foreground' + : variant.audience === 'free' + ? 'bg-slate-200/70 text-slate-700 dark:bg-slate-700/40 dark:text-slate-200' + : variant.audience === 'personal' + ? 'bg-emerald-500/12 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300' + : 'bg-muted text-muted-foreground' )} > - {getVariantMarkerLabel( - variant, - groupedVariantAudienceCounts, - groupedVariantQuotas[index] - )} + {getVariantMarkerLabel(variant, groupedVariantQuotas[index])} )) )} diff --git a/ui/src/components/account/shared/account-surface-card.tsx b/ui/src/components/account/shared/account-surface-card.tsx index aec49882..ad61b231 100644 --- a/ui/src/components/account/shared/account-surface-card.tsx +++ b/ui/src/components/account/shared/account-surface-card.tsx @@ -2,7 +2,11 @@ import type { ReactNode } from 'react'; import { Badge } from '@/components/ui/badge'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import type { UnifiedQuotaResult } from '@/hooks/use-cliproxy-stats'; -import { formatAccountVariantPart, getAccountIdentityPresentation } from '@/lib/account-identity'; +import { + getAccountIdentityPresentation, + getCodexIdentityBadge, + type CodexIdentityBadge, +} from '@/lib/account-identity'; import { cn } from '@/lib/utils'; import { Pause, Star, User } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -10,6 +14,7 @@ import { useTranslation } from 'react-i18next'; import { AccountQuotaPanel } from './account-quota-panel'; type AccountSurfaceMode = 'compact' | 'detailed'; +type AccountAudience = 'business' | 'free' | 'personal' | 'unknown'; type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown'; interface AccountSurfaceCardProps { @@ -36,11 +41,15 @@ interface AccountSurfaceCardProps { className?: string; } -function getAudienceBadgeClass(audience: 'business' | 'personal' | 'unknown') { +function getAudienceBadgeClass(audience: AccountAudience) { if (audience === 'business') { return 'bg-sky-500/12 text-sky-700 dark:text-sky-300'; } + if (audience === 'free') { + return 'bg-slate-200/70 text-slate-700 dark:bg-slate-700/40 dark:text-slate-200'; + } + if (audience === 'personal') { return 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'; } @@ -54,20 +63,22 @@ function getTierBadgeClass(tier: AccountTier | undefined) { : 'bg-yellow-500/15 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400'; } -function getCompactAudienceBadgeLabel( - audience: 'business' | 'personal' | 'unknown', - t: (key: string) => string -) { +function getCompactAudienceBadgeLabel(audience: AccountAudience, t: (key: string) => string) { if (audience === 'business') return t('accountSurfaceCard.business'); + if (audience === 'free') return t('accountSurfaceCard.free'); if (audience === 'personal') return t('accountSurfaceCard.personal'); return '?'; } -function getCompactDetailBadgeClass(audience: 'business' | 'personal' | 'unknown') { +function getCompactDetailBadgeClass(audience: AccountAudience) { if (audience === 'business') { return 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:border-sky-400/30 dark:bg-sky-500/15 dark:text-sky-200'; } + if (audience === 'free') { + return 'border-slate-300/70 bg-slate-100/80 text-slate-700 dark:border-slate-500/40 dark:bg-slate-700/30 dark:text-slate-200'; + } + if (audience === 'personal') { return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:border-emerald-400/30 dark:bg-emerald-500/15 dark:text-emerald-200'; } @@ -88,12 +99,51 @@ function resolveEffectiveTier( return tier; } -function getCodexPlanDetailLabel(quota: UnifiedQuotaResult | undefined): string | null { +function getCodexQuotaBadge(quota: UnifiedQuotaResult | undefined): CodexIdentityBadge { if (!quota || !('planType' in quota) || !quota.planType) { - return null; + return { audience: 'unknown', label: null }; } - return formatAccountVariantPart(quota.planType); + if (quota.planType === 'team') { + return { audience: 'business', label: 'Business' }; + } + + if (quota.planType === 'free') { + return { audience: 'free', label: 'Free' }; + } + + if (quota.planType === 'plus') { + return { audience: 'personal', label: 'Plus' }; + } + + if (quota.planType === 'pro') { + return { audience: 'personal', label: 'Pro' }; + } + + return { audience: 'unknown', label: null }; +} + +function resolveCodexBadge( + identityBadge: CodexIdentityBadge, + quotaBadge: CodexIdentityBadge +): CodexIdentityBadge { + if (!quotaBadge.label) { + return identityBadge; + } + + if ( + quotaBadge.label === 'Business' || + quotaBadge.label === 'Plus' || + quotaBadge.label === 'Pro' + ) { + return quotaBadge; + } + + if (quotaBadge.label === 'Free' && identityBadge.label && identityBadge.label !== 'Free') { + return identityBadge; + } + + return quotaBadge; } export function AccountSurfaceCard({ @@ -124,10 +174,10 @@ export function AccountSurfaceCard({ const title = displayEmail || identity.email || accountId; const normalizedProvider = provider.toLowerCase(); const effectiveTier = resolveEffectiveTier(tier, quota); - const codexPlanDetailLabel = - normalizedProvider === 'codex' ? getCodexPlanDetailLabel(quota) : null; - const resolvedDetailLabel = identity.detailLabel ?? codexPlanDetailLabel; - const resolvedCompactDetailLabel = identity.compactDetailLabel ?? codexPlanDetailLabel; + const effectiveCodexBadge = + normalizedProvider === 'codex' + ? resolveCodexBadge(getCodexIdentityBadge(identity), getCodexQuotaBadge(quota)) + : null; const showTierBadge = (normalizedProvider === 'agy' || normalizedProvider === 'antigravity' || @@ -148,28 +198,38 @@ export function AccountSurfaceCard({ {effectiveTier} )} - {identity.audienceLabel && ( - + {effectiveCodexBadge.label} + + ) + : identity.audienceLabel && ( + + {getCompactAudienceBadgeLabel(identity.audience, t)} + )} - > - {getCompactAudienceBadgeLabel(identity.audience, t)} - - )} - {resolvedCompactDetailLabel && ( + {normalizedProvider !== 'codex' && identity.compactDetailLabel && ( - {resolvedCompactDetailLabel} + {identity.compactDetailLabel} )} {paused && ( @@ -227,7 +287,18 @@ export function AccountSurfaceCard({ {title} {isCompact && (compactMetaBadges ?? defaultCompactMetaBadges)} - {!isCompact && identity.audienceLabel && ( + {!isCompact && normalizedProvider === 'codex' && effectiveCodexBadge?.label && ( + + {effectiveCodexBadge.label} + + )} + {!isCompact && normalizedProvider !== 'codex' && identity.audienceLabel && ( )} - {!isCompact && resolvedDetailLabel && ( + {!isCompact && normalizedProvider !== 'codex' && identity.detailLabel && ( - {resolvedDetailLabel} + {identity.detailLabel} )} {!isCompact && isDefault && ( diff --git a/ui/src/components/setup/wizard/steps/account-step.tsx b/ui/src/components/setup/wizard/steps/account-step.tsx index 42577953..dc9b5930 100644 --- a/ui/src/components/setup/wizard/steps/account-step.tsx +++ b/ui/src/components/setup/wizard/steps/account-step.tsx @@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { ChevronRight, ArrowLeft, User, ExternalLink } from 'lucide-react'; -import { getAccountIdentityPresentation } from '@/lib/account-identity'; +import { getAccountIdentityPresentation, getCodexIdentityBadge } from '@/lib/account-identity'; import { cn } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import type { AccountStepProps } from '../types'; @@ -30,6 +30,8 @@ export function AccountStep({
{accounts.map((acc) => { const identity = getAccountIdentityPresentation(acc.id, acc.email, acc.tokenFile); + const codexBadge = + acc.provider?.toLowerCase() === 'codex' ? getCodexIdentityBadge(identity) : null; return (