feat(ui): complete dashboard i18n coverage across 162 files

Replace all remaining hardcoded English strings with t() calls across
the CCS dashboard UI. Add zh-CN, vi, ja translations for all new keys.

- 162 files updated (components, hooks, pages, lib utilities)
- 7 domains covered: layout/shared, accounts/auth, CLIProxy,
  compatible CLI (Codex/Cursor/Droid/Copilot), logs/monitoring,
  analytics, and lib utilities
- 4 locales: en, zh-CN, vi, ja with natural (not literal) translations
- Fix duplicate customPresetDialog key shadowing in en/zh-CN
- Fix missing ja nav.logs and providerModelSelector keys
- Fix openrouter-banner count type (string → number)
- Fix add-account-dialog double setLocalError call
- Fix codex-model-providers-card missing useTranslation

Closes #983
This commit is contained in:
Tam Nhu Tran
2026-04-13 20:52:30 -04:00
parent 4874c154cb
commit c3401f0a91
162 changed files with 5350 additions and 892 deletions
@@ -115,6 +115,7 @@ export function AccountSafetyWarningCard({
variant="outline"
className="border-amber-500/40 text-amber-700 dark:text-amber-300"
>
{/* TODO i18n: missing key for "High Risk" badge */}
High Risk
</Badge>
</div>
@@ -123,6 +124,7 @@ export function AccountSafetyWarningCard({
<p>{firstLine}</p>
<p className="font-medium text-amber-900 dark:text-amber-200">{secondLine}</p>
<p className="text-xs text-muted-foreground">
{/* TODO i18n: missing key for disclaimer text */}
CCS is provided as-is and does not take responsibility for suspension, bans, or access
loss from upstream providers.
</p>
@@ -148,6 +150,7 @@ export function AccountSafetyWarningCard({
</a>
)}
<span className="rounded-md border border-border/70 bg-muted/60 px-2.5 py-1 text-xs text-muted-foreground">
{/* TODO i18n: missing key for "Applies to CLI and dashboard auth" */}
Applies to CLI and dashboard auth
</span>
</div>
@@ -155,6 +158,7 @@ export function AccountSafetyWarningCard({
{showAcknowledgement && onAcknowledgementTextChange && (
<div className="rounded-lg border border-amber-500/25 bg-amber-500/5 p-2.5">
<Label htmlFor="account-risk-ack-text" className="text-xs leading-5">
{/* TODO i18n: missing key */}
Type exact phrase to continue:{' '}
<code className="rounded bg-muted px-1 py-0.5 font-mono">
{acknowledgementPhrase}
@@ -294,6 +294,7 @@ export function AddAccountDialog({
}
if (requiresAgyResponsibilityFlow && !isAgyRiskChecklistComplete) {
setLocalError(
// TODO i18n: missing key for AGY responsibility error
'Complete all Antigravity responsibility steps before authenticating this provider.'
);
return;
@@ -431,6 +432,7 @@ export function AddAccountDialog({
<p className="text-xs text-muted-foreground">{kiroMethodOption.description}</p>
{isKiroSocial && (
<p className="text-xs text-muted-foreground">
{/* TODO i18n: missing key for Kiro social browser hint */}
If your browser does not return automatically after login, CCS can accept the
final
<span className="mx-1 rounded bg-muted px-1.5 py-0.5 font-mono text-[11px]">
@@ -445,7 +447,9 @@ export function AddAccountDialog({
{isKiroIdc && !showAuthUI && (
<div className="space-y-4 rounded-lg border border-border/60 bg-muted/20 p-4">
<div className="space-y-2">
<Label htmlFor="kiro-idc-start-url">IDC Start URL</Label>
<Label htmlFor="kiro-idc-start-url">
{/* TODO i18n: missing key */}IDC Start URL
</Label>
<Input
id="kiro-idc-start-url"
value={kiroIDCStartUrl}
@@ -457,12 +461,12 @@ export function AddAccountDialog({
disabled={isPending}
/>
<p className="text-xs text-muted-foreground">
Required for organization IAM Identity Center login.
{/* TODO i18n: missing key */}Required for organization IAM Identity Center login.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="kiro-idc-region">IDC Region</Label>
<Label htmlFor="kiro-idc-region">{/* TODO i18n: missing key */}IDC Region</Label>
<Input
id="kiro-idc-region"
value={kiroIDCRegion}
@@ -474,12 +478,13 @@ export function AddAccountDialog({
disabled={isPending}
/>
<p className="text-xs text-muted-foreground">
Optional. Leave blank to use the upstream default region.
{/* TODO i18n: missing key */}Optional. Leave blank to use the upstream default
region.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="kiro-idc-flow">IDC Flow</Label>
<Label htmlFor="kiro-idc-flow">{/* TODO i18n: missing key */}IDC Flow</Label>
<Select
value={kiroIDCFlow}
onValueChange={(value) => {
@@ -488,16 +493,20 @@ export function AddAccountDialog({
}}
>
<SelectTrigger id="kiro-idc-flow">
<SelectValue placeholder="Select IDC flow" />
<SelectValue placeholder="{/* TODO i18n: missing key */}Select IDC flow" />
</SelectTrigger>
<SelectContent>
<SelectItem value="authcode">Authorization Code</SelectItem>
<SelectItem value="device">Device Code</SelectItem>
<SelectItem value="authcode">
{/* TODO i18n: missing key */}Authorization Code
</SelectItem>
<SelectItem value="device">
{/* TODO i18n: missing key */}Device Code
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Auth Code opens a browser and may need the final callback URL pasted back. Device
Code shows a verification code instead.
{/* TODO i18n: missing key */}Auth Code opens a browser and may need the final
callback URL pasted back. Device Code shows a verification code instead.
</p>
</div>
</div>
@@ -540,7 +549,8 @@ export function AddAccountDialog({
{authFlow.isDeviceCodeFlow
? t('addAccountDialog.deviceCodeHint')
: isKiroSocial
? 'Complete sign-in in your browser. If it does not return automatically, paste the final kiro:// callback URL below.'
? // TODO i18n: missing key for Kiro social callback hint
'Complete sign-in in your browser. If it does not return automatically, paste the final kiro:// callback URL below.'
: t('addAccountDialog.browserHint')}
</p>
</div>
@@ -590,7 +600,8 @@ export function AddAccountDialog({
<div className="space-y-2">
<Label htmlFor="callback-url" className="text-xs">
{isKiroSocial
? 'Browser did not return? Paste the final kiro:// callback URL:'
? // TODO i18n: missing key
'Browser did not return? Paste the final kiro:// callback URL:'
: t('addAccountDialog.redirectPasteLabel')}
</Label>
<Input
@@ -626,7 +637,8 @@ export function AddAccountDialog({
{!authFlow.authUrl && !authFlow.isDeviceCodeFlow && (
<p className="text-xs text-center text-muted-foreground">
{isKiroSocial
? 'Preparing the Kiro sign-in URL. If it does not open automatically, it will appear here shortly.'
? // TODO i18n: missing key for Kiro social preparing URL
'Preparing the Kiro sign-in URL. If it does not open automatically, it will appear here shortly.'
: t('addAccountDialog.preparingUrl')}
</p>
)}
@@ -78,6 +78,7 @@ export function AntigravityResponsibilityChecklist({
disabled={disabled}
/>
<Label htmlFor="agy-step-reviewed-issue" className="text-xs leading-5">
{/* TODO i18n: missing key for step 1 */}
Step 1: I reviewed issue #509 and understand AGY OAuth can trigger account
bans/suspensions.
</Label>
@@ -91,6 +92,7 @@ export function AntigravityResponsibilityChecklist({
disabled={disabled}
/>
<Label htmlFor="agy-step-understands-risk" className="text-xs leading-5">
{/* TODO i18n: missing key for step 2 */}
Step 2: I understand this OAuth action is my own decision and I accept the upstream
risk.
</Label>
@@ -106,6 +108,7 @@ export function AntigravityResponsibilityChecklist({
disabled={disabled}
/>
<Label htmlFor="agy-step-accept-responsibility" className="text-xs leading-5">
{/* TODO i18n: missing key for step 3 */}
Step 3: I accept full responsibility. CCS is not liable for suspension, bans, or
access loss.
</Label>
@@ -115,6 +118,7 @@ export function AntigravityResponsibilityChecklist({
<div className="space-y-2 rounded-lg border border-amber-500/25 bg-amber-500/5 p-3">
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-800 dark:text-amber-200">
<ShieldAlert className="h-3.5 w-3.5" />
{/* TODO i18n: missing key for step 4 */}
Step 4: Type exact phrase to continue
</div>
<Input
@@ -133,7 +137,7 @@ export function AntigravityResponsibilityChecklist({
rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-md border border-rose-500/30 bg-rose-500/10 px-2.5 py-1 font-medium text-rose-800 transition-colors hover:bg-rose-500/15 dark:text-rose-200"
>
Read issue #509
{/* TODO i18n: missing key */}Read issue #509
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
@@ -4,6 +4,7 @@
import { cn } from '@/lib/utils';
import { CheckCircle2, XCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface AccountCardStatsProps {
success: number;
@@ -12,6 +13,7 @@ interface AccountCardStatsProps {
}
export function AccountCardStats({ success, failure, showDetails }: AccountCardStatsProps) {
const { t } = useTranslation();
const total = success + failure;
const successRate = total > 0 ? (success / total) * 100 : 100;
@@ -21,7 +23,7 @@ export function AccountCardStats({ success, failure, showDetails }: AccountCardS
<div className="flex items-end justify-between px-0.5">
<div className="flex flex-col">
<span className="text-[8px] text-muted-foreground/70 uppercase font-bold tracking-tight">
Success Rate
{t('authMonitorLive.successRate')}
</span>
<span
className={cn(
@@ -37,6 +39,7 @@ export function AccountCardStats({ success, failure, showDetails }: AccountCardS
</span>
</div>
<div className="flex flex-col items-end">
{/* TODO i18n: missing key for "Volume" */}
<span className="text-[8px] text-muted-foreground/70 uppercase font-bold tracking-tight">
Volume
</span>
@@ -173,7 +173,10 @@ export function AccountCard({
<div
className="inline-flex shrink-0 items-center overflow-hidden rounded-md border border-border/60 bg-muted/60 shadow-sm shadow-black/5 dark:bg-zinc-900/80"
title={groupedHeaderVariants
.map((variant) => variant.audienceLabel ?? variant.detailLabel ?? 'Variant')
.map(
(variant) =>
variant.audienceLabel ?? variant.detailLabel ?? t('accountSurfaceCard.variant')
)
.join(' • ')}
>
{groupedVariantSummaryLabel ? (
@@ -204,6 +207,7 @@ export function AccountCard({
</div>
{account.paused && (
<span className="text-[7px] font-bold uppercase tracking-wide px-1 py-px rounded shrink-0 bg-amber-500/15 text-amber-700 dark:bg-amber-500/25 dark:text-amber-300">
{/* TODO i18n: missing key for compact "Paused" */}
Paused
</span>
)}
@@ -144,7 +144,9 @@ export function AccountQuotaPanel({
return (
<div className={cn('flex items-center gap-1.5 text-xs text-muted-foreground', className)}>
<Loader2 className="w-3 h-3 animate-spin" />
<span>{mode === 'compact' ? t('accountCard.quotaLoading') : 'Loading quota...'}</span>
<span>
{mode === 'compact' ? t('accountCard.quotaLoading') : t('accountQuotaPanel.loadingQuota')}
</span>
</div>
);
}
@@ -204,20 +206,24 @@ export function AccountQuotaPanel({
<>
<CheckCircle2 className="w-3 h-3 text-emerald-500" />
<span className="text-emerald-600 dark:text-emerald-400">
Active · {formatRelativeTime(runtimeLastUsed)}
{/* TODO i18n: missing key for "Active" */}Active ·{' '}
{formatRelativeTime(runtimeLastUsed)}
</span>
</>
) : runtimeLastUsed ? (
<>
<Clock className="w-3 h-3 text-muted-foreground" />
<span className="text-muted-foreground">
Last used {formatRelativeTime(runtimeLastUsed)}
{/* TODO i18n: missing key for "Last used" */}Last used{' '}
{formatRelativeTime(runtimeLastUsed)}
</span>
</>
) : (
<>
<HelpCircle className="w-3 h-3 text-muted-foreground" />
<span className="text-muted-foreground">Not used yet</span>
<span className="text-muted-foreground">
{t('accountCardStats.notUsedYet')}
</span>
</>
)}
</div>
@@ -5,6 +5,7 @@ import type { UnifiedQuotaResult } from '@/hooks/use-cliproxy-stats';
import { getAccountIdentityPresentation } from '@/lib/account-identity';
import { cn } from '@/lib/utils';
import { Pause, Star, User } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { AccountQuotaPanel } from './account-quota-panel';
@@ -53,9 +54,12 @@ 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') {
if (audience === 'business') return 'Biz';
if (audience === 'personal') return 'Pers';
function getCompactAudienceBadgeLabel(
audience: 'business' | 'personal' | 'unknown',
t: (key: string) => string
) {
if (audience === 'business') return t('accountSurfaceCard.business');
if (audience === 'personal') return t('accountSurfaceCard.personal');
return '?';
}
@@ -95,6 +99,7 @@ export function AccountSurfaceCard({
quotaInsetClassName,
className,
}: AccountSurfaceCardProps) {
const { t } = useTranslation();
const identity = getAccountIdentityPresentation(accountId, email, tokenFile);
const title = displayEmail || identity.email || accountId;
const normalizedProvider = provider.toLowerCase();
@@ -129,11 +134,12 @@ export function AccountSurfaceCard({
: 'bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/25 dark:text-emerald-300'
)}
>
{getCompactAudienceBadgeLabel(identity.audience)}
{getCompactAudienceBadgeLabel(identity.audience, t)}
</span>
)}
{paused && (
<span className="text-[8px] font-semibold px-1.5 py-0.5 rounded-md shrink-0 bg-amber-500/15 text-amber-700 dark:bg-amber-500/25 dark:text-amber-300">
{/* TODO i18n: missing key for compact "Paused" badge */}
Paused
</span>
)}
@@ -205,6 +211,7 @@ export function AccountSurfaceCard({
{!isCompact && isDefault && (
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
<Star className="w-2.5 h-2.5 fill-current" />
{/* TODO i18n: missing key for "Default" badge */}
Default
</Badge>
)}
@@ -214,6 +221,7 @@ export function AccountSurfaceCard({
className="text-[10px] h-4 px-1.5 border-yellow-500 text-yellow-600"
>
<Pause className="w-2 h-2 mr-0.5" />
{/* TODO i18n: missing key for "Paused" badge */}
Paused
</Badge>
)}
@@ -12,6 +12,7 @@ import { Database, TrendingUp, Zap } from 'lucide-react';
import type { UsageSummary } from '@/hooks/use-usage';
import { cn } from '@/lib/utils';
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { useTranslation } from 'react-i18next';
interface CacheEfficiencyCardProps {
data: UsageSummary | undefined;
@@ -21,6 +22,7 @@ interface CacheEfficiencyCardProps {
export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficiencyCardProps) {
const { privacyMode } = usePrivacy();
const { t } = useTranslation();
const metrics = useMemo(() => {
if (!data) return null;
@@ -73,11 +75,14 @@ export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficie
<CardHeader className="px-3 py-2">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Database className="w-4 h-4" />
{/* TODO i18n: missing key for "Cache Efficiency" */}
Cache Efficiency
</CardTitle>
</CardHeader>
<CardContent className="px-3 pb-3 pt-0 flex-1 flex items-center justify-center">
<p className="text-sm text-muted-foreground text-center">No cache data available</p>
<p className="text-sm text-muted-foreground text-center">
{t('analyticsCards.noCacheData')}
</p>
</CardContent>
</Card>
);
@@ -88,6 +93,7 @@ export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficie
<CardHeader className="px-3 py-2">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Database className="w-4 h-4" />
{/* TODO i18n: missing key for "Cache Efficiency" */}
Cache Efficiency
</CardTitle>
</CardHeader>
@@ -101,6 +107,7 @@ export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficie
</span>
</div>
<p className="text-[11px] text-muted-foreground uppercase tracking-wider mt-0.5">
{/* TODO i18n: missing key for "Estimated Savings" */}
Estimated Savings
</p>
</div>
@@ -115,7 +122,9 @@ export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficie
{metrics.cacheHitRate.toFixed(0)}%
</span>
</div>
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">Hit Rate</p>
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">
{t('analyticsCards.hitRate')}
</p>
</div>
{/* Cache Cost */}
@@ -123,7 +132,9 @@ export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficie
<span className={cn('text-lg font-bold', privacyMode && PRIVACY_BLUR_CLASS)}>
${metrics.cacheCost.toFixed(2)}
</span>
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">Cache Cost</p>
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">
{t('analyticsCards.cacheCost')}
</p>
</div>
</div>
@@ -137,6 +148,7 @@ export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficie
>
<span>Reads: {formatCompact(metrics.totalCacheReads)}</span>
<span>Writes: {formatCompact(metrics.totalCacheWrites)}</span>
{/* TODO i18n: missing keys for "Reads:" / "Writes:" */}
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden flex">
<div
@@ -145,7 +157,7 @@ export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficie
backgroundColor: '#9e2a2b',
width: `${(metrics.totalCacheReads / metrics.totalCacheTokens) * 100}%`,
}}
title={`Cache Reads: ${metrics.totalCacheReads.toLocaleString()}`}
title={`Cache Reads: ${metrics.totalCacheReads.toLocaleString()}`} // TODO i18n: missing key
/>
<div
className="h-full"
@@ -153,16 +165,18 @@ export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficie
backgroundColor: '#e09f3e',
width: `${(metrics.totalCacheWrites / metrics.totalCacheTokens) * 100}%`,
}}
title={`Cache Writes: ${metrics.totalCacheWrites.toLocaleString()}`}
title={`Cache Writes: ${metrics.totalCacheWrites.toLocaleString()}`} // TODO i18n: missing key
/>
</div>
<div className="flex items-center justify-center gap-3 text-[10px] text-muted-foreground">
<span className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: '#9e2a2b' }} />
{/* TODO i18n: missing key for "Read" */}
Read
</span>
<span className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: '#e09f3e' }} />
{/* TODO i18n: missing key for "Write" */}
Write
</span>
</div>
@@ -15,6 +15,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { Server, Zap, Cpu, Coins } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useCliproxyStats, useCliproxyStatus } from '@/hooks/use-cliproxy-stats';
import { useTranslation } from 'react-i18next';
interface CliproxyStatsCardProps {
className?: string;
@@ -27,6 +28,7 @@ export function CliproxyStatsCard({
}: CliproxyStatsCardProps) {
const { data: status, isLoading: statusLoading } = useCliproxyStatus();
const { data: stats, isLoading: statsLoading, error } = useCliproxyStats(status?.running);
const { t } = useTranslation();
const isLoading = externalLoading || statusLoading || (status?.running && statsLoading);
@@ -36,6 +38,7 @@ export function CliproxyStatsCard({
<CardHeader className="px-3 py-2">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Server className="h-4 w-4" />
{/* TODO i18n: missing key for "CLIProxy Stats" */}
CLIProxy Stats
</CardTitle>
</CardHeader>
@@ -62,16 +65,17 @@ export function CliproxyStatsCard({
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Server className="h-4 w-4 text-muted-foreground" />
{/* TODO i18n: missing key for "CLIProxy Stats" */}
CLIProxy Stats
</CardTitle>
<Badge variant="secondary" className="text-[10px] h-5">
Offline
{t('cliproxyStatsOverview.offline')}
</Badge>
</div>
</CardHeader>
<CardContent className="px-3 pb-3 pt-0 flex-1 flex items-center justify-center">
<p className="text-xs text-muted-foreground text-center">
Start a CLIProxy session (gemini, codex, agy) to collect stats.
{t('cliproxyStatsOverview.noActiveSessionHint')}
</p>
</CardContent>
</Card>
@@ -91,9 +95,11 @@ export function CliproxyStatsCard({
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Server className="h-4 w-4" />
{/* TODO i18n: missing key for "CLIProxy Stats" */}
CLIProxy Stats
</CardTitle>
<Badge variant="destructive" className="text-[10px] h-5">
{/* TODO i18n: missing key for "Error" */}
Error
</Badge>
</div>
@@ -125,6 +131,7 @@ export function CliproxyStatsCard({
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Server className="h-4 w-4" />
{/* TODO i18n: missing key for "CLIProxy Stats" */}
CLIProxy Stats
</CardTitle>
<Badge
@@ -132,7 +139,7 @@ export function CliproxyStatsCard({
className="text-[10px] h-5 text-green-600 border-green-200 bg-green-50 dark:bg-green-900/10 dark:border-green-800"
>
<Zap className="h-3 w-3 mr-0.5" />
Running
{t('cliproxyStatsOverview.running')}
</Badge>
</div>
</CardHeader>
@@ -176,6 +183,7 @@ export function CliproxyStatsCard({
</div>
<div className="text-[9px] text-muted-foreground mt-0.5">
{failedRequests > 0 ? `${failedRequests} failed` : 'All success'}
{/* TODO i18n: missing keys for "failed" / "All success" */}
</div>
</div>
</div>
@@ -187,7 +195,9 @@ export function CliproxyStatsCard({
</div>
<div className="min-w-0">
<div className="text-lg font-bold leading-none">{formatNumber(totalTokens)}</div>
<div className="text-[9px] text-muted-foreground mt-0.5">Total tokens</div>
<div className="text-[9px] text-muted-foreground mt-0.5">
{t('cliproxyStatsOverview.totalTokens')}
</div>
</div>
</div>
</div>
@@ -197,7 +207,7 @@ export function CliproxyStatsCard({
<div className="space-y-1.5">
<div className="flex items-center gap-1.5 text-[10px] font-medium text-muted-foreground">
<Cpu className="h-3 w-3" />
Models Used
{t('cliproxyStatsOverview.modelsUsed')}
</div>
<div className="space-y-1">
{models.map(([model, count]) => {
@@ -14,6 +14,7 @@ import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useTranslation } from 'react-i18next';
interface DateRangeFilterProps {
value?: DateRange;
@@ -54,6 +55,7 @@ export function DateRangeFilter({
className,
}: DateRangeFilterProps) {
const [isOpen, setIsOpen] = React.useState(false);
const { t } = useTranslation();
// Helper to check if a preset is currently selected
const isPresetSelected = (presetRange: DateRange) => {
@@ -101,7 +103,7 @@ export function DateRangeFilter({
format(value.from, 'LLL dd, y')
)
) : (
<span>Pick a date</span>
<span>{t('dateRangeFilter.pickADate')}</span>
)}
</Button>
</PopoverTrigger>
@@ -11,6 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import type { ModelUsage } from '@/hooks/use-usage';
import { cn, getModelColor } from '@/lib/utils';
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { useTranslation } from 'react-i18next';
interface ModelBreakdownChartProps {
data: ModelUsage[];
@@ -20,6 +21,7 @@ interface ModelBreakdownChartProps {
export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdownChartProps) {
const { privacyMode } = usePrivacy();
const { t } = useTranslation();
const chartData = useMemo(() => {
if (!data || data.length === 0) return [];
@@ -40,7 +42,7 @@ export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdo
if (!data || data.length === 0) {
return (
<div className={cn('h-full min-h-[100px] flex items-center justify-center', className)}>
<p className="text-muted-foreground">No model data available</p>
<p className="text-muted-foreground">{t('analyticsCards.noModelData')}</p>
</div>
);
}
@@ -3,6 +3,7 @@ import { ArrowDownRight, ArrowUpRight, Database, Gauge, Sparkles } from 'lucide-
import type { ModelUsage } from '@/hooks/use-usage';
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
interface ModelDetailsContentProps {
model: ModelUsage;
@@ -10,6 +11,7 @@ interface ModelDetailsContentProps {
export function ModelDetailsContent({ model }: ModelDetailsContentProps) {
const { privacyMode } = usePrivacy();
const { t } = useTranslation();
const ioRatioStatus = getIoRatioStatus(model.ioRatio);
return (
@@ -38,22 +40,28 @@ export function ModelDetailsContent({ model }: ModelDetailsContentProps) {
<p className={cn('text-lg font-bold', privacyMode && PRIVACY_BLUR_CLASS)}>
${model.cost.toFixed(2)}
</p>
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">Total Cost</p>
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">
{t('analyticsCards.totalCost')}
</p>
</div>
<div className="p-2 rounded-md bg-muted/50 border text-center">
<p className={cn('text-lg font-bold', privacyMode && PRIVACY_BLUR_CLASS)}>
{formatCompactNumber(model.tokens)}
</p>
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">Total Tokens</p>
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">
{t('analyticsCards.totalTokens')}
</p>
</div>
</div>
{/* Token Breakdown */}
<div className="space-y-2">
<h5 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
{/* TODO i18n: missing key for "Token Breakdown" */}
Token Breakdown
</h5>
<div className={cn('space-y-1', privacyMode && PRIVACY_BLUR_CLASS)}>
{/* TODO i18n: missing keys for Input/Output/Cache Write/Cache Read labels */}
<TokenRow
label="Input"
tokens={model.inputTokens}
@@ -89,7 +97,7 @@ export function ModelDetailsContent({ model }: ModelDetailsContentProps) {
<div className="p-2.5 rounded-md border bg-muted/20 space-y-1.5">
<div className="flex items-center gap-2">
<Gauge className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium">Input/Output Ratio</span>
<span className="text-xs font-medium">{t('analyticsCards.inputOutputRatio')}</span>
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
{ioRatioStatus.description}
@@ -14,6 +14,7 @@ import { cn } from '@/lib/utils';
import { formatDistanceToNow } from 'date-fns';
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { getProjectDisplayName } from './project-name-utils';
import { useTranslation } from 'react-i18next';
interface SessionStatsCardProps {
data: PaginatedSessions | undefined;
@@ -23,6 +24,7 @@ interface SessionStatsCardProps {
export function SessionStatsCard({ data, isLoading, className }: SessionStatsCardProps) {
const { privacyMode } = usePrivacy();
const { t } = useTranslation();
const stats = useMemo(() => {
if (!data?.sessions || data.sessions.length === 0) return null;
@@ -72,11 +74,14 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
<CardHeader className="px-3 py-2">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Terminal className="w-4 h-4" />
{/* TODO i18n: missing key for "Session Stats" */}
Session Stats
</CardTitle>
</CardHeader>
<CardContent className="px-3 pb-3 pt-0 flex-1 flex items-center justify-center">
<p className="text-sm text-muted-foreground text-center">No session data available</p>
<p className="text-sm text-muted-foreground text-center">
{t('analyticsCards.noSessionData')}
</p>
</CardContent>
</Card>
);
@@ -89,6 +94,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
<CardHeader className="px-3 py-2">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Terminal className="w-4 h-4" />
{/* TODO i18n: missing key for "Session Stats" */}
Session Stats
</CardTitle>
</CardHeader>
@@ -102,6 +108,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
<span className="text-xl font-bold">{stats.totalSessions}</span>
</div>
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mt-0.5">
{/* TODO i18n: missing key for "Total Sessions" */}
Total Sessions
</p>
</div>
@@ -115,6 +122,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
</span>
</div>
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mt-0.5">
{/* TODO i18n: missing key for "Avg Cost/Session" */}
Avg Cost/Session
</p>
</div>
@@ -124,6 +132,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
<div className="flex-1 min-h-0 space-y-2">
<div className="flex items-center gap-1 text-xs text-muted-foreground font-medium mb-1">
<Clock className="w-3 h-3" />
{/* TODO i18n: missing key for "Recent Activity" */}
Recent Activity
</div>
<div className="space-y-1.5 max-h-full overflow-y-auto pr-1">
@@ -20,6 +20,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import type { TokenBreakdown } from '@/hooks/use-usage';
import { cn } from '@/lib/utils';
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { useTranslation } from 'react-i18next';
interface TokenBreakdownChartProps {
data?: TokenBreakdown;
@@ -36,6 +37,7 @@ const COLORS = {
export function TokenBreakdownChart({ data, isLoading, className }: TokenBreakdownChartProps) {
const { privacyMode } = usePrivacy();
const { t } = useTranslation();
const chartData = useMemo(() => {
if (!data) return [];
@@ -82,7 +84,7 @@ export function TokenBreakdownChart({ data, isLoading, className }: TokenBreakdo
if (!data || chartData.every((d) => d.tokens === 0)) {
return (
<div className={cn('h-[250px] flex items-center justify-center', className)}>
<p className="text-muted-foreground">No token data available</p>
<p className="text-muted-foreground">{t('analyticsCards.noTokenData')}</p>
</div>
);
}
@@ -4,6 +4,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { CheckCircle2, Zap, Gauge, DollarSign, Database, Lightbulb } from 'lucide-react';
import type { Anomaly, AnomalySummary, AnomalyType } from '@/hooks/use-usage';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
interface UsageInsightsCardProps {
anomalies?: Anomaly[];
@@ -53,6 +54,8 @@ export function UsageInsightsCard({
isLoading,
className,
}: UsageInsightsCardProps) {
const { t } = useTranslation();
if (isLoading) {
return (
<Card
@@ -90,7 +93,9 @@ export function UsageInsightsCard({
<div className="w-12 h-12 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center mb-3 ring-4 ring-green-50 dark:ring-green-900/10">
<CheckCircle2 className="w-6 h-6 text-green-600 dark:text-green-400" />
</div>
<h3 className="font-medium text-foreground text-sm">All Systems Nominal</h3>
<h3 className="font-medium text-foreground text-sm">
{t('healthCard.allSystemsNominal')}
</h3>
<p className="text-xs mt-1.5 max-w-[200px] leading-relaxed">
Your usage patterns are within normal ranges for the selected period.
</p>
@@ -104,13 +109,15 @@ export function UsageInsightsCard({
<div className="px-1 py-2 flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Lightbulb className="w-4 h-4 text-amber-500" />
<h3 className="font-semibold text-sm">Usage Insights</h3>
<h3 className="font-semibold text-sm">{t('analyticsCards.usageInsights')}</h3>
</div>
<Badge
variant="outline"
className="h-5 px-2 text-[10px] font-medium border-amber-200 bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:border-amber-800 dark:text-amber-400"
>
{summary.totalAnomalies} {summary.totalAnomalies === 1 ? 'Alert' : 'Alerts'}
{summary.totalAnomalies}{' '}
{/* TODO i18n: missing key for singular/plural "Alert"/"Alerts" */}{' '}
{summary.totalAnomalies === 1 ? 'Alert' : 'Alerts'}
</Badge>
</div>
@@ -21,6 +21,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import type { DailyUsage, HourlyUsage } from '@/hooks/use-usage';
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
// TODO i18n: import { useTranslation } from 'react-i18next'; when keys are ready
type ChartData = DailyUsage | HourlyUsage;
@@ -38,6 +39,8 @@ export function UsageTrendChart({
className,
}: UsageTrendChartProps) {
const { privacyMode } = usePrivacy();
// TODO i18n: uncomment when keys for "No usage data for today" / "No usage data available" are added
// const { t } = useTranslation();
const chartData = useMemo(() => {
if (!data || data.length === 0) return [];
@@ -64,6 +67,7 @@ export function UsageTrendChart({
return (
<div className={cn('h-full flex items-center justify-center', className)}>
<p className="text-muted-foreground">
{/* TODO i18n: missing keys for "No usage data for today" / "No usage data available" */}
{granularity === 'hourly' ? 'No usage data for today' : 'No usage data available'}
</p>
</div>
+1 -1
View File
@@ -36,7 +36,7 @@ export function UserMenu() {
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleLogout} className="gap-2 text-destructive">
<LogOut className="h-4 w-4" />
Sign Out
{/* TODO i18n: missing key for "Sign Out" */}Sign Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -19,19 +19,19 @@ function getStatusState(status: AiProviderFamilyState['status']) {
case 'ready':
return {
icon: Check,
text: 'Ready',
text: 'Ready', // TODO i18n: missing key
className: 'text-green-600',
};
case 'partial':
return {
icon: AlertCircle,
text: 'Needs attention',
text: 'Needs attention', // TODO i18n: missing key
className: 'text-amber-600',
};
default:
return {
icon: Circle,
text: 'Not configured',
text: 'Not configured', // TODO i18n: missing key
className: 'text-muted-foreground',
};
}
@@ -33,6 +33,7 @@ function renderSecretBadge(entry: AiProviderEntryView) {
: 'bg-muted text-muted-foreground hover:bg-muted'
)}
>
{/* TODO i18n: missing keys for 'Configured' / 'Missing secret' */}
{entry.secretConfigured ? 'Configured' : 'Missing secret'}
</Badge>
);
@@ -47,6 +48,7 @@ export function ProviderEntryCard({
isSelected = false,
variant = 'detail',
}: ProviderEntryCardProps) {
// i18n: most strings in this file lack keys. See TODO comments below.
const hasAdvancedRouting = entry.prefix || entry.proxyUrl || entry.excludedModels.length > 0;
if (variant === 'row') {
@@ -21,6 +21,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import { ChevronDown, KeyRound, SlidersHorizontal } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type {
AiProviderEntryView,
AiProviderFamilyId,
@@ -271,6 +272,7 @@ export function ProviderEntryDialog({
onSubmit,
isSaving,
}: ProviderEntryDialogProps) {
const { t } = useTranslation();
const guide = useMemo(() => getDialogGuide(family), [family]);
const isEditing = Boolean(entry);
const supportsOpenAiCompat = family === 'openai-compatibility';
@@ -382,7 +384,9 @@ export function ProviderEntryDialog({
<div className="space-y-6 px-6 py-6">
<section className="space-y-4">
<div>
<div className="text-sm font-semibold">Required setup</div>
<div className="text-sm font-semibold">
{t('aiProvidersEntryDialog.requiredSetup')}
</div>
<div className="mt-1 text-sm text-muted-foreground">
Save the smallest working configuration first.
</div>
@@ -391,7 +395,9 @@ export function ProviderEntryDialog({
{supportsOpenAiCompat ? (
<div className="grid gap-4">
<div className="space-y-1.5">
<Label htmlFor="connector-name">Connector Name</Label>
<Label htmlFor="connector-name">
{t('aiProvidersEntryDialog.connectorName')}
</Label>
<Input
id="connector-name"
value={name}
@@ -440,7 +446,9 @@ export function ProviderEntryDialog({
<section className="space-y-4">
<div>
<div className="text-sm font-semibold">Optional routing</div>
<div className="text-sm font-semibold">
{t('aiProvidersEntryDialog.optionalRouting')}
</div>
<div className="mt-1 text-sm text-muted-foreground">
Only fill these when the route needs more than the default behavior.
</div>
@@ -72,7 +72,7 @@ const providerOptions = CLIPROXY_PROVIDERS.map((id) => ({
label: getProviderDisplayName(id),
}));
const AGY_DENYLIST_MESSAGE =
'Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.';
'Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.'; // TODO i18n: use t('providerEditor.agyDenylist')
function isDeniedAgyModelForProvider(provider: string, modelId: string | undefined): boolean {
return provider === 'agy' && typeof modelId === 'string' && isDeniedAgyModelId(modelId);
@@ -152,7 +152,7 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create CLIProxy Variant</DialogTitle>
<DialogTitle>{t('providerEditor.createVariant')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
@@ -64,7 +64,7 @@ const providerOptions = CLIPROXY_PROVIDERS.map((id) => ({
const COMPOSITE_TIERS = ['opus', 'sonnet', 'haiku'] as const;
const AGY_DENYLIST_MESSAGE =
'Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.';
'Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.'; // TODO i18n: use t('providerEditor.agyDenylist')
function normalizeOptionalValue(value?: string): string | undefined {
const trimmed = value?.trim();
@@ -11,6 +11,7 @@ import { useCliproxyAuth } from '@/hooks/use-cliproxy';
import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow';
import { cn } from '@/lib/utils';
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
import { useTranslation } from 'react-i18next';
interface VersionInfo {
currentVersion: string;
@@ -117,6 +118,7 @@ export function CliproxyHeader({
}: CliproxyHeaderProps) {
const { data: authData } = useCliproxyAuth();
const { provider: authProvider, isAuthenticating, startAuth } = useCliproxyAuthFlow();
const { t } = useTranslation();
const lastUpdatedText = useRelativeTime(lastUpdated);
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
@@ -157,7 +159,9 @@ export function CliproxyHeader({
<h1 className="text-2xl font-bold tracking-tight">
{versionInfo?.backendLabel ?? 'CLIProxy'}
</h1>
<p className="text-sm text-muted-foreground mt-1">CCS-level account management</p>
<p className="text-sm text-muted-foreground mt-1">
{t('cliproxyHeader.ccsLevelAccountManagement')}
</p>
</div>
{/* Login Buttons - Wrap on mobile */}
@@ -188,7 +192,7 @@ export function CliproxyHeader({
isRunning ? 'bg-green-500 animate-pulse' : 'bg-muted-foreground'
)}
/>
{isRunning ? 'Running' : 'Offline'}
{isRunning ? t('cliproxyStatsOverview.running') : t('cliproxyStatsOverview.offline')}
</Badge>
{versionInfo && (
@@ -29,12 +29,14 @@ import { cn } from '@/lib/utils';
import { useCliproxyStats, useCliproxyStatus } from '@/hooks/use-cliproxy-stats';
import { useCliproxyUpdateCheck } from '@/hooks/use-cliproxy';
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { useTranslation } from 'react-i18next';
interface CliproxyStatsOverviewProps {
className?: string;
}
export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps) {
const { t } = useTranslation();
const { privacyMode } = usePrivacy();
const { data: status, isLoading: statusLoading } = useCliproxyStatus();
const { data: stats, isLoading: statsLoading, error } = useCliproxyStats(status?.running);
@@ -71,15 +73,15 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
<div>
<h2 className="text-lg font-semibold tracking-tight flex items-center gap-2">
<Activity className="h-5 w-5" />
Session Statistics
{t('cliproxyStatsOverview.sessionStatistics')}
</h2>
<p className="text-sm text-muted-foreground">
Real-time usage metrics from {backendLabel}
{t('cliproxyStatsOverview.realTimeMetrics', { backend: backendLabel })}
</p>
</div>
<Badge variant="secondary" className="w-fit gap-1.5">
<ZapOff className="h-3.5 w-3.5" />
Offline
{t('cliproxyStatsOverview.offline')}
</Badge>
</div>
@@ -88,13 +90,9 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
<div className="p-4 rounded-full bg-muted/50 mb-4">
<Server className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="font-medium mb-1">No Active Session</h3>
<h3 className="font-medium mb-1">{t('cliproxyStatsOverview.noActiveSession')}</h3>
<p className="text-sm text-muted-foreground max-w-md">
Start a CLIProxy session using{' '}
<code className="px-1.5 py-0.5 bg-muted rounded text-xs font-mono">ccs gemini</code>,{' '}
<code className="px-1.5 py-0.5 bg-muted rounded text-xs font-mono">ccs codex</code>,
or <code className="px-1.5 py-0.5 bg-muted rounded text-xs font-mono">ccs agy</code>{' '}
to view real-time statistics.
{t('cliproxyStatsOverview.noActiveSessionHint')}
</p>
</CardContent>
</Card>
@@ -110,7 +108,7 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
<CardContent className="flex items-center gap-4 py-6">
<XCircle className="h-8 w-8 text-destructive shrink-0" />
<div>
<h3 className="font-medium">Failed to Load Statistics</h3>
<h3 className="font-medium">{t('cliproxyStatsOverview.failedLoadStats')}</h3>
<p className="text-sm text-muted-foreground">{error.message}</p>
</div>
</CardContent>
@@ -147,10 +145,10 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
<div>
<h2 className="text-lg font-semibold tracking-tight flex items-center gap-2">
<Activity className="h-5 w-5" />
Session Statistics
{t('cliproxyStatsOverview.sessionStatistics')}
</h2>
<p className="text-sm text-muted-foreground">
Real-time usage metrics from {backendLabel}
{t('cliproxyStatsOverview.realTimeMetrics', { backend: backendLabel })}
</p>
</div>
<Badge
@@ -158,7 +156,7 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
className="w-fit gap-1.5 text-green-600 border-green-200 bg-green-50 dark:bg-green-900/10 dark:border-green-800"
>
<Zap className="h-3.5 w-3.5" />
Running
{t('cliproxyStatsOverview.running')}
</Badge>
</div>
@@ -169,11 +167,15 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">Total Requests</p>
<p className="text-xs font-medium text-muted-foreground">
{t('cliproxyStatsOverview.totalRequests')}
</p>
<p className="text-2xl font-bold">{formatNumber(totalRequests)}</p>
<div className="flex items-center gap-1.5 text-[10px]">
<CheckCircle2 className="h-3 w-3 text-green-500" />
<span className="text-muted-foreground">{successRequests} success</span>
<span className="text-muted-foreground">
{t('cliproxyStatsOverview.successCount', { count: successRequests })}
</span>
</div>
</div>
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/20">
@@ -188,7 +190,9 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="space-y-1 flex-1">
<p className="text-xs font-medium text-muted-foreground">Success Rate</p>
<p className="text-xs font-medium text-muted-foreground">
{t('cliproxyStatsOverview.successRate')}
</p>
<p className="text-2xl font-bold">{successRate}%</p>
<div className="h-1.5 mt-2 bg-muted/50 rounded-full overflow-hidden">
<div
@@ -223,7 +227,9 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">Total Tokens</p>
<p className="text-xs font-medium text-muted-foreground">
{t('cliproxyStatsOverview.totalTokens')}
</p>
<p className={cn('text-2xl font-bold', privacyMode && PRIVACY_BLUR_CLASS)}>
{formatNumber(totalTokens)}
</p>
@@ -233,7 +239,9 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
privacyMode && PRIVACY_BLUR_CLASS
)}
>
~${estimateCost(totalTokens).toFixed(2)} estimated
{t('cliproxyStatsOverview.estimatedCost', {
cost: estimateCost(totalTokens).toFixed(2),
})}
</p>
</div>
<div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900/20">
@@ -248,7 +256,9 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">Models Used</p>
<p className="text-xs font-medium text-muted-foreground">
{t('cliproxyStatsOverview.modelsUsed')}
</p>
<p className="text-2xl font-bold">{models.length}</p>
<p className="text-[10px] text-muted-foreground">
{models.length > 0 ? formatModelName(models[0][0]) : 'None'}
@@ -268,7 +278,7 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Cpu className="h-4 w-4" />
Model Usage Distribution
{t('cliproxyStatsOverview.modelUsageDistribution')}
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
@@ -289,7 +299,7 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
</span>
</div>
<div className="flex items-center gap-3 text-muted-foreground shrink-0">
<span>{count} requests</span>
<span>{t('cliproxyStatsOverview.requestCount', { count })}</span>
<span className="text-xs font-medium w-10 text-right">{percentage}%</span>
</div>
</div>
+15 -7
View File
@@ -28,6 +28,7 @@ import { useDeleteVariant } from '@/hooks/use-cliproxy';
import { CliproxyEditDialog } from './cliproxy-edit-dialog';
import type { Variant } from '@/lib/api-client';
import { getProviderDisplayName } from '@/lib/provider-config';
import { useTranslation } from 'react-i18next';
interface CliproxyTableProps {
data: Variant[];
@@ -36,30 +37,35 @@ interface CliproxyTableProps {
export function CliproxyTable({ data }: CliproxyTableProps) {
const deleteMutation = useDeleteVariant();
const [editingVariant, setEditingVariant] = useState<Variant | null>(null);
const { t } = useTranslation();
const columns: ColumnDef<Variant>[] = [
{
accessorKey: 'name',
header: 'Name',
header: t('cliproxyTable.name'),
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
},
{
accessorKey: 'provider',
header: 'Provider',
header: t('cliproxyTable.provider'),
cell: ({ row }) => {
if (row.original.type === 'composite') {
return <Badge variant="secondary">composite</Badge>;
return <Badge variant="secondary">{t('providerEditor.composite')}</Badge>;
}
return getProviderDisplayName(row.original.provider);
},
},
{
accessorKey: 'account',
header: 'Account',
header: t('cliproxyTable.account'),
cell: ({ row }) => {
const account = row.original.account;
if (!account) {
return <span className="text-muted-foreground text-xs italic">default</span>;
return (
<span className="text-muted-foreground text-xs italic">
{t('providerEditor.defaultLabel')}
</span>
);
}
return (
<Badge variant="secondary" className="text-xs font-normal">
@@ -89,7 +95,7 @@ export function CliproxyTable({ data }: CliproxyTableProps) {
},
{
id: 'actions',
header: 'Actions',
header: t('cliproxyTable.actions'),
cell: ({ row }) => (
<div className="flex justify-end">
<DropdownMenu>
@@ -102,6 +108,7 @@ export function CliproxyTable({ data }: CliproxyTableProps) {
<DropdownMenuContent align="end" className="bg-white dark:bg-zinc-950">
<DropdownMenuItem onClick={() => setEditingVariant(row.original)}>
<Pencil className="w-4 h-4 mr-2" />
{/* TODO i18n: missing key for "Edit" */}
Edit
</DropdownMenuItem>
<DropdownMenuItem
@@ -109,6 +116,7 @@ export function CliproxyTable({ data }: CliproxyTableProps) {
onClick={() => deleteMutation.mutate(row.original.name)}
>
<Trash2 className="w-4 h-4 mr-2" />
{/* TODO i18n: missing key for "Delete" */}
Delete
</DropdownMenuItem>
</DropdownMenuContent>
@@ -128,7 +136,7 @@ export function CliproxyTable({ data }: CliproxyTableProps) {
if (data.length === 0) {
return (
<div className="text-center py-12 border rounded-lg bg-muted/5 border-dashed">
<div className="text-muted-foreground text-sm">No CLIProxy variants found.</div>
<div className="text-muted-foreground text-sm">{t('cliproxyHeader.noVariants')}</div>
<div className="text-xs text-muted-foreground mt-1">
Create one to use OAuth-based providers with specific account configurations.
</div>
+7 -5
View File
@@ -6,6 +6,7 @@
import type { ReactNode } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { LayoutDashboard, FileCode, ScrollText } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export type CliproxyTabValue = 'overview' | 'config' | 'logs';
@@ -20,12 +21,13 @@ interface CliproxyTabsProps {
}
const TAB_CONFIG = [
{ value: 'overview' as const, label: 'Overview', icon: LayoutDashboard },
{ value: 'config' as const, label: 'Config', icon: FileCode },
{ value: 'logs' as const, label: 'Logs', icon: ScrollText },
{ value: 'overview' as const, labelKey: 'cliproxyTabs.overview', icon: LayoutDashboard },
{ value: 'config' as const, labelKey: 'Config', icon: FileCode },
{ value: 'logs' as const, labelKey: 'Logs', icon: ScrollText },
];
export function CliproxyTabs({ activeTab, onTabChange, children }: CliproxyTabsProps) {
const { t } = useTranslation();
return (
<Tabs
value={activeTab}
@@ -33,10 +35,10 @@ export function CliproxyTabs({ activeTab, onTabChange, children }: CliproxyTabsP
className="w-full"
>
<TabsList className="grid w-full grid-cols-3 max-w-md">
{TAB_CONFIG.map(({ value, label, icon: Icon }) => (
{TAB_CONFIG.map(({ value, labelKey, icon: Icon }) => (
<TabsTrigger key={value} value={value} className="gap-2">
<Icon className="w-4 h-4" />
<span className="hidden sm:inline">{label}</span>
<span className="hidden sm:inline">{t(labelKey)}</span>
</TabsTrigger>
))}
</TabsList>
@@ -12,8 +12,10 @@ import { buildFileTree } from './file-tree-utils';
import { YamlEditor, EditorStatusBar } from './yaml-editor';
import { DiffDialog } from './diff-dialog';
import { useCliproxyConfig, useCliproxyAuthFile } from '@/hooks/use-cliproxy-config';
import { useTranslation } from 'react-i18next';
export function ConfigSplitView() {
const { t } = useTranslation();
const [selectedFile, setSelectedFile] = useState<string>('config.yaml');
const [showDiff, setShowDiff] = useState(false);
@@ -82,7 +84,7 @@ export function ConfigSplitView() {
<span className="font-medium text-sm">{selectedFile}</span>
{isDirty && isEditingConfig && (
<span className="text-xs text-amber-500 bg-amber-500/10 px-1.5 py-0.5 rounded">
Modified
{t('cliproxyConfig.modified')}
</span>
)}
</div>
@@ -12,6 +12,7 @@ import {
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useTranslation } from 'react-i18next';
interface DiffDialogProps {
open: boolean;
@@ -30,6 +31,7 @@ export function DiffDialog({
onConfirmSave,
isSaving,
}: DiffDialogProps) {
const { t } = useTranslation();
const originalLines = original.split('\n');
const modifiedLines = modified.split('\n');
@@ -63,13 +65,13 @@ export function DiffDialog({
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>Review Changes</DialogTitle>
<DialogTitle>{t('cliproxyConfig.reviewChanges')}</DialogTitle>
</DialogHeader>
<div className="border rounded-lg overflow-hidden">
<div className="grid grid-cols-2 text-xs font-medium bg-muted p-2 border-b">
<div className="text-red-600">Original</div>
<div className="text-green-600 border-l pl-2">Modified</div>
<div className="text-red-600">{t('cliproxyConfig.original')}</div>
<div className="text-green-600 border-l pl-2">{t('cliproxyConfig.modified')}</div>
</div>
<ScrollArea className="h-[400px]">
<div className="divide-y">{renderDiff()}</div>
@@ -78,10 +80,13 @@ export function DiffDialog({
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{/* TODO i18n: missing key for "Cancel" in diff context */}
Cancel
</Button>
<Button onClick={onConfirmSave} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save Changes'}
{isSaving
? /* TODO i18n: missing key */ 'Saving...'
: /* TODO i18n: missing key */ 'Save Changes'}
</Button>
</DialogFooter>
</DialogContent>
@@ -9,6 +9,7 @@ import { Highlight, themes } from 'prism-react-renderer';
import { useTheme } from '@/hooks/use-theme';
import { cn } from '@/lib/utils';
import { AlertCircle, CheckCircle2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface YamlEditorProps {
value: string;
@@ -100,6 +101,7 @@ export function EditorStatusBar({
cursorLine,
cursorCol,
}: EditorStatusBarProps) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-between px-3 py-1.5 border-t bg-muted/30 text-xs">
<div className="flex items-center gap-4">
@@ -116,7 +118,7 @@ export function EditorStatusBar({
</span>
)}
{isDirty && <span className="text-amber-500">Unsaved changes</span>}
{isDirty && <span className="text-amber-500">{t('cliproxyConfig.unsavedChanges')}</span>}
</div>
{cursorLine && cursorCol && (
@@ -12,6 +12,7 @@ import { useQuery } from '@tanstack/react-query';
import { api, withApiBase } from '@/lib/api-client';
import type { CliproxyServerConfig } from '@/lib/api-client';
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
import { useTranslation } from 'react-i18next';
interface AuthTokensResponse {
apiKey: { value: string; isCustom: boolean };
@@ -57,6 +58,7 @@ function clearLocalControlPanelSession(): void {
}
export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanelEmbedProps) {
const { t } = useTranslation();
const iframeRef = useRef<HTMLIFrameElement>(null);
const [loadedFrameKey, setLoadedFrameKey] = useState<string | null>(null);
const [iframeRevision, setIframeRevision] = useState(0);
@@ -280,7 +282,7 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
<div className="flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-2">
<Gauge className="w-5 h-5 text-primary" />
<h2 className="font-semibold">CLIProxy Control Panel</h2>
<h2 className="font-semibold">{t('cliproxyHeader.cliproxyControlPanel')}</h2>
</div>
<button
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm border rounded-md hover:bg-muted"
@@ -295,7 +297,9 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
<div className="w-16 h-16 rounded-full bg-destructive/10 flex items-center justify-center mx-auto mb-6">
<AlertCircle className="w-8 h-8 text-destructive" />
</div>
<h3 className="text-lg font-semibold mb-2">CLIProxy Not Available</h3>
<h3 className="text-lg font-semibold mb-2">
{t('cliproxyHeader.cliproxyNotAvailable')}
</h3>
<p className="text-muted-foreground mb-4">{error}</p>
<p className="text-sm text-muted-foreground">
Start a CLIProxy session with{' '}
@@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { isNativeGeminiModel } from '@/lib/extended-context-utils';
import type { ModelEntry } from './provider-model-selector';
import { useTranslation } from 'react-i18next';
interface ExtendedContextToggleProps {
/** Compatible selected models */
@@ -33,6 +34,7 @@ export function ExtendedContextToggle({
disabled,
className,
}: ExtendedContextToggleProps) {
const { t } = useTranslation();
if (models.length === 0) {
return null;
}
@@ -61,7 +63,7 @@ export function ExtendedContextToggle({
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Zap className={cn('w-4 h-4', enabled ? 'text-primary' : 'text-muted-foreground')} />
<span className="text-sm font-medium">Extended Context</span>
<span className="text-sm font-medium">{t('extendedContext.extendedContext')}</span>
<Badge variant={enabled ? 'default' : 'secondary'} className="text-[10px] h-5 px-1.5">
1M tokens
</Badge>
@@ -29,8 +29,14 @@ import {
} from 'lucide-react';
import type { AccountItemProps } from './types';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
function renderProjectId(projectId: string | undefined, privacyMode: boolean | undefined) {
function renderProjectId(
projectId: string | undefined,
privacyMode: boolean | undefined,
t: TFunction
) {
if (projectId) {
return (
<TooltipProvider>
@@ -50,7 +56,7 @@ function renderProjectId(projectId: string | undefined, privacyMode: boolean | u
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
<p className="text-xs">GCP Project ID (read-only)</p>
<p className="text-xs">{t('providerEditor.gcpProjectIdReadonly')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -63,13 +69,13 @@ function renderProjectId(projectId: string | undefined, privacyMode: boolean | u
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-500">
<AlertTriangle className="w-3 h-3" aria-label="Warning" />
<span>Project ID: N/A</span>
<span>{t('providerEditor.projectIdNA')}</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[250px]">
<div className="text-xs space-y-1">
<p className="font-medium text-amber-600">Missing Project ID</p>
<p>This may cause errors. Remove the account and re-add it to fetch the project ID.</p>
<p className="font-medium text-amber-600">{t('providerEditor.missingProjectId')}</p>
<p>{t('providerEditor.missingProjectIdHint')}</p>
</div>
</TooltipContent>
</Tooltip>
@@ -90,6 +96,7 @@ export function AccountItem({
selected,
onSelectChange,
}: AccountItemProps) {
const { t } = useTranslation();
const normalizedProvider = account.provider.toLowerCase();
const { data: stats } = useCliproxyStats(showQuota);
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
@@ -199,7 +206,7 @@ export function AccountItem({
beforeIdentity={beforeIdentity}
headerEnd={headerEnd}
bodySlot={
account.provider === 'agy' ? renderProjectId(account.projectId, privacyMode) : null
account.provider === 'agy' ? renderProjectId(account.projectId, privacyMode, t) : null
}
quotaInsetClassName="pl-11"
/>
@@ -12,6 +12,7 @@ import { User, Plus, Globe } from 'lucide-react';
import { AccountItem } from './account-item';
import { BulkActionBar } from './bulk-action-bar';
import type { OAuthAccount } from '@/lib/api-client';
import { useTranslation } from 'react-i18next';
interface AccountsSectionProps {
accounts: OAuthAccount[];
@@ -65,6 +66,7 @@ export function AccountsSection({
onKiroNoIncognitoChange,
kiroSettingsLoading,
}: AccountsSectionProps) {
const { t } = useTranslation();
// Multi-select state - raw selection (may contain stale IDs)
const [rawSelectedIds, setRawSelectedIds] = useState<Set<string>>(new Set());
@@ -140,6 +142,7 @@ export function AccountsSection({
/>
)}
<User className="w-4 h-4" />
{/* TODO i18n: missing key for "Accounts" */}
Accounts
{accounts.length > 0 && (
<Badge variant="secondary" className="text-xs">
@@ -191,8 +194,8 @@ export function AccountsSection({
) : (
<div className="py-6 text-center text-muted-foreground bg-muted/30 rounded-lg border border-dashed">
<User className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No accounts connected</p>
<p className="text-xs opacity-70">Add an account to get started</p>
<p className="text-sm">{t('providerEditor.noAccountsConnected')}</p>
<p className="text-xs opacity-70">{t('providerEditor.addAccountToStart')}</p>
</div>
)}
@@ -202,7 +205,7 @@ export function AccountsSection({
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Globe className="w-3.5 h-3.5" />
<span>Use incognito</span>
<span>{t('providerEditor.useIncognito')}</span>
</div>
<Switch
checked={!kiroNoIncognito}
@@ -49,6 +49,7 @@ export function BulkActionBar({
disabled={isLoading}
>
{isPausing ? <Loader2 className="w-3 h-3 animate-spin" /> : <Pause className="w-3 h-3" />}
{/* TODO i18n: missing key for "Pause Selected" */}
Pause Selected
</Button>
<Button
@@ -59,6 +60,7 @@ export function BulkActionBar({
disabled={isLoading}
>
{isResuming ? <Loader2 className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
{/* TODO i18n: missing key for "Resume Selected" */}
Resume Selected
</Button>
</div>
@@ -20,6 +20,7 @@ import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
import { isDeniedAgyModelId } from '@/lib/utils';
import i18n from '@/lib/i18n';
import { usePrivacy } from '@/contexts/privacy-context';
import { useTranslation } from 'react-i18next';
import { useProviderEditor } from './use-provider-editor';
import { CustomPresetDialog } from './custom-preset-dialog';
import { RawEditorSection } from './raw-editor-section';
@@ -55,6 +56,7 @@ export function ProviderEditor({
}: ProviderEditorProps) {
const [customPresetOpen, setCustomPresetOpen] = useState(false);
const { privacyMode } = usePrivacy();
const { t } = useTranslation();
const { data: modelsData } = useCliproxyModels();
const { data: presetsData } = usePresets(provider);
@@ -158,7 +160,7 @@ export function ProviderEditor({
updates.ANTHROPIC_DEFAULT_HAIKU_MODEL,
].some((modelId) => typeof modelId === 'string' && isDeniedAgyModelId(modelId))
) {
toast.error('Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.');
toast.error(t('providerEditor.agyDenylist'));
return;
}
@@ -178,7 +180,7 @@ export function ProviderEditor({
isDeniedAgyModelId(modelId)
)
) {
toast.error('Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.');
toast.error(t('providerEditor.agyDenylist'));
return;
}
@@ -206,7 +208,7 @@ export function ProviderEditor({
isDeniedAgyModelId(modelId)
)
) {
toast.error('Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.');
toast.error(t('providerEditor.agyDenylist'));
return;
}
createPresetMutation.mutate({ profile: provider, data: { name: presetName, ...values } });
@@ -234,7 +236,7 @@ export function ProviderEditor({
{isLoading ? (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
<span className="ml-3 text-muted-foreground">Loading settings...</span>
<span className="ml-3 text-muted-foreground">{t('providerEditor.loadingSettings')}</span>
</div>
) : (
<div className="min-h-0 flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden">
@@ -312,7 +314,7 @@ export function ProviderEditor({
<div className="px-6 py-2 bg-muted/30 border-b flex items-center gap-2 shrink-0 h-[45px]">
<Code2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">
Raw Configuration (JSON)
{t('rawEditorSection.rawConfig')} (JSON)
</span>
</div>
<RawEditorSection
@@ -13,6 +13,7 @@ import { ExtendedContextToggle } from '../extended-context-toggle';
import { stripExtendedContextSuffix } from '@/lib/extended-context-utils';
import { findCatalogModel, getResolvedCatalogModels } from '@/lib/model-catalogs';
import type { ModelConfigSectionProps } from './types';
import { useTranslation } from 'react-i18next';
type CatalogPresetModel = NonNullable<ModelConfigSectionProps['catalog']>['models'][number];
@@ -53,6 +54,7 @@ export function ModelConfigSection({
onDeletePreset,
isDeletePending,
}: ModelConfigSectionProps) {
const { t } = useTranslation();
const pinningReady = (routing?.models ?? []).some((hint) => hint.pinnedAvailable);
const routingHintMap = useMemo(
() =>
@@ -118,9 +120,10 @@ export function ModelConfigSection({
<div>
<h3 className="text-sm font-medium mb-2 flex items-center gap-2">
<Sparkles className="w-4 h-4" />
{/* TODO i18n: missing key for "Presets" */}
Presets
</h3>
<p className="text-xs text-muted-foreground mb-3">Apply pre-configured model mappings</p>
<p className="text-xs text-muted-foreground mb-3">{t('providerEditor.presets')}</p>
<div className="space-y-4">
{presetGroups.map((group) => (
<div key={group.key}>
@@ -204,7 +207,7 @@ export function ModelConfigSection({
{/* Model Mapping */}
<div>
<h3 className="text-sm font-medium mb-2">Model Mapping</h3>
<h3 className="text-sm font-medium mb-2">{t('providerEditor.modelMapping')}</h3>
<p className="text-xs text-muted-foreground mb-4">
Configure which models to use for each tier
</p>
@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge';
import { Save, Loader2, RefreshCw, Globe, Network } from 'lucide-react';
import { ProviderLogo } from '../provider-logo';
import type { SettingsResponse } from './types';
import { useTranslation } from 'react-i18next';
interface ProviderEditorHeaderProps {
provider: string;
@@ -38,6 +39,7 @@ export function ProviderEditorHeader({
onRefetch,
onSave,
}: ProviderEditorHeaderProps) {
const { t } = useTranslation();
return (
<div className="px-6 py-4 border-b bg-background flex items-center justify-between shrink-0">
<div className="flex items-center gap-3">
@@ -67,12 +69,13 @@ export function ProviderEditorHeader({
</div>
{isRemoteMode ? (
<p className="text-xs text-blue-600 dark:text-blue-400 mt-0.5">
{/* TODO i18n: missing key for remote traffic text */}
Traffic auto-routed to remote server
</p>
) : (
data && (
<p className="text-xs text-muted-foreground mt-0.5">
Last modified: {new Date(data.mtime).toLocaleString()}
{t('providerEditor.lastModified')}: {new Date(data.mtime).toLocaleString()}
</p>
)
)}
@@ -10,6 +10,7 @@ import { Info, Shield } from 'lucide-react';
import { UsageCommand } from './usage-command';
import type { SettingsResponse } from './types';
import type { AuthStatus, CliTarget } from '@/lib/api-client';
import { useTranslation } from 'react-i18next';
interface ProviderInfoTabProps {
provider: string;
@@ -28,6 +29,7 @@ export function ProviderInfoTab({
authStatus,
supportsModelConfig = false,
}: ProviderInfoTabProps) {
const { t } = useTranslation();
const resolvedTarget = defaultTarget || 'claude';
const isDroidTarget = resolvedTarget === 'droid';
const isCodexProvider = provider === 'codex';
@@ -44,17 +46,22 @@ export function ProviderInfoTab({
<div>
<h3 className="text-sm font-medium flex items-center gap-2 mb-3">
<Info className="w-4 h-4" />
{/* TODO i18n: missing key for "Provider Information" */}
Provider Information
</h3>
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">Provider</span>
<span className="font-medium text-muted-foreground">
{t('providerEditor.provider')}
</span>
<span className="font-mono">{displayName}</span>
</div>
{data && (
<>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">File Path</span>
<span className="font-medium text-muted-foreground">
{t('providerEditor.filePath')}
</span>
<div className="flex items-center gap-2 min-w-0">
<code className="bg-muted px-1.5 py-0.5 rounded text-xs break-all">
{data.path}
@@ -63,13 +70,17 @@ export function ProviderInfoTab({
</div>
</div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">Last Modified</span>
<span className="font-medium text-muted-foreground">
{t('providerEditor.lastModified')}
</span>
<span className="text-xs">{new Date(data.mtime).toLocaleString()}</span>
</div>
</>
)}
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">Status</span>
<span className="font-medium text-muted-foreground">
{t('providerEditor.status')}
</span>
{authStatus.authenticated ? (
<Badge
variant="outline"
@@ -85,7 +96,9 @@ export function ProviderInfoTab({
)}
</div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">Default Target</span>
<span className="font-medium text-muted-foreground">
{t('providerEditor.defaultTarget')}
</span>
<span className="font-mono">{resolvedTarget}</span>
</div>
</div>
@@ -93,7 +106,7 @@ export function ProviderInfoTab({
{/* Quick Usage */}
<div>
<h3 className="text-sm font-medium mb-3">Quick Usage</h3>
<h3 className="text-sm font-medium mb-3">{t('providerEditor.quickUsage')}</h3>
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
<UsageCommand label="Run with prompt" command={`ccs ${provider} "your prompt"`} />
{isCodexProvider && (
@@ -7,6 +7,7 @@ import { lazy, Suspense } from 'react';
import { Loader2, X, AlertTriangle } from 'lucide-react';
import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator';
import type { RawEditorSectionProps } from './types';
import { useTranslation } from 'react-i18next';
// Lazy load CodeEditor
const CodeEditor = lazy(() =>
@@ -21,6 +22,7 @@ export function RawEditorSection({
profileEnv,
missingRequiredFields = [],
}: RawEditorSectionProps) {
const { t } = useTranslation();
const hasMissingFields = missingRequiredFields.length > 0;
return (
@@ -28,7 +30,7 @@ export function RawEditorSection({
fallback={
<div className="flex items-center justify-center h-full">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading editor...</span>
<span className="ml-2 text-muted-foreground">{t('providerEditor.loadingEditor')}</span>
</div>
}
>
@@ -6,6 +6,7 @@
import { useState, useMemo, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import i18n from '@/lib/i18n';
import type { SettingsResponse, UseProviderEditorReturn } from './types';
import type { ProviderCatalog } from '../provider-model-selector';
import {
@@ -177,11 +178,11 @@ export function useProviderEditor(
setRawJsonEdits(null);
// Show warning if fields missing (runtime uses defaults)
if (responseData?.warning) {
toast.success('Settings saved', {
toast.success(i18n.t('settings.saved'), {
description: responseData.warning,
});
} else {
toast.success('Settings saved');
toast.success(i18n.t('settings.saved'));
}
},
onError: (error: Error) => {
@@ -379,12 +379,12 @@ export function FlexibleModelSelector({
{model.tier === 'paid' && <PaidBadge label={t('providerModelSelector.paid')} />}
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? (
<Badge variant="outline" className="text-[9px] h-4 px-1">
Shadowed
{t('providerModelSelector.shadowed')}
</Badge>
) : null}
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? (
<Badge variant="outline" className="text-[9px] h-4 px-1">
Prefix only
{t('providerModelSelector.prefixOnly')}
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
@@ -425,12 +425,12 @@ export function FlexibleModelSelector({
</span>
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? (
<Badge variant="outline" className="text-[9px] h-4 px-1">
Shadowed
{t('providerModelSelector.shadowed')}
</Badge>
) : null}
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? (
<Badge variant="outline" className="text-[9px] h-4 px-1">
Prefix only
{t('providerModelSelector.prefixOnly')}
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
@@ -450,7 +450,7 @@ export function FlexibleModelSelector({
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{value}</span>
<Badge variant="outline" className="text-[9px] h-4 px-1">
Current
{t('providerModelSelector.current')}
</Badge>
</div>
),
@@ -458,7 +458,7 @@ export function FlexibleModelSelector({
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{value}</span>
<Badge variant="outline" className="text-[9px] h-4 px-1">
Current
{t('providerModelSelector.current')}
</Badge>
</div>
),
@@ -489,7 +489,11 @@ export function FlexibleModelSelector({
? [
{
key: 'current',
label: <span className="text-xs text-muted-foreground">Current value</span>,
label: (
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.currentValue')}
</span>
),
},
]
: []),
@@ -531,8 +535,8 @@ export function FlexibleModelSelector({
>
<div className="font-medium">
{selectedRoutingHint.pinnedAvailable
? 'Preferred pinned model:'
: 'Pinned route status:'}{' '}
? t('providerModelSelector.preferredPinnedModel')
: t('providerModelSelector.pinnedRouteStatus')}{' '}
<code>
{selectedRoutingHint.pinnedAvailable
? selectedRoutingHint.recommendedModelId
@@ -544,7 +548,7 @@ export function FlexibleModelSelector({
) : null}
{value && !selectedRoutingHint && normalizeModelValue(value, routing) !== value ? (
<div className="rounded-md border border-amber-300/60 bg-amber-50 px-2.5 py-2 text-[11px] text-amber-900 dark:border-amber-500/30 dark:bg-amber-950/25 dark:text-amber-100">
Pinned model is not currently advertised by the proxy: <code>{value}</code>
{t('providerModelSelector.pinnedModelNotAdvertised', { model: value })}
</div>
) : null}
</div>
@@ -4,6 +4,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import type { CliproxyRoutingState, RoutingStrategy } from '@/lib/api-client';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
interface RoutingGuidanceCardProps {
className?: string;
@@ -35,6 +36,7 @@ export function RoutingGuidanceCard({
error,
onApply,
}: RoutingGuidanceCardProps) {
const { t } = useTranslation();
const currentStrategy = state?.strategy ?? 'round-robin';
const [selected, setSelected] = useState<RoutingStrategy>(currentStrategy);
const [detailsOpen, setDetailsOpen] = useState(false);
@@ -114,7 +116,7 @@ export function RoutingGuidanceCard({
<div className="inline-flex h-7 w-7 items-center justify-center rounded-lg bg-muted text-primary">
<ArrowRightLeft className="h-4 w-4" />
</div>
<div className="text-sm font-medium">Routing strategy</div>
<div className="text-sm font-medium">{t('routingGuidance.routingStrategy')}</div>
<Badge variant="secondary">{currentStrategy}</Badge>
{state ? <Badge variant="outline">{sourceLabel}</Badge> : null}
{state ? <Badge variant="outline">{state.target}</Badge> : null}
@@ -164,6 +166,7 @@ export function RoutingGuidanceCard({
) : (
<ChevronDown className="mr-1 h-3.5 w-3.5" />
)}
{/* TODO i18n: missing key for detail toggle */}
{detailToggleLabel}
</Button>
<Button size="sm" onClick={() => onApply(selected)} disabled={saveDisabled || !!error}>
@@ -173,9 +176,9 @@ export function RoutingGuidanceCard({
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground xl:col-span-2">
<span>Round robin spreads usage.</span>
<span>{t('routingGuidance.roundRobin')}</span>
<span className="hidden text-border sm:inline"></span>
<span>Fill first keeps backup accounts cold until they are needed.</span>
<span>{t('routingGuidance.fillFirst')}</span>
</div>
{error ? (
@@ -1,4 +1,5 @@
import { FileCode2, History, PenLine, Settings2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { CodexFeaturesCard } from '@/components/compatible-cli/codex-features-card';
import { CodexMcpServersCard } from '@/components/compatible-cli/codex-mcp-servers-card';
import { CodexModelProvidersCard } from '@/components/compatible-cli/codex-model-providers-card';
@@ -51,6 +52,8 @@ export function CodexControlCenterTab({
saving,
onPatch,
}: CodexControlCenterTabProps) {
const { t } = useTranslation();
return (
<ScrollArea className="h-full">
<div className="space-y-6 pr-1 pb-6">
@@ -65,7 +68,7 @@ export function CodexControlCenterTab({
</div>
<div>
<h2 className="text-base font-semibold tracking-tight text-foreground">
Structured controls boundary
{t('codex.controlCenter')}
</h2>
</div>
</div>
@@ -74,6 +77,7 @@ export function CodexControlCenterTab({
<li className="flex items-start gap-2.5">
<PenLine className="h-4 w-4 shrink-0 text-muted-foreground/60 mt-0.5" />
<span className="leading-relaxed">
{/* TODO i18n: missing key codex.writesUserLayer */}
Writes exclusively to user-layer{' '}
<code className="text-[11px] bg-muted/70 px-1.5 py-0.5 rounded border border-border/50">
config.toml
@@ -83,6 +87,7 @@ export function CodexControlCenterTab({
<li className="flex items-start gap-2.5">
<History className="h-4 w-4 shrink-0 text-muted-foreground/60 mt-0.5" />
<span className="leading-relaxed">
{/* TODO i18n: missing key codex.noRepoTrustReflection */}
Does not reflect repo trust layers or CLI overrides
</span>
</li>
@@ -95,9 +100,11 @@ export function CodexControlCenterTab({
<FileCode2 className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="space-y-1">
<p className="text-[11px] font-bold text-amber-700 dark:text-amber-300 uppercase tracking-wider">
{/* TODO i18n: missing key codex.formattingNote */}
Formatting Note
</p>
<p className="text-[13px] leading-relaxed text-amber-800/80 dark:text-amber-200/70">
{/* TODO i18n: missing key codex.formattingNoteDesc */}
Saves normalize TOML formatting and strip comments. Switch to the raw editor
if exact layout matters.
</p>
@@ -115,7 +122,7 @@ export function CodexControlCenterTab({
disabledReason={disabledReason}
saving={saving}
onSave={(values: CodexTopLevelSettingsPatch) =>
onPatch({ kind: 'top-level', values }, 'Saved top-level Codex settings.')
onPatch({ kind: 'top-level', values }, t('toasts.codexSaved'))
}
/>
@@ -128,6 +135,7 @@ export function CodexControlCenterTab({
onSave={(projectPath, trustLevel) =>
onPatch(
{ kind: 'project-trust', path: projectPath, trustLevel },
// TODO i18n: missing keys codex.savedProjectTrust / codex.removedProjectTrust
trustLevel ? 'Saved project trust entry.' : 'Removed project trust entry.'
)
}
@@ -143,14 +151,23 @@ export function CodexControlCenterTab({
onSave={(name, values: CodexProfilePatchValues, setAsActive) =>
onPatch(
{ kind: 'profile', action: 'upsert', name, values, setAsActive },
// TODO i18n: missing key codex.savedProfile
'Saved profile.'
)
}
onDelete={(name) =>
onPatch({ kind: 'profile', action: 'delete', name }, 'Deleted profile.')
onPatch(
{ kind: 'profile', action: 'delete', name },
// TODO i18n: missing key codex.deletedProfile
'Deleted profile.'
)
}
onSetActive={(name) =>
onPatch({ kind: 'profile', action: 'set-active', name }, 'Set active profile.')
onPatch(
{ kind: 'profile', action: 'set-active', name },
// TODO i18n: missing key codex.setActiveProfile
'Set active profile.'
)
}
/>
@@ -162,11 +179,16 @@ export function CodexControlCenterTab({
onSave={(name, values) =>
onPatch(
{ kind: 'model-provider', action: 'upsert', name, values },
// TODO i18n: missing key codex.savedModelProvider
'Saved model provider.'
)
}
onDelete={(name) =>
onPatch({ kind: 'model-provider', action: 'delete', name }, 'Deleted model provider.')
onPatch(
{ kind: 'model-provider', action: 'delete', name },
// TODO i18n: missing key codex.deletedModelProvider
'Deleted model provider.'
)
}
/>
@@ -176,10 +198,18 @@ export function CodexControlCenterTab({
disabledReason={disabledReason}
saving={saving}
onSave={(name, values) =>
onPatch({ kind: 'mcp-server', action: 'upsert', name, values }, 'Saved MCP server.')
onPatch(
{ kind: 'mcp-server', action: 'upsert', name, values },
// TODO i18n: missing key codex.savedMcpServer
'Saved MCP server.'
)
}
onDelete={(name) =>
onPatch({ kind: 'mcp-server', action: 'delete', name }, 'Deleted MCP server.')
onPatch(
{ kind: 'mcp-server', action: 'delete', name },
// TODO i18n: missing key codex.deletedMcpServer
'Deleted MCP server.'
)
}
/>
@@ -189,7 +219,11 @@ export function CodexControlCenterTab({
disabled={disabled}
disabledReason={disabledReason}
onToggle={(feature, enabled) =>
onPatch({ kind: 'feature', feature, enabled }, 'Saved feature toggle.')
onPatch(
{ kind: 'feature', feature, enabled },
// TODO i18n: missing key codex.savedFeatureToggle
'Saved feature toggle.'
)
}
/>
</div>
@@ -1,5 +1,6 @@
import { type ReactNode } from 'react';
import { ExternalLink, ShieldCheck } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
@@ -85,6 +86,8 @@ interface CodexDocsTabProps {
}
export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) {
const { t } = useTranslation();
const docsReference = diagnostics.docsReference ?? {
notes: [],
links: [],
@@ -103,15 +106,18 @@ export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) {
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<ShieldCheck className="h-4 w-4" />
{/* TODO i18n: missing key codex.ccsBridgeRecipe */}
CCS bridge recipe
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<div className="space-y-1.5">
<p>
{/* TODO i18n: missing key codex.builtInLabel */}
<strong>Built-in:</strong> Use <code>ccsxp</code> for the CCS provider shortcut.
</p>
<p>
{/* TODO i18n: missing key codex.nativeLabelRecipe */}
<strong>Native:</strong> Configure the recipe below to use CLIProxy directly with{' '}
<code>codex</code>.
</p>
@@ -121,12 +127,15 @@ export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) {
</pre>
<ol className="ml-4 list-decimal space-y-1.5 [&>li]:pl-1">
<li>
{/* TODO i18n: missing key codex.saveCliproxyProvider */}
Save the <code>cliproxy</code> provider in your user config.
</li>
<li>
{/* TODO i18n: missing key codex.setTopLevelModelProvider */}
Set top-level <code>model_provider</code> to <code>cliproxy</code>.
</li>
<li>
{/* TODO i18n: missing key codex.exportCliproxyApiKey */}
Export <code>CLIPROXY_API_KEY</code> before launching native Codex.
</li>
</ol>
@@ -137,6 +146,7 @@ export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) {
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<ShieldCheck className="h-4 w-4" />
{/* TODO i18n: missing key codex.upstreamNotes */}
Upstream notes
</CardTitle>
</CardHeader>
@@ -150,7 +160,9 @@ export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) {
)}
<Separator />
<div className="space-y-2">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Codex docs</p>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
{t('codex.codexDocs')}
</p>
<div className="space-y-1.5">
{docsLinks.map((link) => (
<a
@@ -175,6 +187,7 @@ export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) {
<Separator />
<div className="space-y-2">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
{/* TODO i18n: missing key codex.providerBridgeReference */}
Provider / bridge reference
</p>
<div className="space-y-1.5">
@@ -204,12 +217,14 @@ export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) {
<>
<Separator />
<p className="text-xs text-muted-foreground">
{/* TODO i18n: missing key codex.providerValues */}
Provider values: {docsReference.providerValues.join(', ')}
</p>
</>
)}
{docsReference.settingsHierarchy.length > 0 && (
<p className="text-xs text-muted-foreground">
{/* TODO i18n: missing key codex.settingsHierarchy */}
Settings hierarchy: {docsReference.settingsHierarchy.join(' -> ')}
</p>
)}
@@ -1,4 +1,5 @@
import { Sparkles } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
@@ -20,6 +21,8 @@ export function CodexFeaturesCard({
disabledReason,
onToggle,
}: CodexFeaturesCardProps) {
const { t } = useTranslation();
const knownFeatureNames = new Set(catalog.map((feature) => feature.name));
const configOnlyFeatures = Object.entries(state)
.filter(([name]) => !knownFeatureNames.has(name))
@@ -27,9 +30,11 @@ export function CodexFeaturesCard({
return (
<CodexConfigCardShell
// TODO i18n: missing key codex.features
title="Features"
badge="features"
icon={<Sparkles className="h-4 w-4" />}
// TODO i18n: missing key codex.featuresDesc
description="Toggle the supported Codex feature flags CCS can safely manage."
disabledReason={disabledReason}
>
@@ -58,7 +63,7 @@ export function CodexFeaturesCard({
onClick={() => onToggle(feature.name, null)}
disabled={disabled}
>
Use default
{t('codex.useDefault')}
</Button>
) : null}
<Switch
@@ -76,9 +81,11 @@ export function CodexFeaturesCard({
<div className="space-y-2">
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{/* TODO i18n: missing key codex.configOnlyFlags */}
Existing config-only flags
</p>
<p className="text-xs text-muted-foreground">
{/* TODO i18n: missing key codex.configOnlyFlagsDesc */}
These feature keys already exist in your `config.toml`, so CCS can surface them
without claiming full catalog coverage.
</p>
@@ -92,17 +99,19 @@ export function CodexFeaturesCard({
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{name}</p>
<Badge variant="secondary" className="text-[10px]">
{/* TODO i18n: missing key codex.existing */}
existing
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{/* TODO i18n: missing keys codex.nonBooleanForm / codex.discoveredFromFile */}
{current === null
? 'Stored in a non-boolean form. Use raw TOML if you need to edit it.'
: "Discovered from the current file instead of CCS's built-in catalog."}
</p>
</div>
{current === null ? (
<Badge variant="outline">Raw only</Badge>
<Badge variant="outline">{t('codex.rawOnly')}</Badge>
) : (
<div className="flex items-center gap-2">
<Button
@@ -111,7 +120,7 @@ export function CodexFeaturesCard({
onClick={() => onToggle(name, null)}
disabled={disabled}
>
Use default
{t('codex.useDefault')}
</Button>
<Switch
checked={current === true}
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react';
import { Loader2, PlugZap, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
@@ -67,6 +68,7 @@ function McpServerEditor({
onSave,
onDelete,
}: McpServerEditorProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState<CodexMcpServerEntry>(initialDraft);
return (
@@ -92,8 +94,8 @@ function McpServerEditor({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="stdio">stdio</SelectItem>
<SelectItem value="streamable-http">streamable-http</SelectItem>
<SelectItem value="stdio">{t('codex.stdio')}</SelectItem>
<SelectItem value="streamable-http">{t('codex.streamableHttp')}</SelectItem>
</SelectContent>
</Select>
{draft.transport === 'stdio' ? (
@@ -136,6 +138,7 @@ function McpServerEditor({
startupTimeoutSec: event.target.value ? Number(event.target.value) : null,
}))
}
// TODO i18n: missing key codex.startupTimeoutSec
placeholder="Startup timeout (sec)"
disabled={disabled}
/>
@@ -149,6 +152,7 @@ function McpServerEditor({
toolTimeoutSec: event.target.value ? Number(event.target.value) : null,
}))
}
// TODO i18n: missing key codex.toolTimeoutSec
placeholder="Tool timeout (sec)"
disabled={disabled}
/>
@@ -172,6 +176,7 @@ function McpServerEditor({
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
{/* TODO i18n: missing key codex.enabled */}
Enabled
<Switch
checked={draft.enabled}
@@ -180,6 +185,7 @@ function McpServerEditor({
/>
</label>
<label className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
{/* TODO i18n: missing key codex.required */}
Required
<Switch
checked={draft.required}
@@ -192,6 +198,7 @@ function McpServerEditor({
<div className="flex justify-between gap-2">
<Button variant="outline" onClick={onDelete} disabled={disabled || saving || !canDelete}>
<Trash2 className="mr-2 h-4 w-4" />
{/* TODO i18n: missing key common.delete */}
Delete
</Button>
<Button
@@ -212,6 +219,7 @@ function McpServerEditor({
disabled={disabled || saving || draft.name.trim().length === 0}
>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{/* TODO i18n: missing key codex.saveMcpServer */}
Save MCP server
</Button>
</div>
@@ -227,6 +235,8 @@ export function CodexMcpServersCard({
onSave,
onDelete,
}: CodexMcpServersCardProps) {
const { t } = useTranslation();
const [selectedName, setSelectedName] = useState('new');
const selectedEntry = useMemo(
() => entries.find((entry) => entry.name === selectedName) ?? null,
@@ -237,18 +247,21 @@ export function CodexMcpServersCard({
return (
<CodexConfigCardShell
// TODO i18n: missing key codex.mcpServers
title="MCP servers"
badge="mcp_servers"
icon={<PlugZap className="h-4 w-4" />}
// TODO i18n: missing key codex.mcpServersDesc
description="Manage the safe MCP transport fields. Keep auth headers and bearer tokens in raw TOML."
disabledReason={disabledReason}
>
<Select value={selectedName} onValueChange={setSelectedName} disabled={disabled}>
<SelectTrigger>
{/* TODO i18n: missing key codex.selectMcpServer */}
<SelectValue placeholder="Select MCP server" />
</SelectTrigger>
<SelectContent>
<SelectItem value="new">Create new MCP server</SelectItem>
<SelectItem value="new">{t('codex.createNewMcpServer')}</SelectItem>
{entries.map((entry) => (
<SelectItem key={entry.name} value={entry.name}>
{entry.name}
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react';
import { KeyRound, Loader2, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
@@ -62,6 +63,7 @@ function ModelProviderEditor({
onSave,
onDelete,
}: ModelProviderEditorProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState<CodexModelProviderEntry>(initialDraft);
return (
@@ -69,8 +71,10 @@ function ModelProviderEditor({
{isNew && (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground">
<p>
{/* TODO i18n: missing key codex.cliproxyQuickStart */}
Quick start: apply the CLIProxy Codex preset here, then set{' '}
<strong>Default provider</strong> to <code>cliproxy</code> in Top-level settings.
<strong>{t('codex.defaultProvider')}</strong> to <code>cliproxy</code> in Top-level
settings.
</p>
<Button
variant="outline"
@@ -78,6 +82,7 @@ function ModelProviderEditor({
onClick={() => setDraft(CLIPROXY_CODEX_PROVIDER_PRESET)}
disabled={disabled}
>
{/* TODO i18n: missing key codex.useCliproxyCodexPreset */}
Use CLIProxy Codex preset
</Button>
</div>
@@ -87,6 +92,7 @@ function ModelProviderEditor({
<Input
value={draft.name}
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
// TODO i18n: missing key codex.providerId
placeholder="Provider id"
disabled={disabled || !isNew}
/>
@@ -95,6 +101,7 @@ function ModelProviderEditor({
onChange={(event) =>
setDraft((current) => ({ ...current, displayName: event.target.value || null }))
}
// TODO i18n: missing key codex.displayName
placeholder="Display name"
disabled={disabled}
/>
@@ -126,10 +133,11 @@ function ModelProviderEditor({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="responses">responses</SelectItem>
<SelectItem value="responses">{t('codex.responses')}</SelectItem>
</SelectContent>
</Select>
<label className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
{/* TODO i18n: missing key codex.requiresOpenaiAuth */}
Requires OpenAI auth
<Switch
checked={draft.requiresOpenaiAuth}
@@ -140,6 +148,7 @@ function ModelProviderEditor({
/>
</label>
<label className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
{/* TODO i18n: missing key codex.supportsWebsockets */}
Supports websockets
<Switch
checked={draft.supportsWebsockets}
@@ -154,6 +163,7 @@ function ModelProviderEditor({
<div className="flex justify-between gap-2">
<Button variant="outline" onClick={onDelete} disabled={disabled || saving || !canDelete}>
<Trash2 className="mr-2 h-4 w-4" />
{/* TODO i18n: missing key common.delete */}
Delete
</Button>
<Button
@@ -170,14 +180,16 @@ function ModelProviderEditor({
disabled={disabled || saving || draft.name.trim().length === 0}
>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{/* TODO i18n: missing key codex.saveProvider */}
Save provider
</Button>
</div>
<p className="text-xs text-muted-foreground">
{/* TODO i18n: missing key codex.nativeCodexCliproxyHint */}
If you want plain native <code>codex</code> to default to CLIProxy, save a provider named{' '}
<code>cliproxy</code> with <code>CLIPROXY_API_KEY</code> here, then pick{' '}
<code>cliproxy</code> in the <strong>Default provider</strong> control above.
<code>cliproxy</code> in the <strong>{t('codex.defaultProvider')}</strong> control above.
</p>
</>
);
@@ -191,6 +203,7 @@ export function CodexModelProvidersCard({
onSave,
onDelete,
}: CodexModelProvidersCardProps) {
const { t } = useTranslation();
const [selectedName, setSelectedName] = useState<string>('new');
const selectedEntry = useMemo(
() => entries.find((entry) => entry.name === selectedName) ?? null,
@@ -201,18 +214,21 @@ export function CodexModelProvidersCard({
return (
<CodexConfigCardShell
// TODO i18n: missing key codex.modelProviders
title="Model providers"
badge="model_providers"
icon={<KeyRound className="h-4 w-4" />}
// TODO i18n: missing key codex.modelProvidersDesc
description="Edit the common provider fields CCS can support safely. Keep secret migration and inline bearer tokens in raw TOML."
disabledReason={disabledReason}
>
<Select value={selectedName} onValueChange={setSelectedName} disabled={disabled}>
<SelectTrigger>
{/* TODO i18n: missing key codex.selectProvider */}
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
<SelectItem value="new">Create new provider</SelectItem>
<SelectItem value="new">{t('codex.createNewProvider')}</SelectItem>
{entries.map((entry) => (
<SelectItem key={entry.name} value={entry.name}>
{entry.name}
@@ -8,6 +8,7 @@ import {
TerminalSquare,
XCircle,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { QuickCommands } from '@/components/shared';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -59,6 +60,8 @@ interface CodexOverviewTabProps {
}
export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
const { t } = useTranslation();
const inspectProfileCommand = diagnostics.config.activeProfile
? `codex --profile ${diagnostics.config.activeProfile}`
: 'codex';
@@ -71,25 +74,29 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Info className="h-4 w-4" />
{/* TODO i18n: missing key codex.howCodexWorks */}
How Codex works in CCS
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
<ul className="ml-4 list-disc space-y-1.5 [&>li]:pl-1">
<li>Codex is a first-class, runtime-only target in CCS v1.</li>
<li>{t('codex.nativeDesc')}</li>
<li>
{/* TODO i18n: missing key codex.nativeConfigLabel */}
<strong>Native config:</strong> <code>ccs-codex</code> and <code>ccsx</code> launch
native Codex using your saved defaults.
</li>
<li>
{/* TODO i18n: missing key codex.transientOverrides */}
<strong>Transient overrides:</strong> <code>ccsxp</code> (or{' '}
<code>ccs codex --target codex</code>) uses the CCS provider shortcut.
</li>
<li>
{/* TODO i18n: missing key codex.cliproxyDefault */}
<strong>CLIProxy default:</strong> To make plain <code>codex</code> use CLIProxy,
set <code>model_provider = "cliproxy"</code> and add the recipe below.
</li>
<li>API profiles continue to default to Claude or Droid.</li>
<li>{t('codex.apiProfilesDefault')}</li>
</ul>
</CardContent>
</Card>
@@ -98,16 +105,19 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<TerminalSquare className="h-4 w-4" />
{/* TODO i18n: missing key codex.runtimeInstall */}
Runtime install
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Status</span>
<span className="text-sm text-muted-foreground">{t('codex.status')}</span>
<Badge variant={diagnostics.binary.installed ? 'default' : 'secondary'}>
{/* TODO i18n: missing keys codex.detected / codex.notFound */}
{diagnostics.binary.installed ? 'Detected' : 'Not found'}
</Badge>
</div>
{/* TODO i18n: missing keys codex.detectionSource / codex.binaryPath / codex.installDirectory / codex.version / codex.nativeAliases / codex.ccsProviderShortcut / codex.configOverrideSupport */}
<DetailRow label="Detection source" value={diagnostics.binary.source} mono />
<DetailRow label="Binary path" value={diagnostics.binary.path || 'Not found'} mono />
<DetailRow
@@ -121,6 +131,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<div className="flex items-center justify-between rounded-md border px-3 py-2">
<span className="text-sm text-muted-foreground">--config override support</span>
<Badge variant={diagnostics.binary.supportsConfigOverrides ? 'default' : 'secondary'}>
{/* TODO i18n: missing keys codex.available / codex.missing */}
{diagnostics.binary.supportsConfigOverrides ? 'Available' : 'Missing'}
</Badge>
</div>
@@ -131,6 +142,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Route className="h-4 w-4" />
{/* TODO i18n: missing key codex.cliproxyNativeCodex */}
CLIProxy-backed native Codex
</CardTitle>
</CardHeader>
@@ -139,34 +151,39 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<>
<div className="space-y-1.5">
<p>
<strong>Two supported paths:</strong>
<strong>{t('codex.twoSupportedPaths')}</strong>
</p>
<ul className="ml-4 list-disc space-y-1 [&>li]:pl-1">
<li>
{/* TODO i18n: missing key codex.builtInCcsxp */}
<strong>Built-in:</strong> Use <code>ccsxp</code> for the CCS provider
shortcut.
</li>
<li>
{/* TODO i18n: missing key codex.nativeRecipe */}
<strong>Native:</strong> Configure the recipe below to use CLIProxy directly
with <code>codex</code>.
</li>
</ul>
</div>
<div className="rounded-md border bg-muted/20 p-3">
<p className="font-medium text-foreground">Saved native Codex recipe</p>
<p className="font-medium text-foreground">{t('codex.codexNativeRecipe')}</p>
<pre className="mt-2 overflow-x-auto rounded-md bg-background p-3 text-xs text-foreground">
{CLIPROXY_NATIVE_CODEX_RECIPE}
</pre>
</div>
<ol className="ml-4 list-decimal space-y-1.5 [&>li]:pl-1">
<li>
{/* TODO i18n: missing key codex.saveProviderNamedCliproxy */}
Save a provider named <code>cliproxy</code> with the base URL and env key above.
</li>
<li>
{/* TODO i18n: missing key codex.inTopLevelSetDefault */}
In <strong>Top-level settings</strong>, set <strong>Default provider</strong> to{' '}
<code>cliproxy</code>.
</li>
<li>
{/* TODO i18n: missing key codex.exportCliproxyApiKey */}
Export <code>CLIPROXY_API_KEY</code> in your shell before launching native
Codex.
</li>
@@ -174,6 +191,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
</>
) : (
<p>
{/* TODO i18n: missing key codex.noConfigOverrides */}
This Codex build can still use the native path, but CCS-backed Codex routing via{' '}
<code>ccsxp</code> or <code>ccs codex --target codex</code> stays unavailable until
the detected Codex binary exposes <code>--config</code> overrides.
@@ -186,30 +204,34 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Folder className="h-4 w-4" />
{/* TODO i18n: missing key codex.configFile */}
Config file
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="rounded-md border p-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-sm">User config</span>
<span className="font-medium text-sm">{t('codex.userConfig')}</span>
{diagnostics.file.exists ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-muted-foreground" />
)}
</div>
{/* TODO i18n: missing keys codex.path / codex.resolved / codex.size / codex.lastModified */}
<DetailRow label="Path" value={diagnostics.file.path} mono />
<DetailRow label="Resolved" value={diagnostics.file.resolvedPath} mono />
<DetailRow label="Size" value={formatBytes(diagnostics.file.sizeBytes)} />
<DetailRow label="Last modified" value={formatTimestamp(diagnostics.file.mtimeMs)} />
{diagnostics.file.parseError && (
<p className="text-xs text-amber-600">
{/* TODO i18n: missing key codex.tomlWarning */}
TOML warning: {diagnostics.file.parseError}
</p>
)}
{diagnostics.file.readError && (
<p className="text-xs text-destructive">
{/* TODO i18n: missing key codex.readWarning */}
Read warning: {diagnostics.file.readError}
</p>
)}
@@ -221,34 +243,46 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<ShieldCheck className="h-4 w-4" />
{/* TODO i18n: missing key codex.currentUserLayerSummary */}
Current user-layer summary
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<DetailRow label="Model" value={diagnostics.config.model || 'Not set'} mono />
{/* TODO i18n: missing key codex.notSet */}
<DetailRow
label="Model provider"
label={t('codex.model')}
value={diagnostics.config.model || 'Not set'}
mono
/>
<DetailRow
label={t('codex.defaultProvider')}
value={diagnostics.config.modelProvider || 'Not set'}
mono
/>
<DetailRow
// TODO i18n: missing key codex.activeProfile
label="Active profile"
value={diagnostics.config.activeProfile || 'Not set'}
mono
/>
<DetailRow
label="Approval policy"
label={t('codex.approvalPolicy')}
value={diagnostics.config.approvalPolicy || 'Not set'}
mono
/>
<DetailRow
label="Sandbox mode"
label={t('codex.sandboxMode')}
value={diagnostics.config.sandboxMode || 'Not set'}
mono
/>
<DetailRow label="Web search" value={diagnostics.config.webSearch || 'Not set'} mono />
<DetailRow
label={t('codex.webSearch')}
value={diagnostics.config.webSearch || 'Not set'}
mono
/>
<Separator />
<div className="grid grid-cols-2 gap-2 text-xs">
{/* TODO i18n: missing keys codex.providersCount / codex.profilesCount / codex.enabledFeaturesCount / codex.mcpServersCount */}
<Badge variant="outline" className="justify-center">
providers: {diagnostics.config.modelProviderCount}
</Badge>
@@ -265,6 +299,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
{diagnostics.config.topLevelKeys.length > 0 && (
<div className="space-y-2">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
{/* TODO i18n: missing key codex.userLayerKeysPresent */}
User-layer keys present
</p>
<div className="flex flex-wrap gap-1.5">
@@ -282,28 +317,31 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<QuickCommands
snippets={[
{
// TODO i18n: missing key codex.nativeShortAlias
label: 'Native short alias',
command: 'ccsx',
description: 'Launch the short native Codex runtime alias.',
},
{
// TODO i18n: missing key codex.ccsCodexShortcut
label: 'CCS Codex shortcut',
command: 'ccsxp "your prompt"',
description: supportsManagedRouting
? 'Run the built-in CCS Codex provider on native Codex.'
? t('codex.runBuiltInCodex')
: 'Requires a Codex build that exposes --config overrides.',
},
{
// TODO i18n: missing key codex.explicitProviderRoute
label: 'Explicit provider route',
command: 'ccs codex --target codex "your prompt"',
description: supportsManagedRouting
? 'Use the explicit built-in Codex provider route.'
? t('codex.runBuiltInCodexExplicit')
: 'Requires a Codex build that exposes --config overrides.',
},
{
label: diagnostics.config.activeProfile
? 'Inspect active profile'
: 'Open native Codex',
: t('codex.openNativeCodex'),
command: inspectProfileCommand,
description: diagnostics.config.activeProfile
? 'Inspect the active named profile directly in native Codex.'
@@ -316,12 +354,13 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Route className="h-4 w-4" />
{/* TODO i18n: missing key codex.runtimeVsProvider */}
Runtime vs provider
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2">
<div className="flex flex-col rounded-md border p-3 text-sm">
<p className="font-medium text-foreground">Native Codex runtime</p>
<p className="font-medium text-foreground">{t('codex.nativeCodexRuntime')}</p>
<ul className="mt-2 flex-grow list-disc space-y-1.5 pl-4 text-muted-foreground [&>li]:pl-1">
<li>
<code>ccs-codex</code>
@@ -334,11 +373,12 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
</li>
</ul>
<Badge variant="secondary" className="mt-4 w-fit justify-center font-normal">
{/* TODO i18n: missing key codex.honorsSavedNativeConfig */}
Honors saved native user config
</Badge>
</div>
<div className="flex flex-col rounded-md border p-3 text-sm">
<p className="font-medium text-foreground">CCS Codex provider / bridge</p>
<p className="font-medium text-foreground">{t('codex.ccsCodexProvider')}</p>
{supportsManagedRouting ? (
<>
<ul className="mt-2 flex-grow list-disc space-y-1.5 pl-4 text-muted-foreground [&>li]:pl-1">
@@ -350,11 +390,13 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
</li>
</ul>
<Badge variant="secondary" className="mt-4 w-fit justify-center font-normal">
{/* TODO i18n: missing key codex.usesTransientOverrides */}
Uses transient overrides
</Badge>
</>
) : (
<p className="mt-2 text-muted-foreground">
{/* TODO i18n: missing key codex.unavailableNoConfig */}
Unavailable (Codex build lacks <code>--config</code> support).
</p>
)}
@@ -364,15 +406,15 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Supported flows</CardTitle>
<CardTitle className="text-base">{t('codex.supportedFlows')}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Flow</TableHead>
<TableHead>Status</TableHead>
<TableHead>Notes</TableHead>
<TableHead>{t('codex.flow')}</TableHead>
<TableHead>{t('codex.status')}</TableHead>
<TableHead>{t('codex.notes')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -381,6 +423,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<TableCell className="font-mono text-xs">{entry.label}</TableCell>
<TableCell>
<Badge variant={entry.supported ? 'default' : 'secondary'}>
{/* TODO i18n: missing keys common.yes / common.no */}
{entry.supported ? 'Yes' : 'No'}
</Badge>
</TableCell>
@@ -397,6 +440,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="h-4 w-4 text-amber-600" />
{/* TODO i18n: missing key codex.warnings */}
Warnings
</CardTitle>
</CardHeader>
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react';
import { Layers3, Loader2, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
@@ -62,6 +63,7 @@ function ProfileEditor({
onDelete,
onSetActive,
}: ProfileEditorProps) {
const { t } = useTranslation();
const [nameDraft, setNameDraft] = useState(initialName);
const [modelDraft, setModelDraft] = useState<string | null>(initialModel);
const [providerDraft, setProviderDraft] = useState<string | null>(initialProvider);
@@ -88,10 +90,10 @@ function ProfileEditor({
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use global provider" />
<SelectValue placeholder={t('codex.useGlobalProvider')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unset__">Use global provider</SelectItem>
<SelectItem value="__unset__">{t('codex.useGlobalProvider')}</SelectItem>
{providerNames.map((name) => (
<SelectItem key={name} value={name}>
{name}
@@ -105,10 +107,10 @@ function ProfileEditor({
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use global effort" />
<SelectValue placeholder={t('codex.useGlobalEffort')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unset__">Use global effort</SelectItem>
<SelectItem value="__unset__">{t('codex.useGlobalEffort')}</SelectItem>
{['minimal', 'low', 'medium', 'high', 'xhigh'].map((value) => (
<SelectItem key={value} value={value}>
{value}
@@ -126,6 +128,7 @@ function ProfileEditor({
disabled={disabled || saving || !selectedEntryName}
>
<Trash2 className="mr-2 h-4 w-4" />
{/* TODO i18n: missing key common.delete */}
Delete
</Button>
<Button
@@ -135,6 +138,7 @@ function ProfileEditor({
disabled || saving || !selectedEntryName || selectedEntryName === activeProfile
}
>
{/* TODO i18n: missing key codex.setActive */}
Set active
</Button>
</div>
@@ -155,6 +159,7 @@ function ProfileEditor({
disabled={disabled || saving || nameDraft.trim().length === 0}
>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{/* TODO i18n: missing key codex.saveProfile */}
Save profile
</Button>
<Button
@@ -172,6 +177,7 @@ function ProfileEditor({
disabled={disabled || saving || nameDraft.trim().length === 0}
>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{/* TODO i18n: missing key codex.saveAndActivate */}
Save + activate
</Button>
</div>
@@ -191,6 +197,8 @@ export function CodexProfilesCard({
onDelete,
onSetActive,
}: CodexProfilesCardProps) {
const { t } = useTranslation();
const [selectedName, setSelectedName] = useState('new');
const selectedEntry = useMemo(
() => entries.find((entry) => entry.name === selectedName) ?? null,
@@ -200,21 +208,24 @@ export function CodexProfilesCard({
return (
<CodexConfigCardShell
title="Profiles"
title={t('codex.profiles')}
badge="profiles"
icon={<Layers3 className="h-4 w-4" />}
// TODO i18n: missing key codex.profilesDesc
description="Create reusable Codex overlays and set the active default profile."
disabledReason={disabledReason}
>
<Select value={selectedName} onValueChange={setSelectedName} disabled={disabled}>
<SelectTrigger>
{/* TODO i18n: missing key codex.selectProfile */}
<SelectValue placeholder="Select profile" />
</SelectTrigger>
<SelectContent>
<SelectItem value="new">Create new profile</SelectItem>
<SelectItem value="new">{t('codex.createNewProfile')}</SelectItem>
{entries.map((entry) => (
<SelectItem key={entry.name} value={entry.name}>
{entry.name}
{/* TODO i18n: missing key codex.activeSuffix */}
{entry.name === activeProfile ? ' (active)' : ''}
</SelectItem>
))}
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { FolderCheck, Loader2, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
@@ -34,6 +35,7 @@ function ProjectTrustComposer({
saving,
onSave,
}: ProjectTrustComposerProps) {
const { t } = useTranslation();
const [pathDraft, setPathDraft] = useState(workspacePath);
const [trustLevel, setTrustLevel] = useState('trusted');
@@ -50,12 +52,13 @@ function ProjectTrustComposer({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="trusted">trusted</SelectItem>
<SelectItem value="untrusted">untrusted</SelectItem>
<SelectItem value="trusted">{t('codex.trusted')}</SelectItem>
<SelectItem value="untrusted">{t('codex.untrusted')}</SelectItem>
</SelectContent>
</Select>
<Button onClick={() => onSave(pathDraft, trustLevel)} disabled={disabled || saving}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{/* TODO i18n: missing key codex.saveTrust */}
Save trust
</Button>
</div>
@@ -70,15 +73,20 @@ export function CodexProjectTrustCard({
saving = false,
onSave,
}: CodexProjectTrustCardProps) {
const { t } = useTranslation();
return (
<CodexConfigCardShell
// TODO i18n: missing key codex.projectTrust
title="Project trust"
badge="projects"
icon={<FolderCheck className="h-4 w-4" />}
// TODO i18n: missing key codex.projectTrustDesc
description="Trust current workspaces or remove stale trust entries without opening raw TOML."
disabledReason={disabledReason}
>
<p className="text-xs text-muted-foreground">
{/* TODO i18n: missing key codex.trustPathsHint */}
Paths must be absolute or start with <code>~/</code>. Relative paths are rejected so CCS
does not trust the wrong folder.
</p>
@@ -96,12 +104,13 @@ export function CodexProjectTrustCard({
onClick={() => onSave(workspacePath, 'trusted')}
disabled={disabled || saving}
>
{/* TODO i18n: missing key codex.trustCurrentWorkspace */}
Trust current workspace
</Button>
<div className="space-y-2">
{entries.length === 0 ? (
<p className="text-xs text-muted-foreground">No explicit project trust entries saved.</p>
<p className="text-xs text-muted-foreground">{t('codex.noProjectTrustEntries')}</p>
) : (
entries.map((entry) => (
<div
@@ -121,6 +130,7 @@ export function CodexProjectTrustCard({
}
disabled={disabled || saving}
>
{/* TODO i18n: missing key codex.toggle */}
Toggle
</Button>
<Button
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { CircleAlert, Loader2, SlidersHorizontal } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -93,6 +94,8 @@ function TopLevelControlsForm({
saving,
onSave,
}: TopLevelControlsFormProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState<CodexTopLevelSettingsView>(initialValues);
const reasoningOptions = withCurrentValue(
['minimal', 'low', 'medium', 'high', 'xhigh'],
@@ -121,7 +124,7 @@ function TopLevelControlsForm({
<>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<p className="text-xs font-medium">Model</p>
<p className="text-xs font-medium">{t('codex.model')}</p>
<Input
value={draft.model ?? ''}
onChange={(event) =>
@@ -133,7 +136,7 @@ function TopLevelControlsForm({
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Reasoning effort</p>
<p className="text-xs font-medium">{t('codex.reasoningEffort')}</p>
<Select
value={toSelectValue(draft.modelReasoningEffort)}
onValueChange={(next) =>
@@ -145,10 +148,10 @@ function TopLevelControlsForm({
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use default" />
<SelectValue placeholder={t('codex.useDefault')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Use default</SelectItem>
<SelectItem value={UNSET}>{t('codex.useDefault')}</SelectItem>
{reasoningOptions.map((value) => (
<SelectItem key={value} value={value}>
{value}
@@ -159,7 +162,7 @@ function TopLevelControlsForm({
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Default provider</p>
<p className="text-xs font-medium">{t('codex.defaultProvider')}</p>
<Select
value={toSelectValue(draft.modelProvider)}
onValueChange={(next) =>
@@ -168,10 +171,10 @@ function TopLevelControlsForm({
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use Codex default" />
<SelectValue placeholder={t('codex.useCodexDefault')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Use Codex default</SelectItem>
<SelectItem value={UNSET}>{t('codex.useCodexDefault')}</SelectItem>
{providerOptions.map((name) => (
<SelectItem key={name} value={name}>
{name}
@@ -182,7 +185,7 @@ function TopLevelControlsForm({
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Approval policy</p>
<p className="text-xs font-medium">{t('codex.approvalPolicy')}</p>
<Select
value={toSelectValue(draft.approvalPolicy)}
onValueChange={(next) =>
@@ -191,10 +194,10 @@ function TopLevelControlsForm({
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use default" />
<SelectValue placeholder={t('codex.useDefault')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Use default</SelectItem>
<SelectItem value={UNSET}>{t('codex.useDefault')}</SelectItem>
{approvalOptions.map((value) => (
<SelectItem key={value} value={value}>
{value}
@@ -205,7 +208,7 @@ function TopLevelControlsForm({
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Sandbox mode</p>
<p className="text-xs font-medium">{t('codex.sandboxMode')}</p>
<Select
value={toSelectValue(draft.sandboxMode)}
onValueChange={(next) =>
@@ -214,10 +217,10 @@ function TopLevelControlsForm({
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use default" />
<SelectValue placeholder={t('codex.useDefault')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Use default</SelectItem>
<SelectItem value={UNSET}>{t('codex.useDefault')}</SelectItem>
{sandboxOptions.map((value) => (
<SelectItem key={value} value={value}>
{value}
@@ -228,7 +231,7 @@ function TopLevelControlsForm({
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Web search</p>
<p className="text-xs font-medium">{t('codex.webSearch')}</p>
<Select
value={toSelectValue(draft.webSearch)}
onValueChange={(next) =>
@@ -237,10 +240,10 @@ function TopLevelControlsForm({
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use default" />
<SelectValue placeholder={t('codex.useDefault')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Use default</SelectItem>
<SelectItem value={UNSET}>{t('codex.useDefault')}</SelectItem>
{webSearchOptions.map((value) => (
<SelectItem key={value} value={value}>
{value}
@@ -251,7 +254,7 @@ function TopLevelControlsForm({
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Tool output token limit</p>
<p className="text-xs font-medium">{t('codex.toolOutputTokenLimit')}</p>
<Input
type="number"
min={1}
@@ -268,7 +271,7 @@ function TopLevelControlsForm({
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Personality</p>
<p className="text-xs font-medium">{t('codex.personality')}</p>
<Select
value={toSelectValue(draft.personality)}
onValueChange={(next) =>
@@ -277,10 +280,10 @@ function TopLevelControlsForm({
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use default" />
<SelectValue placeholder={t('codex.useDefault')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Use default</SelectItem>
<SelectItem value={UNSET}>{t('codex.useDefault')}</SelectItem>
{personalityOptions.map((value) => (
<SelectItem key={value} value={value}>
{value}
@@ -296,21 +299,24 @@ function TopLevelControlsForm({
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<CircleAlert className="h-4 w-4 text-amber-600 dark:text-amber-300" />
<p className="text-sm font-semibold">Long context override</p>
<p className="text-sm font-semibold">{t('codex.longContextOverride')}</p>
<Badge
variant="outline"
className="border-amber-500/40 bg-background/80 text-[10px] uppercase tracking-[0.16em] text-amber-700 dark:text-amber-300"
>
{/* TODO i18n: missing key codex.manualOptInOnly */}
Manual opt-in only
</Badge>
<Badge
variant="secondary"
className="text-[10px] uppercase tracking-[0.16em] text-muted-foreground"
>
{/* TODO i18n: missing keys codex.gpt54Selected / codex.gpt54Reference */}
{isGpt54Selected ? 'GPT-5.4 selected' : 'GPT-5.4 reference'}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{/* TODO i18n: missing key codex.draftValuesOnly */}
Draft values only. Nothing applies until Save.
</p>
</div>
@@ -329,6 +335,7 @@ function TopLevelControlsForm({
}))
}
>
{/* TODO i18n: missing key codex.fillCautiousPair */}
Fill cautious pair
</Button>
<Button
@@ -343,6 +350,7 @@ function TopLevelControlsForm({
}))
}
>
{/* TODO i18n: missing key codex.setOfficialMaxWindow */}
Set official max window
</Button>
<Button
@@ -358,6 +366,7 @@ function TopLevelControlsForm({
}))
}
>
{/* TODO i18n: missing key codex.clear */}
Clear
</Button>
</div>
@@ -366,26 +375,31 @@ function TopLevelControlsForm({
<div className="grid gap-2 sm:grid-cols-3">
<div className="rounded-lg border bg-background/85 px-3 py-3 shadow-sm shadow-black/5">
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{/* TODO i18n: missing key codex.officialMax */}
Official max
</p>
<p className="mt-1 font-mono text-base font-semibold text-foreground">1.05M / 1M</p>
<p className="mt-1 text-[11px] text-muted-foreground">GPT-5.4 context cap</p>
<p className="mt-1 text-[11px] text-muted-foreground">{t('codex.gptContextCap')}</p>
</div>
<div className="rounded-lg border bg-background/85 px-3 py-3 shadow-sm shadow-black/5">
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{/* TODO i18n: missing key codex.standardWindow */}
Standard window
</p>
<p className="mt-1 font-mono text-base font-semibold text-foreground">
{formatInteger(GPT_54_STANDARD_CONTEXT_WINDOW)}
</p>
<p className="mt-1 text-[11px] text-muted-foreground">Normal usage window</p>
<p className="mt-1 text-[11px] text-muted-foreground">{t('codex.normalUsageWindow')}</p>
</div>
<div className="rounded-lg border bg-background/85 px-3 py-3 shadow-sm shadow-black/5">
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{/* TODO i18n: missing key codex.above272k */}
Above 272K
</p>
<p className="mt-1 font-mono text-base font-semibold text-foreground">Counts 2x</p>
<p className="mt-1 text-[11px] text-muted-foreground">Usage-limit cost above 272K</p>
<p className="mt-1 font-mono text-base font-semibold text-foreground">
{t('codex.counts2x')}
</p>
<p className="mt-1 text-[11px] text-muted-foreground">{t('codex.usageLimitCost')}</p>
</div>
</div>
@@ -393,12 +407,15 @@ function TopLevelControlsForm({
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{/* TODO i18n: missing key codex.oneCautiousPair */}
One cautious pair
</p>
<div className="rounded-full border bg-background px-2.5 py-1 font-mono text-[11px] font-medium">
{/* TODO i18n: missing key codex.context */}
Context {formatInteger(CCS_GPT_54_STARTER_CONTEXT_WINDOW)}
</div>
<div className="rounded-full border bg-background px-2.5 py-1 font-mono text-[11px] font-medium">
{/* TODO i18n: missing key codex.autoCompact */}
Auto-compact {formatInteger(CCS_GPT_54_STARTER_AUTO_COMPACT_TOKEN_LIMIT)}
</div>
</div>
@@ -407,20 +424,23 @@ function TopLevelControlsForm({
variant="outline"
className="border-border/70 bg-background/80 text-[10px] uppercase tracking-[0.14em] text-muted-foreground"
>
{/* TODO i18n: missing key codex.notOfficial */}
Not official
</Badge>
<Badge
variant="outline"
className="border-border/70 bg-background/80 text-[10px] uppercase tracking-[0.14em] text-muted-foreground"
>
{/* TODO i18n: missing key codex.draftOnly */}
Draft only
</Badge>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
<span>Quick-fill only. Review before saving.</span>
<span>{t('codex.quickFillWarning')}</span>
{!isGpt54Selected && draft.model ? (
<span>
{/* TODO i18n: missing key codex.shouldBeCheckedSeparately */}
<code>{draft.model}</code> should be checked separately.
</span>
) : null}
@@ -429,9 +449,9 @@ function TopLevelControlsForm({
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<p className="text-xs font-medium">Model context window</p>
<p className="text-xs font-medium">{t('codex.modelContextWindow')}</p>
<Input
aria-label="Model context window"
aria-label={t('codex.modelContextWindow')}
type="number"
min={1}
value={draft.modelContextWindow ?? ''}
@@ -441,18 +461,20 @@ function TopLevelControlsForm({
modelContextWindow: parseOptionalInteger(event.target.value),
}))
}
// TODO i18n: missing key codex.unset
placeholder="Unset"
disabled={disabled}
/>
<p className="text-xs text-muted-foreground">
{/* TODO i18n: missing key codex.writesModelContextWindow */}
Writes <code>model_context_window</code>. Leave unset to keep Codex defaults.
</p>
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Auto-compact token limit</p>
<p className="text-xs font-medium">{t('codex.autoCompactTokenLimit')}</p>
<Input
aria-label="Auto-compact token limit"
aria-label={t('codex.autoCompactTokenLimit')}
type="number"
min={1}
value={draft.modelAutoCompactTokenLimit ?? ''}
@@ -462,10 +484,12 @@ function TopLevelControlsForm({
modelAutoCompactTokenLimit: parseOptionalInteger(event.target.value),
}))
}
// TODO i18n: missing key codex.unset
placeholder="Unset"
disabled={disabled}
/>
<p className="text-xs text-muted-foreground">
{/* TODO i18n: missing key codex.writesAutoCompactTokenLimit */}
Writes <code>model_auto_compact_token_limit</code>. Leave unset to keep model
defaults.
</p>
@@ -473,6 +497,7 @@ function TopLevelControlsForm({
</div>
<div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
{/* TODO i18n: missing key codex.docs */}
<span className="text-[10px] uppercase tracking-[0.14em]">Docs</span>
<a
href="https://developers.openai.com/api/docs/models/gpt-5.4"
@@ -480,6 +505,7 @@ function TopLevelControlsForm({
rel="noreferrer"
className="underline underline-offset-2 hover:text-foreground"
>
{/* TODO i18n: missing key codex.gpt54ModelPage */}
GPT-5.4 model page
</a>
<a
@@ -488,6 +514,7 @@ function TopLevelControlsForm({
rel="noreferrer"
className="underline underline-offset-2 hover:text-foreground"
>
{/* TODO i18n: missing key codex.releaseNotes */}
Release notes
</a>
<a
@@ -496,6 +523,7 @@ function TopLevelControlsForm({
rel="noreferrer"
className="underline underline-offset-2 hover:text-foreground"
>
{/* TODO i18n: missing key codex.configReference */}
Config reference
</a>
</div>
@@ -504,6 +532,7 @@ function TopLevelControlsForm({
<div className="flex justify-end">
<Button onClick={() => onSave(patch)} disabled={disabled || saving || !hasChanges}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{/* TODO i18n: missing key codex.saveTopLevelSettings */}
Save top-level settings
</Button>
</div>
@@ -521,9 +550,11 @@ export function CodexTopLevelControlsCard({
}: CodexTopLevelControlsCardProps) {
return (
<CodexConfigCardShell
// TODO i18n: missing key codex.topLevelControls
title="Top-level controls"
badge="config.toml"
icon={<SlidersHorizontal className="h-4 w-4" />}
// TODO i18n: missing key codex.topLevelControlsDesc
description="Structured controls for the stable top-level Codex settings users touch most often. Unsupported upstream shapes stay untouched and should be edited in raw TOML."
disabledReason={disabledReason}
>
@@ -1,4 +1,5 @@
import { BrainCircuit } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
@@ -39,12 +40,14 @@ export function DroidByokReasoningControlsCard({
onEffortChange,
onAnthropicBudgetChange,
}: DroidByokReasoningControlsCardProps) {
const { t } = useTranslation();
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<BrainCircuit className="h-4 w-4" />
BYOK Reasoning / Thinking
{t('droidSettings.reasoningControls')}
<Badge variant="outline" className="text-[10px] font-normal">
customModels
</Badge>
@@ -55,6 +58,7 @@ export function DroidByokReasoningControlsCard({
{models.length === 0 ? (
<p className="text-xs text-muted-foreground">
{/* TODO i18n: missing key droidSettings.noByokModels */}
No BYOK custom models found in settings.json (`customModels` or `custom_models`).
</p>
) : (
@@ -75,7 +79,9 @@ export function DroidByokReasoningControlsCard({
<div className="grid gap-2 sm:grid-cols-2">
<div className="space-y-1">
<p className="text-[11px] font-medium">Reasoning Effort</p>
<p className="text-[11px] font-medium">
{t('codex.reasoningEffortCapitalized')}
</p>
<Select
value={model.effort ?? UNSET_VALUE}
onValueChange={(next) =>
@@ -84,9 +90,11 @@ export function DroidByokReasoningControlsCard({
disabled={disabled}
>
<SelectTrigger className="h-8 text-xs">
{/* TODO i18n: missing key droidSettings.useProviderDefault */}
<SelectValue placeholder="Use provider default" />
</SelectTrigger>
<SelectContent>
{/* TODO i18n: missing key droidSettings.useProviderDefault */}
<SelectItem value={UNSET_VALUE}>Use provider default</SelectItem>
{DROID_REASONING_EFFORT_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
@@ -99,7 +107,7 @@ export function DroidByokReasoningControlsCard({
{model.providerKind === 'anthropic' && (
<div className="space-y-1">
<p className="text-[11px] font-medium">Thinking Budget Tokens</p>
<p className="text-[11px] font-medium">{t('codex.thinkingBudgetTokens')}</p>
<Input
type="number"
min={1024}
@@ -1,4 +1,5 @@
import { SlidersHorizontal } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
@@ -123,12 +124,14 @@ export function DroidSettingsQuickControlsCard({
onBooleanSettingChange,
onNumberSettingChange,
}: DroidSettingsQuickControlsCardProps) {
const { t } = useTranslation();
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<SlidersHorizontal className="h-4 w-4" />
Quick Settings
{t('droidSettings.quickControls')}
<Badge variant="outline" className="text-[10px] font-normal">
settings.json
</Badge>
@@ -140,6 +143,7 @@ export function DroidSettingsQuickControlsCard({
<div className="grid gap-3 sm:grid-cols-2">
{enumFieldConfig.map((field) => (
<div key={field.key} className="space-y-1">
{/* TODO i18n: missing keys for droidSettings enum/boolean/number field labels */}
<p className="text-xs font-medium">{field.label}</p>
<Select
value={values[field.key] ?? UNSET_VALUE}
@@ -149,10 +153,10 @@ export function DroidSettingsQuickControlsCard({
disabled={disabled}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Use default" />
<SelectValue placeholder={t('codex.useDefault')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET_VALUE}>Use default</SelectItem>
<SelectItem value={UNSET_VALUE}>{t('codex.useDefault')}</SelectItem>
{field.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
@@ -166,6 +170,7 @@ export function DroidSettingsQuickControlsCard({
{numberFieldConfig.map((field) => (
<div key={field.key} className="space-y-1">
{/* TODO i18n: missing keys for droidSettings number field labels */}
<p className="text-xs font-medium">{field.label}</p>
<Input
type="number"
@@ -190,6 +195,7 @@ export function DroidSettingsQuickControlsCard({
{booleanFieldConfig.map((field) => (
<div key={field.key} className="space-y-1">
{/* TODO i18n: missing keys for droidSettings boolean field labels */}
<p className="text-xs font-medium">{field.label}</p>
<Select
value={toBooleanSelectValue(values[field.key])}
@@ -197,10 +203,10 @@ export function DroidSettingsQuickControlsCard({
disabled={disabled}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Use default" />
<SelectValue placeholder={t('codex.useDefault')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET_VALUE}>Use default</SelectItem>
<SelectItem value={UNSET_VALUE}>{t('codex.useDefault')}</SelectItem>
<SelectItem value="true">true</SelectItem>
<SelectItem value="false">false</SelectItem>
</SelectContent>
@@ -67,6 +67,7 @@ export function RawConfigEditorPanel({
{title}
{dirty && (
<Badge variant="secondary" className="text-[10px]">
{/* TODO i18n: missing key rawJsonSettingsEditor.unsaved */}
Unsaved
</Badge>
)}
@@ -80,15 +81,18 @@ export function RawConfigEditorPanel({
) : (
<Save className="h-4 w-4 mr-1" />
)}
{/* TODO i18n: missing key rawJsonSettingsEditor.save */}
Save
</Button>
{onDiscard ? (
<Button variant="outline" size="sm" onClick={onDiscard} disabled={!dirty || loading}>
{/* TODO i18n: missing key rawJsonSettingsEditor.discard */}
Discard
</Button>
) : null}
<Button variant="outline" size="sm" onClick={handleCopy} disabled={!value}>
<Copy className="h-4 w-4 mr-1" />
{/* TODO i18n: missing keys rawJsonSettingsEditor.copied / rawJsonSettingsEditor.copy */}
{copied ? 'Copied' : 'Copy'}
</Button>
<Button variant="outline" size="sm" onClick={onRefresh} aria-label="Refresh raw config">
@@ -113,6 +117,7 @@ export function RawConfigEditorPanel({
)}
{readWarning && (
<div className="mx-4 mt-4 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{/* TODO i18n: missing key rawJsonSettingsEditor.readOnly */}
Read-only: {readWarning}
</div>
)}
@@ -6,6 +6,7 @@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Loader2, Save, RefreshCw } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface RawSettings {
path: string;
@@ -35,12 +36,14 @@ export function HeaderSection({
onRefresh,
onSave,
}: HeaderSectionProps) {
const { t } = useTranslation();
return (
<div className="px-6 py-4 border-b bg-background flex items-center justify-between shrink-0">
<div className="flex items-center gap-3">
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">Copilot Configuration</h2>
<h2 className="text-lg font-semibold">{t('copilotConfigForm.copilotConfiguration')}</h2>
{rawSettings && (
<Badge variant="outline" className="text-xs">
copilot.settings.json
@@ -49,7 +52,8 @@ export function HeaderSection({
</div>
{rawSettings && (
<p className="text-xs text-muted-foreground mt-0.5">
Last modified:{' '}
{/* TODO i18n: missing key copilotConfigForm.lastModified */}
Last modified: {/* TODO i18n: missing key copilotConfigForm.neverSaved */}
{rawSettings.exists ? new Date(rawSettings.mtime).toLocaleString() : 'Never saved'}
</p>
)}
@@ -67,11 +71,13 @@ export function HeaderSection({
{isUpdating || isSavingRawSettings ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
{/* TODO i18n: missing key copilotConfigForm.saving */}
Saving...
</>
) : (
<>
<Save className="w-4 h-4 mr-1" />
{/* TODO i18n: missing key copilotConfigForm.save */}
Save
</>
)}
@@ -12,6 +12,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AlertTriangle, Code2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
import { HeaderSection } from './header-section';
@@ -22,6 +23,7 @@ import { RawEditorSection } from './raw-editor-section';
import { useCopilotConfigForm } from './use-copilot-config-form';
export function CopilotConfigForm() {
const { t } = useTranslation();
const {
configLoading,
rawSettingsLoading,
@@ -83,9 +85,10 @@ export function CopilotConfigForm() {
<div className="px-6 pt-4 shrink-0">
<Alert variant="warning">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Deprecated Copilot models detected</AlertTitle>
<AlertTitle>{t('copilotConfigForm.deprecatedModels')}</AlertTitle>
<AlertDescription className="space-y-2">
<p>
{/* TODO i18n: missing key copilotConfigForm.deprecatedModelsDesc */}
Loading this page did not rewrite your files. Save the Copilot configuration to
persist these replacements.
</p>
@@ -108,12 +111,15 @@ export function CopilotConfigForm() {
<div className="px-4 pt-4 shrink-0">
<TabsList className="w-full">
<TabsTrigger value="config" className="flex-1">
{/* TODO i18n: missing key copilotConfigForm.modelConfig */}
Model Config
</TabsTrigger>
<TabsTrigger value="settings" className="flex-1">
{/* TODO i18n: missing key copilotConfigForm.settings */}
Settings
</TabsTrigger>
<TabsTrigger value="info" className="flex-1">
{/* TODO i18n: missing key copilotConfigForm.info */}
Info
</TabsTrigger>
</TabsList>
@@ -8,6 +8,7 @@ import { CopyButton } from '@/components/ui/copy-button';
import { Badge } from '@/components/ui/badge';
import { Info } from 'lucide-react';
import { TabsContent } from '@/components/ui/tabs';
import { useTranslation } from 'react-i18next';
import { UsageCommand } from './usage-command';
interface RawSettings {
@@ -22,6 +23,7 @@ interface InfoTabProps {
}
export function InfoTab({ rawSettings }: InfoTabProps) {
const { t } = useTranslation();
return (
<TabsContent value="info" className="h-full mt-0 border-0 p-0 data-[state=inactive]:hidden">
<ScrollArea className="h-full">
@@ -29,17 +31,22 @@ export function InfoTab({ rawSettings }: InfoTabProps) {
<div>
<h3 className="text-sm font-medium flex items-center gap-2 mb-3">
<Info className="w-4 h-4" />
{/* TODO i18n: missing key for 'Configuration Info' */}
Configuration Info
</h3>
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">Provider</span>
<span className="font-medium text-muted-foreground">
{t('copilotConfigForm.provider')}
</span>
<span className="font-mono">GitHub Copilot</span>
</div>
{rawSettings && (
<>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">File Path</span>
<span className="font-medium text-muted-foreground">
{t('copilotConfigForm.filePath')}
</span>
<div className="flex items-center gap-2 min-w-0">
<code className="bg-muted px-1.5 py-0.5 rounded text-xs break-all">
{rawSettings.path}
@@ -48,7 +55,9 @@ export function InfoTab({ rawSettings }: InfoTabProps) {
</div>
</div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">Status</span>
<span className="font-medium text-muted-foreground">
{t('copilotConfigForm.status')}
</span>
<Badge
variant="outline"
className={
@@ -57,6 +66,7 @@ export function InfoTab({ rawSettings }: InfoTabProps) {
: 'w-fit text-muted-foreground'
}
>
{/* TODO i18n: missing key for 'File exists' / 'Using defaults' */}
{rawSettings.exists ? 'File exists' : 'Using defaults'}
</Badge>
</div>
@@ -66,8 +76,9 @@ export function InfoTab({ rawSettings }: InfoTabProps) {
</div>
<div>
<h3 className="text-sm font-medium mb-3">Quick Usage</h3>
<h3 className="text-sm font-medium mb-3">{t('copilotConfigForm.quickUsage')}</h3>
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
{/* TODO i18n: missing keys for usage command labels */}
<UsageCommand label="Run with Copilot" command="ccs copilot" />
<UsageCommand label="Authenticate" command="ccs copilot auth" />
<UsageCommand label="Start daemon" command="ccs copilot --start" />
@@ -9,6 +9,7 @@ import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
import { Sparkles, Zap } from 'lucide-react';
import { TabsContent } from '@/components/ui/tabs';
import { useTranslation } from 'react-i18next';
import type { CopilotModel } from '@/hooks/use-copilot';
import { FREE_PRESETS, PAID_PRESETS } from './presets';
import { FlexibleModelSelector } from './model-selector';
@@ -70,6 +71,7 @@ export function ModelConfigTab({
onUpdateSonnetModel,
onUpdateHaikuModel,
}: ModelConfigTabProps) {
const { t } = useTranslation();
const mappedModelLimits = [
{ label: 'Default', id: currentModel },
{ label: 'Opus', id: opusModel || currentModel },
@@ -94,10 +96,10 @@ export function ModelConfigTab({
<div>
<h3 className="text-sm font-medium mb-2 flex items-center gap-2">
<Sparkles className="w-4 h-4" />
Presets
{t('providerEditor.presets')}
</h3>
<p className="text-xs text-muted-foreground mb-3">
Apply pre-configured model mappings
{t('copilotConfigForm.modelMapping')}
</p>
{/* Free Tier Presets */}
@@ -107,9 +109,12 @@ export function ModelConfigTab({
variant="outline"
className="text-[10px] bg-green-100 text-green-700 border-green-200"
>
{/* TODO i18n: missing key for 'Free Tier' */}
Free Tier
</Badge>
<span className="text-[10px] text-muted-foreground">No premium usage count</span>
<span className="text-[10px] text-muted-foreground">
{t('copilotConfigForm.noPremiumUsage')}
</span>
</div>
<div className="flex flex-wrap gap-2">
{FREE_PRESETS.map((preset) => (
@@ -135,9 +140,11 @@ export function ModelConfigTab({
variant="outline"
className="text-[10px] bg-blue-100 text-blue-700 border-blue-200"
>
{/* TODO i18n: missing key for 'Pro+ Required' */}
Pro+ Required
</Badge>
<span className="text-[10px] text-muted-foreground">
{/* TODO i18n: missing key for 'Uses premium request quota' */}
Uses premium request quota
</span>
</div>
@@ -163,13 +170,15 @@ export function ModelConfigTab({
{/* Model Mapping */}
<div>
<h3 className="text-sm font-medium mb-2">Model Mapping</h3>
<h3 className="text-sm font-medium mb-2">{t('copilotConfigForm.modelMapping')}</h3>
<p className="text-xs text-muted-foreground mb-4">
{/* TODO i18n: missing key for model mapping description */}
Configure which models to use for each tier
</p>
<div className="mb-4 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-900 dark:text-amber-200">
<p className="font-medium">GitHub Copilot controls prompt/context limits upstream.</p>
<p className="font-medium">{t('copilotConfigForm.githubCopilotControls')}</p>
<p className="mt-1">
{/* TODO i18n: missing key for 'CCS can switch Copilot models...' */}
CCS can switch Copilot models, but it cannot increase the provider&apos;s max prompt
or context window.
</p>
@@ -183,6 +192,7 @@ export function ModelConfigTab({
</div>
) : (
<p className="mt-2 text-[11px] font-mono">
{/* TODO i18n: missing key for 'Start the daemon to inspect...' */}
Start the daemon to inspect live model limits from GitHub Copilot metadata.
</p>
)}
@@ -5,6 +5,7 @@
import type { ModelPreset } from './types';
// TODO i18n: missing keys for preset descriptions ("Free tier - no premium usage", etc.)
// Note: ALL Claude models require paid Copilot subscription
export const FREE_PRESETS: ModelPreset[] = [
{
@@ -25,6 +26,7 @@ export const FREE_PRESETS: ModelPreset[] = [
},
];
// TODO i18n: missing keys for paid preset descriptions
export const PAID_PRESETS: ModelPreset[] = [
{
name: 'Claude Opus 4.5',
@@ -5,6 +5,7 @@
import { Suspense, lazy } from 'react';
import { Loader2, X, AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator';
// Lazy load CodeEditor
@@ -29,6 +30,7 @@ export function RawEditorSection({
onChange,
missingRequiredFields = [],
}: RawEditorSectionProps) {
const { t } = useTranslation();
const hasMissingFields = missingRequiredFields.length > 0;
return (
@@ -36,7 +38,7 @@ export function RawEditorSection({
fallback={
<div className="flex items-center justify-center h-full">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading editor...</span>
<span className="ml-2 text-muted-foreground">{t('providerEditor.loadingEditor')}</span>
</div>
}
>
@@ -44,6 +46,7 @@ export function RawEditorSection({
{!isRawJsonValid && rawJsonEdits !== null && (
<div className="mb-2 px-3 py-2 bg-destructive/10 text-destructive text-sm rounded-md flex items-center gap-2 mx-6 mt-4 shrink-0">
<X className="w-4 h-4" />
{/* TODO i18n: missing key for 'Invalid JSON syntax' */}
Invalid JSON syntax
</div>
)}
@@ -52,12 +55,14 @@ export function RawEditorSection({
<AlertTriangle className="w-4 h-4 mt-0.5 text-amber-500 shrink-0" />
<div>
<span className="font-medium text-amber-600 dark:text-amber-400">
{/* TODO i18n: missing key for 'Missing required fields:' */}
Missing required fields:
</span>{' '}
<code className="text-xs bg-muted px-1 py-0.5 rounded">
{missingRequiredFields.join(', ')}
</code>
<p className="text-xs text-muted-foreground mt-1">
{/* TODO i18n: missing key for 'These fields will use default values at runtime.' */}
These fields will use default values at runtime.
</p>
</div>
@@ -8,6 +8,7 @@ import { useCopilot } from '@/hooks/use-copilot';
import type { CopilotNormalizationWarning } from '@/hooks/use-copilot';
import { isApiConflictError } from '@/lib/api-client';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import type { ModelPreset } from './types';
/** Required env vars for Copilot settings (informational only - runtime fills defaults) */
@@ -31,6 +32,7 @@ function dedupeWarnings(
}
export function useCopilotConfigForm() {
const { t } = useTranslation();
const {
config,
configLoading,
@@ -91,7 +93,7 @@ export function useCopilotConfigForm() {
sonnetModel: preset.sonnet,
haikuModel: preset.haiku,
}));
toast.success(`Applied "${preset.name}" preset`);
toast.success(t('toasts.presetApplied', { name: preset.name }));
};
// Raw JSON content
@@ -191,15 +193,15 @@ export function useCopilotConfigForm() {
}
if (uniqueWarnings.length > 0) {
toast.warning('Copilot configuration saved with model adjustments', {
toast.warning(t('toasts.settingsSavedWithAdjustments'), {
description: descriptions.join(' '),
});
} else if (descriptions.length > 0) {
toast.success('Copilot configuration saved', {
toast.success(t('toasts.settingsSaved'), {
description: descriptions.join(' '),
});
} else {
toast.success('Copilot configuration saved');
toast.success(t('toasts.settingsSaved'));
}
// Clear local state
@@ -209,7 +211,7 @@ export function useCopilotConfigForm() {
if (isApiConflictError(error)) {
setConflictDialog(true);
} else {
toast.error('Failed to save settings');
toast.error(t('toasts.failedSaveSettings'));
}
}
};
@@ -25,6 +25,7 @@ export function getPlanBadgeStyle(plan?: CopilotPlanTier): string {
/** Get multiplier display */
export function getMultiplierDisplay(multiplier?: number): string | null {
if (multiplier === undefined || multiplier === null) return null;
// TODO i18n: missing key for 'Free' multiplier label
if (multiplier === 0) return 'Free';
if (multiplier < 1) return `${multiplier}x`;
if (multiplier === 1) return '1x';
@@ -9,8 +9,10 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useCopilot } from '@/hooks/use-copilot';
import { CheckCircle2, XCircle, AlertTriangle, Loader2, Download } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export function CopilotStatusCard() {
const { t } = useTranslation();
const {
status,
statusLoading,
@@ -28,7 +30,7 @@ export function CopilotStatusCard() {
return (
<Card>
<CardHeader>
<CardTitle>GitHub Copilot Status</CardTitle>
<CardTitle>{t('copilotPage.status')}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
@@ -41,10 +43,10 @@ export function CopilotStatusCard() {
return (
<Card>
<CardHeader>
<CardTitle>GitHub Copilot Status</CardTitle>
<CardTitle>{t('copilotPage.status')}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">Failed to load status</p>
<p className="text-muted-foreground">{t('copilotConfigForm.failedLoadStatus')}</p>
</CardContent>
</Card>
);
@@ -54,21 +56,21 @@ export function CopilotStatusCard() {
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
GitHub Copilot Status
{t('copilotPage.status')}
{status.enabled ? (
<Badge variant="default">Enabled</Badge>
<Badge variant="default">{t('copilotPage.enabled')}</Badge>
) : (
<Badge variant="secondary">Disabled</Badge>
<Badge variant="secondary">{t('copilotPage.disabled')}</Badge>
)}
</CardTitle>
<CardDescription>Use your GitHub Copilot subscription with Claude Code</CardDescription>
<CardDescription>{t('copilotConfigForm.useWithClaudeCode')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Warning Banner */}
<div className="flex items-start gap-2 rounded-md border border-yellow-500/20 bg-yellow-500/10 p-3">
<AlertTriangle className="h-5 w-5 text-yellow-500 shrink-0 mt-0.5" />
<p className="text-sm text-yellow-700 dark:text-yellow-300">
This uses a reverse-engineered API. Excessive usage may trigger GitHub abuse detection.
{t('copilotPage.unofficialItem2')}
</p>
</div>
@@ -82,7 +84,7 @@ export function CopilotStatusCard() {
<XCircle className="h-5 w-5 text-red-500" />
)}
<span className="text-sm">
copilot-api {status.installed ? `v${status.version}` : 'Not Installed'}
copilot-api {status.installed ? `v${status.version}` : t('copilotPage.missing')}
</span>
</div>
@@ -94,7 +96,7 @@ export function CopilotStatusCard() {
<XCircle className="h-5 w-5 text-red-500" />
)}
<span className="text-sm">
{status.authenticated ? 'Authenticated' : 'Not Authenticated'}
{status.authenticated ? t('copilotPage.connected') : t('copilotPage.notConnected')}
</span>
</div>
@@ -105,15 +107,25 @@ export function CopilotStatusCard() {
) : (
<XCircle className="h-5 w-5 text-muted-foreground" />
)}
<span className="text-sm">Daemon {status.daemon_running ? 'Running' : 'Stopped'}</span>
<span className="text-sm">
{t('copilotPage.daemon')}{' '}
{status.daemon_running ? t('copilotPage.running') : t('copilotPage.stopped')}
</span>
</div>
</div>
{/* Quick Info */}
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
<span>Port: {status.port}</span>
<span>Model: {status.model}</span>
<span>Auto-start: {status.auto_start ? 'Yes' : 'No'}</span>
<span>
{t('copilotPage.port')}: {status.port}
</span>
<span>
{t('providerEditor.modelMapping')}: {status.model}
</span>
<span>
{/* TODO i18n: missing key for 'Auto-start' */}
Auto-start: {status.auto_start ? t('copilotPage.yes') : t('copilotPage.no')}
</span>
</div>
{/* Actions */}
@@ -123,12 +135,12 @@ export function CopilotStatusCard() {
{isInstalling ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Installing...
{t('copilotPage.installing')}
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
Install copilot-api
{t('copilotPage.installCopilotApi')}
</>
)}
</Button>
@@ -143,10 +155,10 @@ export function CopilotStatusCard() {
{isAuthenticating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Authenticating...
{t('copilotPage.authenticating')}
</>
) : (
'Authenticate with GitHub'
t('copilotPage.authenticate')
)}
</Button>
)}
@@ -161,10 +173,10 @@ export function CopilotStatusCard() {
{isStoppingDaemon ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Stopping...
{t('copilotPage.stopping')}
</>
) : (
'Stop Daemon'
t('copilotPage.stop')
)}
</Button>
) : (
@@ -177,10 +189,10 @@ export function CopilotStatusCard() {
{isStartingDaemon ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Starting...
{t('copilotPage.starting')}
</>
) : (
'Start Daemon'
t('copilotPage.start')
)}
</Button>
)}
+3 -1
View File
@@ -2,6 +2,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { CheckCircle, AlertTriangle, XCircle, Wrench } from 'lucide-react';
import { useFixHealth } from '@/hooks/use-health';
import { useTranslation } from 'react-i18next';
interface HealthCheck {
id: string;
@@ -35,6 +36,7 @@ const statusConfig = {
export function HealthCard({ check }: { check: HealthCheck }) {
const fixMutation = useFixHealth();
const { t } = useTranslation();
const config = statusConfig[check.status];
const Icon = config.icon;
@@ -54,7 +56,7 @@ export function HealthCard({ check }: { check: HealthCheck }) {
disabled={fixMutation.isPending}
>
<Wrench className="w-3 h-3 mr-1" />
Fix
{t('health.fix')}
</Button>
)}
</div>
+5 -2
View File
@@ -1,24 +1,27 @@
import { Badge } from '@/components/ui/badge';
import { CcsLogo } from '@/components/shared/ccs-logo';
import { useTranslation } from 'react-i18next';
interface HeroSectionProps {
version?: string;
}
export function HeroSection({ version }: HeroSectionProps) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-4">
<CcsLogo size="lg" showText={false} />
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">CCS Config</h1>
<h1 className="text-2xl font-bold">{t('heroSection.title')}</h1>
{version && (
<Badge variant="outline" className="font-mono text-xs">
v{version}
</Badge>
)}
</div>
<p className="text-muted-foreground text-sm mt-1">Claude Code Switch Dashboard</p>
<p className="text-muted-foreground text-sm mt-1">{t('heroSection.subtitle')}</p>
</div>
</div>
);
+6 -4
View File
@@ -1,26 +1,28 @@
import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button';
import { FileTextIcon, SettingsIcon, GithubIcon, ExternalLinkIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export function HubFooter() {
const { t } = useTranslation();
const currentYear = new Date().getFullYear();
const footerLinks = [
{
icon: <FileTextIcon className="w-4 h-4" />,
label: 'Logs',
label: t('hubFooter.logs'),
href: '#logs',
onClick: () => console.log('Navigate to Logs'),
},
{
icon: <SettingsIcon className="w-4 h-4" />,
label: 'Settings',
label: t('hubFooter.settings'),
href: '#settings',
onClick: () => console.log('Navigate to Settings'),
},
{
icon: <GithubIcon className="w-4 h-4" />,
label: 'GitHub',
label: t('hubFooter.github'),
href: 'https://github.com/kaitranntt/ccs',
external: true,
},
@@ -32,7 +34,7 @@ export function HubFooter() {
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<span>CCS v0.0.0</span>
<Separator orientation="vertical" className="h-4" />
<span>© {currentYear} kaitranntt</span>
<span>{t('hubFooter.copyright', { year: currentYear })}</span>
</div>
<div className="ml-auto flex items-center space-x-2">
+3 -1
View File
@@ -1,9 +1,11 @@
import { Moon, Sun } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useTheme } from '@/hooks/use-theme';
import { useTranslation } from 'react-i18next';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
return (
<Button
@@ -13,7 +15,7 @@ export function ThemeToggle() {
>
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
<span className="sr-only">{t('themeToggle.srLabel')}</span>
</Button>
);
}
@@ -13,6 +13,7 @@ import {
import { Switch } from '@/components/ui/switch';
import type { LogsConfig, UpdateLogsConfigPayload } from '@/lib/api-client';
import { cn } from '@/lib/utils';
// TODO i18n: import { useTranslation } from 'react-i18next'; when keys are ready
function parseInteger(value: string, fallback: number) {
const parsed = Number.parseInt(value, 10);
@@ -32,6 +33,8 @@ export function LogsConfigCard({
onSave: (payload: UpdateLogsConfigPayload) => void;
isPending: boolean;
}) {
// TODO i18n: uncomment when keys for Commit Changes, Rollback Draft, etc. are added
// const { t } = useTranslation();
const [draft, setDraft] = useState(config);
useEffect(() => {
@@ -247,6 +250,7 @@ export function LogsConfigCard({
className="h-10 w-full gap-2 rounded-xl bg-primary text-[11px] font-semibold uppercase tracking-[0.14em] shadow-lg shadow-primary/20 transition-all hover:scale-[1.02] active:scale-[0.98]"
>
<Save className="h-3.5 w-3.5" />
{/* TODO i18n: missing key for "Commit Changes" */}
Commit Changes
</Button>
<Button
@@ -256,6 +260,7 @@ export function LogsConfigCard({
className="h-9 gap-2 text-[10px] font-medium uppercase tracking-[0.12em] text-foreground/45 hover:text-foreground"
>
<RotateCcw className="h-3 w-3" />
{/* TODO i18n: missing key for "Rollback Draft" */}
Rollback Draft
</Button>
</div>
+6 -1
View File
@@ -15,6 +15,7 @@ import type { LogsEntry } from '@/lib/api-client';
import { cn } from '@/lib/utils';
import { LogLevelBadge } from './log-level-badge';
import { formatJson } from './utils';
import { useTranslation } from 'react-i18next';
function MetaRow({
label,
@@ -52,6 +53,8 @@ export function LogsDetailPanel({
entry: LogsEntry | null;
sourceLabel?: string;
}) {
const { t } = useTranslation();
if (!entry) {
return (
<div className="flex h-full flex-col items-center justify-center p-8 text-center animate-in fade-in duration-1000">
@@ -63,6 +66,7 @@ export function LogsDetailPanel({
</div>
<div className="max-w-xs space-y-3">
<h3 className="text-[15px] font-semibold uppercase tracking-[0.14em] text-foreground/65">
{/* TODO i18n: missing key for "Inspector Standby" */}
Inspector Standby
</h3>
<p className="text-[13px] leading-relaxed text-muted-foreground/55 font-medium">
@@ -134,13 +138,14 @@ export function LogsDetailPanel({
className="min-w-0 gap-2 rounded-lg px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.1em] transition-all data-[state=active]:bg-background data-[state=active]:text-primary data-[state=active]:shadow-sm"
>
<Info className="h-3.5 w-3.5" />
Details
{t('logsDetailPanel.details')}
</TabsTrigger>
<TabsTrigger
value="raw"
className="min-w-0 gap-2 rounded-lg px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.1em] transition-all data-[state=active]:bg-background data-[state=active]:text-primary data-[state=active]:shadow-sm"
>
<FileJson className="h-3.5 w-3.5" />
{/* TODO i18n: missing key for "Raw Context" */}
Raw Context
</TabsTrigger>
</TabsList>
+20 -7
View File
@@ -3,6 +3,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import type { LogsEntry } from '@/lib/api-client';
import { cn } from '@/lib/utils';
import { LogLevelBadge } from './log-level-badge';
import { useTranslation } from 'react-i18next';
export function LogsEntryList({
entries,
@@ -19,6 +20,8 @@ export function LogsEntryList({
isLoading: boolean;
isFetching: boolean;
}) {
const { t } = useTranslation();
return (
<div className="flex min-h-0 flex-1 flex-col bg-background/50 backdrop-blur-sm">
<div className="flex shrink-0 items-center justify-between border-b border-border bg-card/40 px-6 py-3 shadow-sm">
@@ -53,13 +56,23 @@ export function LogsEntryList({
</div>
<div className="flex items-center gap-0 border-b border-border bg-muted/30 px-0 py-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-foreground/45">
<div className="w-[6.5rem] shrink-0 px-6">Time</div>
<div className="w-14 shrink-0 border-l border-border/10 px-2 text-center">Lvl</div>
<div className="w-[15rem] shrink-0 border-l border-border/10 px-4">Source</div>
<div className="flex-1 border-l border-border/10 px-4">Message</div>
<div className="w-[5.5rem] shrink-0 border-l border-border/10 px-2 text-center">Proc</div>
<div className="w-[5.5rem] shrink-0 border-l border-border/10 px-3 text-center">Run</div>
<div className="w-11 shrink-0 border-l border-border/10 px-2 text-center">Open</div>
<div className="w-[6.5rem] shrink-0 px-6">{t('logsConfig.time')}</div>
<div className="w-14 shrink-0 border-l border-border/10 px-2 text-center">
{t('logsConfig.level')}
</div>
<div className="w-[15rem] shrink-0 border-l border-border/10 px-4">
{t('logsConfig.source')}
</div>
<div className="flex-1 border-l border-border/10 px-4">{t('logsConfig.message')}</div>
<div className="w-[5.5rem] shrink-0 border-l border-border/10 px-2 text-center">
{t('logsConfig.proc')}
</div>
<div className="w-[5.5rem] shrink-0 border-l border-border/10 px-3 text-center">
{t('logsConfig.run')}
</div>
<div className="w-11 shrink-0 border-l border-border/10 px-2 text-center">
{t('logsConfig.open')}
</div>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
+3 -1
View File
@@ -6,6 +6,7 @@ import type { LogsSource } from '@/lib/api-client';
import { cn } from '@/lib/utils';
import type { LogsLevelFilter, LogsSourceFilter } from '@/hooks/use-logs';
import { getLogLevelOptions } from '@/hooks/use-logs';
import { useTranslation } from 'react-i18next';
export function LogsFilters({
sources,
@@ -32,6 +33,7 @@ export function LogsFilters({
onRefresh: () => void;
isRefreshing: boolean;
}) {
const { t } = useTranslation();
const levels = getLogLevelOptions();
const limits = [50, 100, 150, 250];
@@ -188,7 +190,7 @@ export function LogsFilters({
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-1000" />
<div className="flex items-center gap-2">
<RefreshCw className={cn('h-3.5 w-3.5', isRefreshing && 'animate-spin')} />
<span>Refresh Entries</span>
<span>{t('logsConfig.refreshEntries')}</span>
</div>
</Button>
</div>
@@ -2,6 +2,7 @@ import { Activity, Archive, Database, RadioTower } from 'lucide-react';
import type { LogsConfig, LogsEntry, LogsSource } from '@/lib/api-client';
import { cn } from '@/lib/utils';
import { formatCount, formatLogTimestamp, formatRelativeLogTime } from './utils';
// TODO i18n: import { useTranslation } from 'react-i18next'; when keys are ready
function MetricCard({
label,
@@ -43,12 +44,15 @@ export function LogsOverviewCards({
entries: LogsEntry[];
latestTimestamp: string | null;
}) {
// TODO i18n: uncomment when keys for Pipeline/Retention/Coverage/Visible Entries are added
// const { t } = useTranslation();
const nativeSources = sources.filter((source) => source.kind === 'native').length;
const legacySources = sources.length - nativeSources;
const errorCount = entries.filter((entry) => entry.level === 'error').length;
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{/* TODO i18n: missing keys for Pipeline/Retention/Coverage/Visible Entries labels and detail strings */}
<MetricCard
label="Pipeline"
value={config.enabled ? 'Enabled' : 'Disabled'}
@@ -1,9 +1,12 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { useTranslation } from 'react-i18next';
export function LogsPageSkeleton() {
const { t } = useTranslation();
return (
<div className="space-y-6" aria-label="Loading logs workspace">
<div className="space-y-6" aria-label={t('logsPageSkeleton.loadingLogs')}>
<Card className="gap-4">
<CardHeader className="space-y-3">
<Skeleton className="h-4 w-28" />
+5
View File
@@ -1,4 +1,9 @@
import type { LogsLevel } from '@/lib/api-client';
// NOTE: This module contains utility functions that are not directly i18n-aware.
// String literals here ("No activity yet", "Error", etc.) are used as fallbacks
// and defaults in non-component contexts. Components consuming these values
// should wrap them with t() calls when rendering.
// TODO i18n: Consider making formatRelativeLogTime/formatLogTimestamp i18n-aware
export function formatLogTimestamp(timestamp: string | null | undefined) {
if (!timestamp) {
@@ -3,6 +3,7 @@
*/
import { CheckCircle2, XCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface InlineStatsBadgeProps {
success: number;
@@ -10,8 +11,13 @@ interface InlineStatsBadgeProps {
}
export function InlineStatsBadge({ success, failure }: InlineStatsBadgeProps) {
const { t } = useTranslation();
if (success === 0 && failure === 0) {
return <span className="text-[9px] text-muted-foreground/50 font-mono">no activity</span>;
return (
<span className="text-[9px] text-muted-foreground/50 font-mono">
{t('authMonitorLive.noActivity')}
</span>
);
}
return (
@@ -12,6 +12,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import type { ProviderStats } from '../types';
import { getSuccessRate } from '../utils';
import { InlineStatsBadge } from './inline-stats-badge';
import { useTranslation } from 'react-i18next';
interface ProviderCardProps {
stats: ProviderStats;
@@ -30,6 +31,7 @@ export function ProviderCard({
onMouseEnter,
onMouseLeave,
}: ProviderCardProps) {
const { t } = useTranslation();
const successRate = getSuccessRate(stats.successCount, stats.failureCount);
const providerColor = PROVIDER_COLORS[stats.provider.toLowerCase()] || '#6b7280';
@@ -59,6 +61,7 @@ export function ProviderCard({
{stats.displayName}
</h3>
<p className="text-[10px] text-muted-foreground">
{/* TODO i18n: missing key for account count */}
{stats.accountCount} account{stats.accountCount !== 1 ? 's' : ''}
</p>
</div>
@@ -73,11 +76,11 @@ export function ProviderCard({
<div className="space-y-2">
{/* Inline success/failure stats - immediately visible */}
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">Stats</span>
<span className="text-muted-foreground">{t('authMonitorLive.stats')}</span>
<InlineStatsBadge success={stats.successCount} failure={stats.failureCount} />
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Success Rate</span>
<span className="text-muted-foreground">{t('authMonitorLive.successRate')}</span>
<span
className="font-mono font-semibold"
style={{
@@ -125,11 +128,11 @@ export function ProviderCard({
<TooltipTrigger asChild>
<AlertTriangle
className="absolute -top-1 -right-1 w-2.5 h-2.5 text-amber-500"
aria-label="Missing Project ID"
aria-label={t('providerCard.missingProjectIdAria')}
/>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
Missing Project ID - re-add account to fix
{/* TODO i18n: missing key */}Missing Project ID - re-add account to fix
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -146,17 +146,27 @@ export function AuthMonitor() {
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-gradient-to-r from-emerald-500/5 via-transparent to-transparent dark:from-emerald-500/10">
<div className="flex items-center gap-2">
<LivePulse />
<span className="text-xs font-semibold tracking-tight text-foreground">LIVE</span>
<span className="text-[10px] text-muted-foreground">Account Monitor</span>
<span className="text-xs font-semibold tracking-tight text-foreground">
{t('authMonitorLive.live')}
</span>
<span className="text-[10px] text-muted-foreground">
{t('authMonitorLive.accountMonitor')}
</span>
</div>
<div className="flex items-center gap-4 text-[10px] text-muted-foreground">
<div className="flex items-center gap-1.5">
<Radio className="w-3 h-3 animate-pulse" />
<span>Updated {timeSinceUpdate || 'now'}</span>
<span>
{timeSinceUpdate
? t('authMonitorLive.updated', { time: timeSinceUpdate })
: t('authMonitorLive.updatedNow')}
</span>
</div>
<span className="text-muted-foreground/50">|</span>
<span>{t('authMonitor.accountsCount', { count: displayedAccountCount })}</span>
<span className="font-mono">{displayedTotalRequests.toLocaleString()} req</span>
<span className="font-mono">
{displayedTotalRequests.toLocaleString()} {t('authMonitorLive.requestsLabel')}
</span>
</div>
</div>
@@ -210,6 +220,7 @@ export function AuthMonitor() {
/>
) : (
<div className="p-6">
{/* TODO i18n: missing key for "Request Distribution by Provider" */}
<div className="text-[10px] text-muted-foreground uppercase tracking-widest mb-4">
Request Distribution by Provider
</div>
@@ -133,8 +133,12 @@ export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) {
<div className="flex-1 overflow-hidden bg-card/30">
{activeTab === 'overview' && <OverviewTab parsed={parsed} />}
{activeTab === 'headers' && <HeadersTab headers={parsed.requestHeaders} />}
{activeTab === 'request' && <BodyTab content={parsed.requestBody} label="Request" />}
{activeTab === 'response' && <BodyTab content={parsed.responseBody} label="Response" />}
{activeTab === 'request' && (
<BodyTab content={parsed.requestBody} label={t('errorLogs.tabRequest')} />
)}
{activeTab === 'response' && (
<BodyTab content={parsed.responseBody} label={t('errorLogs.tabResponse')} />
)}
{activeTab === 'raw' && <RawTab content={content} />}
</div>
</div>
@@ -144,9 +144,10 @@ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
/** Headers tab content */
export function HeadersTab({ headers }: { headers: Record<string, string> }) {
const { t } = useTranslation();
const entries = Object.entries(headers);
if (entries.length === 0) {
return <div className="p-4 text-xs text-muted-foreground">No headers available</div>;
return <div className="p-4 text-xs text-muted-foreground">{t('errorLogs.noHeaders')}</div>;
}
return (
@@ -20,6 +20,7 @@ import { isOpenRouterProfile, extractTierMapping, applyTierMapping } from './uti
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import i18n from '@/lib/i18n';
import { useTranslation } from 'react-i18next';
import type { Settings, SettingsResponse } from './types';
import type { CliTarget } from '@/lib/api-client';
@@ -50,6 +51,7 @@ export function FriendlyUISection({
onAddEnvVar,
onEnvBulkChange,
}: FriendlyUISectionProps) {
const { t } = useTranslation();
const isOpenRouter = isOpenRouterProfile(currentSettings);
const settingsEnv = currentSettings?.env;
@@ -124,11 +126,13 @@ export function FriendlyUISection({
<Tabs defaultValue="env" className="h-full w-full min-w-0 flex flex-col">
<div className="px-4 pt-4 shrink-0">
<TabsList className="w-full">
{/* TODO i18n: missing key for "Configuration" tab label */}
<TabsTrigger value="env" className="flex-1">
{isOpenRouter ? 'Configuration' : 'Environment Variables'}
{isOpenRouter ? 'Configuration' : t('settingsDialog.envTab')}
</TabsTrigger>
{/* TODO i18n: missing key for "Info & Usage" tab label */}
<TabsTrigger value="info" className="flex-1">
Info & Usage
Info &amp; Usage
</TabsTrigger>
</TabsList>
</div>
@@ -145,6 +149,7 @@ export function FriendlyUISection({
<div className="h-full overflow-y-auto overflow-x-hidden p-4 space-y-6">
{/* Model Selection - Primary Focus */}
<div className="space-y-3">
{/* TODO i18n: missing key for "Model Selection" label */}
<Label className="text-sm font-medium">Model Selection</Label>
<OpenRouterModelPicker
value={currentEnv.ANTHROPIC_MODEL}
@@ -162,7 +167,7 @@ export function FriendlyUISection({
{/* API Key */}
<div className="space-y-2">
<Label className="text-sm font-medium">API Key</Label>
<Label className="text-sm font-medium">{t('profileDialog.apiKey')}</Label>
<MaskedInput
value={currentEnv.ANTHROPIC_AUTH_TOKEN || ''}
onChange={(e) => onEnvValueChange('ANTHROPIC_AUTH_TOKEN', e.target.value)}
@@ -193,6 +198,7 @@ export function FriendlyUISection({
)}
/>
<Settings2 className="h-4 w-4" />
{/* TODO i18n: missing key for "Additional Variables" label */}
<span>Additional Variables</span>
<span className="text-xs font-normal opacity-70">
({unmanagedEnvVars.length})
@@ -220,18 +226,18 @@ export function FriendlyUISection({
{/* Fixed Add Variable Input at Bottom */}
<div className="p-4 border-t bg-background shrink-0">
<Label className="text-xs font-medium text-muted-foreground">
Add Environment Variable
{t('envEditor.addVariable')}
</Label>
<div className="flex gap-2 mt-2">
<Input
placeholder="VARIABLE_NAME"
placeholder={t('envEditor.keyPlaceholder')}
value={newEnvKey}
onChange={(e) => onNewEnvKeyChange(e.target.value.toUpperCase())}
className="font-mono text-sm h-8 w-2/5"
onKeyDown={(e) => e.key === 'Enter' && newEnvKey.trim() && onAddEnvVar()}
/>
<Input
placeholder="value"
placeholder={t('envEditor.valuePlaceholder')}
value={newEnvValue}
onChange={(e) => onNewEnvValueChange(e.target.value)}
className="font-mono text-sm h-8 flex-1"
@@ -13,6 +13,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Save, Loader2, Trash2, RefreshCw } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { OpenRouterBadge } from '@/components/profiles/openrouter-badge';
import { isOpenRouterProfile } from './utils';
import type { Settings } from './types';
@@ -49,6 +50,7 @@ export function HeaderSection({
onDelete,
onSave,
}: HeaderSectionProps) {
const { t } = useTranslation();
const isMutating = isSaving || isTargetSaving;
const disableHeaderActions = isLoading || isMutating;
@@ -66,11 +68,11 @@ export function HeaderSection({
</div>
{data && (
<p className="text-xs text-muted-foreground mt-0.5">
Last modified: {new Date(data.mtime).toLocaleString()}
{t('profileEditor.lastModified')}: {new Date(data.mtime).toLocaleString()}
</p>
)}
<div className="mt-2 flex items-center gap-2">
<span className="text-xs text-muted-foreground">Default target:</span>
<span className="text-xs text-muted-foreground">{t('profileEditor.defaultTarget')}:</span>
<Select
value={target}
onValueChange={(value) => {
@@ -83,9 +85,9 @@ export function HeaderSection({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="claude">Claude Code</SelectItem>
<SelectItem value="droid">Factory Droid</SelectItem>
<SelectItem value="codex">Codex CLI</SelectItem>
<SelectItem value="claude">{t('profileEditor.targetClaude')}</SelectItem>
<SelectItem value="droid">{t('profileEditor.targetDroid')}</SelectItem>
<SelectItem value="codex">{t('profileEditor.targetCodex')}</SelectItem>
</SelectContent>
</Select>
{isTargetSaving && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
@@ -104,12 +106,12 @@ export function HeaderSection({
{isSaving ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
Saving...
{t('profileEditor.saving')}
</>
) : (
<>
<Save className="w-4 h-4 mr-1" />
Save
{t('settingsAuth.save')}
</>
)}
</Button>
@@ -1,5 +1,6 @@
import { ArrowUpRight, Image as ImageIcon } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
@@ -15,33 +16,38 @@ interface ImageAnalysisStatusSectionProps {
onToggleNativeRead?: (enabled: boolean) => void;
}
const TARGET_LABELS: Record<CliTarget, string> = {
claude: 'Claude Code',
droid: 'Factory Droid',
codex: 'Codex CLI',
};
function getPreviewLabel(
t: (key: string) => string,
source: 'saved' | 'editor',
previewState: ImageAnalysisStatusSectionProps['previewState']
) {
if (previewState === 'refreshing') return 'Refreshing preview';
if (previewState === 'invalid') return 'Saved status';
return source === 'editor' ? 'Live preview' : 'Saved status';
if (previewState === 'refreshing') return t('imageAnalysisStatus.refreshingPreview');
if (previewState === 'invalid') return t('imageAnalysisStatus.savedStatus');
return source === 'editor'
? t('imageAnalysisStatus.livePreview')
: t('imageAnalysisStatus.savedStatus');
}
function getHeaderLabel(status: ImageAnalysisStatus, target: CliTarget): string {
if (status.status === 'disabled') return 'Disabled globally';
if (target !== 'claude') return `${TARGET_LABELS[target]} bypasses the hook`;
if (status.nativeReadPreference) return 'Native image reading';
if (status.status === 'hook-missing') return 'Setup needed';
if (status.authReadiness === 'missing') return 'Needs auth';
if (status.proxyReadiness === 'unavailable') return 'Needs proxy';
if (status.effectiveRuntimeMode === 'native-read') return 'Native fallback';
return 'Transformer ready';
function getHeaderLabel(
t: (key: string, options?: Record<string, unknown>) => string,
status: ImageAnalysisStatus,
target: CliTarget
): string {
if (status.status === 'disabled') return t('imageAnalysisStatus.disabledGlobally');
if (target !== 'claude')
return t('imageAnalysisStatus.targetBypassesHook', {
target: t(`imageAnalysisStatus.targetLabel.${target}`),
});
if (status.nativeReadPreference) return t('imageAnalysisStatus.nativeImageReading');
if (status.status === 'hook-missing') return t('imageAnalysisStatus.setupNeeded');
if (status.authReadiness === 'missing') return t('imageAnalysisStatus.needsAuth');
if (status.proxyReadiness === 'unavailable') return t('imageAnalysisStatus.needsProxy');
if (status.effectiveRuntimeMode === 'native-read') return t('imageAnalysisStatus.nativeFallback');
return t('imageAnalysisStatus.transformerReady');
}
function getHeaderBadge(
t: (key: string) => string,
status: ImageAnalysisStatus,
target: CliTarget
): {
@@ -50,75 +56,93 @@ function getHeaderBadge(
} {
if (status.status === 'disabled') {
return {
label: 'Disabled',
label: t('imageAnalysisStatus.badgeDisabled'),
className: 'border-border/80 bg-background/85 text-muted-foreground',
};
}
if (target !== 'claude') {
return {
label: 'Bypassed',
label: t('imageAnalysisStatus.badgeBypassed'),
className: 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200',
};
}
if (status.nativeReadPreference) {
return {
label: 'Native',
label: t('imageAnalysisStatus.badgeNative'),
className: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200',
};
}
if (status.status === 'hook-missing' || status.authReadiness === 'missing') {
return {
label: status.status === 'hook-missing' ? 'Setup' : 'Auth',
label:
status.status === 'hook-missing'
? t('imageAnalysisStatus.badgeSetup')
: t('imageAnalysisStatus.badgeAuth'),
className: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200',
};
}
if (status.proxyReadiness === 'unavailable') {
return {
label: 'Proxy',
label: t('imageAnalysisStatus.badgeProxy'),
className: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200',
};
}
return {
label: 'Ready',
label: t('imageAnalysisStatus.badgeReady'),
className: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200',
};
}
function getToggleSummary(status: ImageAnalysisStatus, target: CliTarget): string {
function getToggleSummary(
t: (key: string, options?: Record<string, unknown>) => string,
status: ImageAnalysisStatus,
target: CliTarget
): string {
if (status.nativeReadPreference) {
if (status.profileModel && status.nativeImageCapable) {
return `${status.profileModel} looks image-ready. CCS will bypass the transformer here.`;
return t('imageAnalysisStatus.toggleSummaryNativeCapable', { model: status.profileModel });
}
if (status.profileModel) {
return `CCS will prefer native reading for ${status.profileModel}.`;
return t('imageAnalysisStatus.toggleSummaryNativeModel', { model: status.profileModel });
}
return 'CCS will prefer native image reading for this profile.';
return t('imageAnalysisStatus.toggleSummaryNativeDefault');
}
if (!status.backendDisplayName && target === 'claude') {
return 'This profile currently stays on native file access.';
return t('imageAnalysisStatus.toggleSummaryNativeFileAccess');
}
if (!status.backendDisplayName) {
return `Saved Claude-side image routing is inactive while ${TARGET_LABELS[target]} is selected.`;
return t('imageAnalysisStatus.toggleSummaryInactiveTarget', {
target: t(`imageAnalysisStatus.targetLabel.${target}`),
});
}
const modelSuffix = status.model ? ` · ${status.model}` : '';
return `Transformer route: ${status.backendDisplayName}${modelSuffix}.`;
return t('imageAnalysisStatus.toggleSummaryTransformerRoute', {
backend: status.backendDisplayName,
modelSuffix,
});
}
function getExceptionalNote(status: ImageAnalysisStatus, target: CliTarget): string | null {
function getExceptionalNote(
t: (key: string, options?: Record<string, unknown>) => string,
status: ImageAnalysisStatus,
target: CliTarget
): string | null {
if (status.status === 'disabled') {
return 'Image is disabled globally in CCS settings.';
return t('imageAnalysisStatus.noteDisabledGlobally');
}
if (target !== 'claude') {
return `Current target ${TARGET_LABELS[target]} bypasses the Claude Read hook.`;
return t('imageAnalysisStatus.noteTargetBypassesHook', {
target: t(`imageAnalysisStatus.targetLabel.${target}`),
});
}
if (status.nativeReadPreference) {
return status.nativeImageCapable === true ? null : status.nativeImageReason;
}
if (status.status === 'hook-missing') {
return 'Persist the profile hook before transformer routing can run here.';
return t('imageAnalysisStatus.notePersistHook');
}
if (status.authReadiness === 'missing') {
return status.authReason;
@@ -137,6 +161,8 @@ export function ImageAnalysisStatusSection({
nativeReadPreferenceOverride,
onToggleNativeRead,
}: ImageAnalysisStatusSectionProps) {
const { t } = useTranslation();
if (!status) {
return (
<div className="rounded-2xl border bg-muted/20 px-4 py-3" aria-live="polite">
@@ -148,12 +174,12 @@ export function ImageAnalysisStatusSection({
const nativeReadChecked = nativeReadPreferenceOverride ?? status.nativeReadPreference;
const effectiveStatus = { ...status, nativeReadPreference: nativeReadChecked };
const headerBadge = getHeaderBadge(effectiveStatus, target);
const note = getExceptionalNote(effectiveStatus, target);
const headerBadge = getHeaderBadge(t, effectiveStatus, target);
const note = getExceptionalNote(t, effectiveStatus, target);
const capabilityLabel = status.nativeImageCapable
? 'Verified'
? t('imageAnalysisStatus.capabilityVerified')
: status.profileModel
? 'Unknown'
? t('imageAnalysisStatus.capabilityUnknown')
: null;
return (
@@ -166,13 +192,14 @@ export function ImageAnalysisStatusSection({
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">Image</h3>
<h3 className="text-sm font-semibold">{t('imageAnalysisStatus.sectionTitle')}</h3>
<Badge className={cn('h-5 border px-1.5 text-[10px]', headerBadge.className)}>
{headerBadge.label}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{getPreviewLabel(source, previewState)} · {getHeaderLabel(effectiveStatus, target)}
{getPreviewLabel(t, source, previewState)} ·{' '}
{getHeaderLabel(t, effectiveStatus, target)}
</p>
</div>
</div>
@@ -180,7 +207,7 @@ export function ImageAnalysisStatusSection({
<Button size="sm" variant="outline" className="h-8 shrink-0" asChild>
<Link to="/settings?tab=image">
Open Settings
{t('imageAnalysisStatus.openSettings')}
<ArrowUpRight className="ml-1 h-3.5 w-3.5" />
</Link>
</Button>
@@ -190,7 +217,9 @@ export function ImageAnalysisStatusSection({
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-medium text-foreground">Use native image reading</div>
<div className="text-sm font-medium text-foreground">
{t('imageAnalysisStatus.useNativeImageReading')}
</div>
{capabilityLabel && (
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
{capabilityLabel}
@@ -198,7 +227,7 @@ export function ImageAnalysisStatusSection({
)}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{getToggleSummary(effectiveStatus, target)}
{getToggleSummary(t, effectiveStatus, target)}
</p>
</div>
@@ -206,7 +235,7 @@ export function ImageAnalysisStatusSection({
checked={nativeReadChecked}
onCheckedChange={onToggleNativeRead}
disabled={!onToggleNativeRead}
aria-label="Use native image reading"
aria-label={t('imageAnalysisStatus.useNativeImageReading')}
/>
</div>
</div>
+9 -7
View File
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
import { Loader2, Code2, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { HeaderSection } from './header-section';
import { FriendlyUISection } from './friendly-ui-section';
@@ -24,6 +25,7 @@ export function ProfileEditor({
onDelete,
onHasChangesUpdate,
}: ProfileEditorProps) {
const { t } = useTranslation();
const [localEdits, setLocalEdits] = useState<Record<string, string>>({});
const [conflictDialog, setConflictDialog] = useState(false);
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
@@ -299,15 +301,15 @@ export function ProfileEditor({
{isLoading ? (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
<span className="ml-3 text-muted-foreground">Loading settings...</span>
<span className="ml-3 text-muted-foreground">{t('settingsDialog.loadingSettings')}</span>
</div>
) : isError ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center space-y-3">
<p className="text-sm text-muted-foreground">Failed to load settings.</p>
<p className="text-sm text-muted-foreground">{t('settingsPage.failedLoad')}</p>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="w-4 h-4 mr-1" />
Retry
{t('apiProfiles.retry')}
</Button>
</div>
</div>
@@ -332,7 +334,7 @@ export function ProfileEditor({
<div className="px-6 py-2 bg-muted/30 border-b flex items-center gap-2 shrink-0 h-[45px]">
<Code2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">
Raw Configuration (JSON)
{t('rawEditorSection.rawConfig')}
</span>
</div>
<RawEditorSection
@@ -355,9 +357,9 @@ export function ProfileEditor({
<ConfirmDialog
open={conflictDialog}
title="File Modified Externally"
description="Overwrite with your changes or discard?"
confirmText="Overwrite"
title={t('settingsDialog.conflictTitle')}
description={t('settingsDialog.conflictDesc')}
confirmText={t('settingsDialog.overwrite')}
variant="destructive"
onConfirm={() => handleConflictResolve(true)}
onCancel={() => handleConflictResolve(false)}
@@ -5,6 +5,7 @@
import { Suspense, lazy } from 'react';
import { Loader2, X, AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator';
import { ImageAnalysisStatusSection } from './image-analysis-status-section';
import type { Settings } from './types';
@@ -44,6 +45,7 @@ export function RawEditorSection({
onChange,
missingRequiredFields = [],
}: RawEditorSectionProps) {
const { t } = useTranslation();
const hasMissingFields = missingRequiredFields.length > 0;
return (
@@ -51,7 +53,9 @@ export function RawEditorSection({
fallback={
<div className="flex items-center justify-center h-full">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading editor...</span>
<span className="ml-2 text-muted-foreground">
{t('profileEditorSections.loadingEditor')}
</span>
</div>
}
>
@@ -59,7 +63,7 @@ export function RawEditorSection({
{!isRawJsonValid && rawJsonEdits !== null && (
<div className="mb-2 px-3 py-2 bg-destructive/10 text-destructive text-sm rounded-md flex items-center gap-2 mx-6 mt-4 shrink-0">
<X className="w-4 h-4" />
Invalid JSON syntax
{t('profileEditor.invalidJson')}
</div>
)}
{isRawJsonValid && hasMissingFields && (
@@ -67,13 +71,13 @@ export function RawEditorSection({
<AlertTriangle className="w-4 h-4 mt-0.5 text-amber-500 shrink-0" />
<div>
<span className="font-medium text-amber-600 dark:text-amber-400">
Missing required fields:
{t('profileEditor.missingFields')}:
</span>{' '}
<code className="text-xs bg-muted px-1 py-0.5 rounded">
{missingRequiredFields.join(', ')}
</code>
<p className="text-xs text-muted-foreground mt-1">
These fields will use default values at runtime.
{t('profileEditor.missingFieldsHint')}
</p>
</div>
</div>
@@ -6,6 +6,7 @@
import { useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import type { Settings, SettingsResponse } from './types';
/** Required env vars for profiles to function (informational only - runtime fills defaults) */
@@ -34,6 +35,7 @@ export function useProfileEditor({
onSuccess,
onConflict,
}: UseProfileEditorOptions) {
const { t } = useTranslation();
const queryClient = useQueryClient();
// Fetch settings for selected profile
@@ -132,11 +134,11 @@ export function useProfileEditor({
onSuccess();
// Show warning if fields missing (runtime uses defaults)
if (data?.warning) {
toast.success('Settings saved', {
toast.success(t('commonToast.settingsSaved'), {
description: data.warning,
});
} else {
toast.success('Settings saved');
toast.success(t('commonToast.settingsSaved'));
}
},
onError: (error: Error) => {
@@ -3,6 +3,7 @@
* Visual indicator for OpenRouter-configured profiles
*/
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
@@ -13,6 +14,8 @@ interface OpenRouterBadgeProps {
}
export function OpenRouterBadge({ className, showTooltip = true }: OpenRouterBadgeProps) {
const { t } = useTranslation();
const badge = (
<Badge
variant="outline"
@@ -33,7 +36,7 @@ export function OpenRouterBadge({ className, showTooltip = true }: OpenRouterBad
<Tooltip>
<TooltipTrigger asChild>{badge}</TooltipTrigger>
<TooltipContent>
<p>Access 349+ models via OpenRouter</p>
<p>{t('openrouterBadge.integration')}</p>
</TooltipContent>
</Tooltip>
);
@@ -5,6 +5,7 @@
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { X, Sparkles, ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useOpenRouterReady } from '@/hooks/use-openrouter-models';
@@ -16,6 +17,7 @@ interface OpenRouterBannerProps {
}
export function OpenRouterBanner({ onCreateClick }: OpenRouterBannerProps) {
const { t } = useTranslation();
const [dismissed, setDismissed] = useState(true); // Start hidden to avoid flash
const { modelCount, isLoading } = useOpenRouterReady();
@@ -40,10 +42,13 @@ export function OpenRouterBanner({ onCreateClick }: OpenRouterBannerProps) {
<Sparkles className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm">NEW: OpenRouter Integration</p>
<p className="font-medium text-sm">
{t('openrouterBadge.new')}: {t('openrouterBadge.integration')}
</p>
<p className="text-xs text-white/80 truncate">
Browse {isLoading ? '300+' : `${modelCount}+`} models from OpenAI, Anthropic, Google,
Meta and more.
{t('openrouterBanner.accessModels', {
count: isLoading ? 300 : modelCount,
})}
</p>
</div>
</div>
@@ -56,7 +61,7 @@ export function OpenRouterBanner({ onCreateClick }: OpenRouterBannerProps) {
onClick={onCreateClick}
className="bg-white text-accent hover:bg-white/90 h-8"
>
Try it now
{t('openrouterBanner.add')}
</Button>
)}
<a
@@ -4,6 +4,7 @@
*/
import { useState, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -33,9 +34,10 @@ interface OpenRouterModelPickerProps {
export function OpenRouterModelPicker({
value,
onChange,
placeholder = 'Search models...',
placeholder,
className,
}: OpenRouterModelPickerProps) {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [selectedCategory, setSelectedCategory] = useState<ModelCategory | null>(null);
@@ -105,7 +107,7 @@ export function OpenRouterModelPicker({
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={placeholder}
placeholder={placeholder ?? t('openrouterModelPicker.searchModels')}
className="pl-9"
/>
</div>
@@ -180,7 +182,7 @@ export function OpenRouterModelPicker({
<div>
<div className="text-muted-foreground bg-background sticky top-0 mb-2 flex items-center gap-1.5 py-1.5 text-xs font-semibold border-b pb-2">
<Sparkles className="h-3 w-3 text-accent" />
<span>Newest Models</span>
<span>{t('openrouterModelPicker.newestModels')}</span>
</div>
<div className="space-y-1">
{newestModels.map((model) => (
@@ -3,6 +3,7 @@
* Permanent promotional card for OpenRouter - always visible in sidebar footer
*/
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { useOpenRouterReady } from '@/hooks/use-openrouter-models';
import { Zap } from 'lucide-react';
@@ -12,7 +13,8 @@ interface OpenRouterPromoCardProps {
}
export function OpenRouterPromoCard({ onCreateClick }: OpenRouterPromoCardProps) {
const { modelCount, isLoading } = useOpenRouterReady();
const { t } = useTranslation();
useOpenRouterReady();
return (
<div className="p-3 border-t bg-gradient-to-r from-accent/5 to-accent/10 dark:from-accent/10 dark:to-accent/15">
@@ -21,9 +23,11 @@ export function OpenRouterPromoCard({ onCreateClick }: OpenRouterPromoCardProps)
<img src="/icons/openrouter.svg" alt="" className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-accent dark:text-accent-foreground">OpenRouter</p>
<p className="text-xs font-medium text-accent dark:text-accent-foreground">
{t('openrouterPromoCard.title')}
</p>
<p className="text-[10px] text-muted-foreground truncate">
{isLoading ? '300+' : `${modelCount}+`} models available
{t('openrouterPromoCard.description')}
</p>
</div>
<Button
@@ -33,7 +37,7 @@ export function OpenRouterPromoCard({ onCreateClick }: OpenRouterPromoCardProps)
className="h-7 px-2 text-accent hover:text-accent hover:bg-accent/10 dark:hover:bg-accent/20"
>
<Zap className="w-3 h-3 mr-1" />
<span className="text-xs">Add</span>
<span className="text-xs">{t('openrouterBanner.add')}</span>
</Button>
</div>
</div>
+3 -1
View File
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -23,6 +24,7 @@ interface ProfileCardProps {
}
export function ProfileCard({ profile, settings, onSwitch, onConfig, onTest }: ProfileCardProps) {
const { t } = useTranslation();
const showOpenRouterIcon = isOpenRouterProfile(settings);
return (
@@ -36,7 +38,7 @@ export function ProfileCard({ profile, settings, onSwitch, onConfig, onTest }: P
<TooltipTrigger asChild>
<img src="/icons/openrouter.svg" alt="OpenRouter" className="w-4 h-4" />
</TooltipTrigger>
<TooltipContent>OpenRouter profile</TooltipContent>
<TooltipContent>{t('profileCard.openRouter')}</TooltipContent>
</Tooltip>
)}
{profile.isActive && (
@@ -230,7 +230,7 @@ export function ProfileCreateDialog({
setValue('haikuModel', model.id);
setModelSearch(model.name);
// Show feedback that model was applied to all tiers
toast.success(`Applied "${model.name}" to all model tiers`, {
toast.success(t('profileCreateDialog.appliedModelToTiers', { model: model.name }), {
duration: 2000,
});
};
@@ -268,11 +268,11 @@ export function ProfileCreateDialog({
};
try {
await createMutation.mutateAsync(finalData);
toast.success(`Profile "${finalData.name}" created`);
toast.success(t('profileCreateDialog.profileCreated', { name: finalData.name }));
onSuccess(finalData.name);
onOpenChange(false);
} catch (error) {
toast.error((error as Error).message || 'Failed to create profile');
toast.error((error as Error).message || t('profileCreateDialog.failedCreate'));
}
};
@@ -289,11 +289,9 @@ export function ProfileCreateDialog({
<DialogHeader className="p-6 pb-4 border-b">
<DialogTitle className="flex items-center gap-2">
<Plus className="w-5 h-5 text-primary" />
Create API Profile
{t('profileCreateDialog.createProfile')}
</DialogTitle>
<DialogDescription>
Choose a provider preset or configure a custom API endpoint.
</DialogDescription>
<DialogDescription>{t('profileCreateDialog.chooseProviderHint')}</DialogDescription>
</DialogHeader>
<form
@@ -361,7 +359,7 @@ export function ProfileCreateDialog({
<div className="space-y-2">
<Label className="text-xs font-medium uppercase tracking-[0.12em] text-foreground/70">
Local Runtimes
{t('openrouterQuickStart.localRuntimesTitle')}
</Label>
<div className="flex flex-wrap gap-2">
{LOCAL_RUNTIME_PRESETS.map((preset) => (
@@ -386,13 +384,13 @@ export function ProfileCreateDialog({
<div className="px-6 pt-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="basic" className="relative">
Basic Information
{t('profileCreateDialog.basicInformation')}
{hasBasicErrors && (
<span className="absolute top-1 right-2 w-2 h-2 rounded-full bg-destructive animate-pulse" />
)}
</TabsTrigger>
<TabsTrigger value="models" className="relative">
Model Configuration
{t('profileCreateDialog.modelConfiguration')}
{hasModelErrors && (
<span className="absolute top-1 right-2 w-2 h-2 rounded-full bg-destructive animate-pulse" />
)}
@@ -405,19 +403,19 @@ export function ProfileCreateDialog({
{/* Profile Name */}
<div className="space-y-1.5">
<Label htmlFor="name">
Profile Name <span className="text-destructive">*</span>
{t('profileDialog.name')} <span className="text-destructive">*</span>
</Label>
<Input
id="name"
{...register('name')}
placeholder="my-api"
placeholder={t('profileDialog.namePlaceholder')}
className="font-mono"
/>
{errors.name ? (
<p className="text-xs text-destructive">{errors.name.message}</p>
) : (
<p className="text-xs text-muted-foreground">
Used in CLI:{' '}
{t('profileCreateDialog.usedInCli')}{' '}
<code className="bg-muted px-1 rounded text-[10px]">ccs my-api "prompt"</code>
</p>
)}
@@ -425,11 +423,11 @@ export function ProfileCreateDialog({
{/* Base URL - always editable, pre-filled from preset */}
<div className="space-y-1.5">
<Label htmlFor="baseUrl">API Base URL</Label>
<Label htmlFor="baseUrl">{t('profileCreateDialog.apiBaseUrl')}</Label>
<Input
id="baseUrl"
{...register('baseUrl')}
placeholder="https://api.example.com/v1"
placeholder={t('profileCreateDialog.baseUrlPlaceholder')}
/>
{errors.baseUrl ? (
<p className="text-xs text-destructive">{errors.baseUrl.message}</p>
@@ -441,12 +439,12 @@ export function ProfileCreateDialog({
) : currentPreset ? (
<p className="text-xs text-muted-foreground">
{currentPreset.baseUrl
? `Pre-filled from ${currentPreset.name}. You can customize if needed.`
: `Optional for ${currentPreset.name}. Leave blank to use native Anthropic auth.`}
? t('profileCreateDialog.prefilledFromPreset', { name: currentPreset.name })
: t('profileCreateDialog.optionalForPreset', { name: currentPreset.name })}
</p>
) : (
<p className="text-xs text-muted-foreground">
The endpoint that accepts OpenAI-compatible and Anthropic requests
{t('profileCreateDialog.endpointHint')}
</p>
)}
</div>
@@ -454,12 +452,14 @@ export function ProfileCreateDialog({
{/* API Key - optional for presets that don't require it */}
<div className="space-y-1.5">
<Label htmlFor="apiKey">
API Key{' '}
{t('profileDialog.apiKey')}{' '}
{currentPreset?.requiresApiKey !== false && (
<span className="text-destructive">*</span>
)}
{currentPreset?.requiresApiKey === false && (
<span className="text-muted-foreground text-xs font-normal">(optional)</span>
<span className="text-muted-foreground text-xs font-normal">
{t('profileCreateDialog.optional')}
</span>
)}
</Label>
<div className="relative">
@@ -469,8 +469,9 @@ export function ProfileCreateDialog({
{...register('apiKey')}
placeholder={
currentPreset?.requiresApiKey === false
? 'Optional - only if auth is enabled'
: (currentPreset?.apiKeyPlaceholder ?? 'sk-...')
? t('profileCreateDialog.apiKeyOptionalPlaceholder')
: (currentPreset?.apiKeyPlaceholder ??
t('profileCreateDialog.apiKeyPlaceholder'))
}
className="pr-10"
/>
@@ -489,7 +490,7 @@ export function ProfileCreateDialog({
<p className="text-xs text-destructive">{errors.apiKey.message}</p>
) : currentPreset?.requiresApiKey === false ? (
<p className="text-xs text-muted-foreground">
Only needed if your local endpoint has authentication enabled
{t('profileCreateDialog.apiKeyOptionalHint')}
</p>
) : (
currentPreset?.apiKeyHint && (
@@ -499,7 +500,7 @@ export function ProfileCreateDialog({
</div>
<div className="space-y-1.5">
<Label htmlFor="target">Default Target CLI</Label>
<Label htmlFor="target">{t('codex.defaultTargetCli')}</Label>
<Select
value={targetValue}
onValueChange={(value) => setValue('target', value as CliTarget)}
@@ -508,9 +509,9 @@ export function ProfileCreateDialog({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="claude">Claude Code (default)</SelectItem>
<SelectItem value="droid">Factory Droid</SelectItem>
<SelectItem value="codex">Codex CLI</SelectItem>
<SelectItem value="claude">{t('profileCard.claudeCodeDefault')}</SelectItem>
<SelectItem value="droid">{t('profileCard.factoryDroid')}</SelectItem>
<SelectItem value="codex">{t('profileCard.codexCli')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
@@ -553,10 +554,9 @@ export function ProfileCreateDialog({
<div className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-950/20 text-blue-800 dark:text-blue-300 rounded-md text-sm border border-blue-100 dark:border-blue-900/30">
<Info className="w-5 h-5 shrink-0 mt-0.5" />
<div>
<p className="font-medium mb-1">Model Mapping</p>
<p className="font-medium mb-1">{t('profileCreateDialog.modelMapping')}</p>
<p className="text-xs opacity-90">
Map Claude Code tiers (Opus/Sonnet/Haiku) to models supported by your
provider.
{t('profileCreateDialog.modelMappingDesc')}
</p>
</div>
</div>
@@ -564,11 +564,11 @@ export function ProfileCreateDialog({
{/* OpenRouter Model Picker */}
{isOpenRouter && (
<div className="space-y-2">
<Label>Search Models</Label>
<Label>{t('openrouterModelPicker.searchModels')}</Label>
<Input
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
placeholder="Type to search (e.g., opus, sonnet, gpt-4o)..."
placeholder={t('profileCreateDialog.searchModelsPlaceholder')}
onKeyDown={(e) => {
if (e.key === 'Enter' && filteredModels.length > 0) {
e.preventDefault();
@@ -580,15 +580,15 @@ export function ProfileCreateDialog({
{filteredModels.length === 0 ? (
<p className="text-sm text-muted-foreground p-3 text-center">
{modelSearch
? `No models found for "${modelSearch}"`
: 'Loading models...'}
? t('profileCreateDialog.noModelsFound', { query: modelSearch })
: t('profileCreateDialog.loadingModels')}
</p>
) : (
<div className="p-1">
{!modelSearch && (
<div className="flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground">
<Sparkles className="w-3 h-3 text-accent" />
<span>Newest Models</span>
<span>{t('openrouterModelPicker.newestModels')}</span>
</div>
)}
{filteredModels.map((model) => (
@@ -609,7 +609,7 @@ export function ProfileCreateDialog({
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="model">
Default Model
{t('profileCreateDialog.defaultModel')}
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
ANTHROPIC_MODEL
</Badge>
@@ -625,7 +625,7 @@ export function ProfileCreateDialog({
<div className="grid gap-3 pt-2 border-t">
<div className="space-y-1.5">
<Label htmlFor="sonnetModel" className="text-sm">
Sonnet Mapping
{t('profileCreateDialog.sonnetMapping')}
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
DEFAULT_SONNET
</Badge>
@@ -633,14 +633,14 @@ export function ProfileCreateDialog({
<Input
id="sonnetModel"
{...register('sonnetModel')}
placeholder="e.g. gpt-4o, claude-sonnet-4"
placeholder={t('profileCreateDialog.sonnetMappingPlaceholder')}
className="font-mono text-sm h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="opusModel" className="text-sm">
Opus Mapping
{t('profileCreateDialog.opusMapping')}
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
DEFAULT_OPUS
</Badge>
@@ -648,14 +648,14 @@ export function ProfileCreateDialog({
<Input
id="opusModel"
{...register('opusModel')}
placeholder="e.g. o1, claude-opus-4.5"
placeholder={t('profileCreateDialog.opusMappingPlaceholder')}
className="font-mono text-sm h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="haikuModel" className="text-sm">
Haiku Mapping
{t('profileCreateDialog.haikuMapping')}
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
DEFAULT_HAIKU
</Badge>
@@ -663,7 +663,7 @@ export function ProfileCreateDialog({
<Input
id="haikuModel"
{...register('haikuModel')}
placeholder="e.g. gpt-4o-mini, claude-3.5-haiku"
placeholder={t('profileCreateDialog.haikuMappingPlaceholder')}
className="font-mono text-sm h-9"
/>
</div>
@@ -674,7 +674,7 @@ export function ProfileCreateDialog({
<DialogFooter className="p-6 pt-4 border-t bg-muted/10">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
{t('profileDialog.cancel')}
</Button>
<Button
type="submit"
@@ -684,12 +684,12 @@ export function ProfileCreateDialog({
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
{t('cliproxyDialog.creating')}
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Create Profile
{t('profileCreateDialog.createProfile')}
</>
)}
</Button>
@@ -803,6 +803,7 @@ function ModelSearchItem({
onClick: () => void;
showAge?: boolean;
}) {
const { t } = useTranslation();
return (
<button
type="button"
@@ -824,7 +825,7 @@ function ModelSearchItem({
variant="secondary"
className="text-xs group-hover:bg-accent-foreground/20 group-hover:text-accent-foreground"
>
Free
{t('profileCreateDialog.free')}
</Badge>
) : (
<span>{formatPricingPair(model.pricing)}</span>
+9 -3
View File
@@ -1,8 +1,10 @@
import { useProfiles } from '@/hooks/use-profiles';
import { ProfileCard } from './profile-card';
import { Skeleton } from '@/components/ui/skeleton';
import { useTranslation } from 'react-i18next';
export function ProfileDeck() {
const { t } = useTranslation();
const { data: response, isLoading, error } = useProfiles();
if (isLoading) {
@@ -18,7 +20,11 @@ export function ProfileDeck() {
}
if (error) {
return <div className="text-destructive text-sm">Failed to load profiles: {error.message}</div>;
return (
<div className="text-destructive text-sm">
{t('apiProfiles.failedLoadTitle')}: {error.message}
</div>
);
}
const profiles = response?.profiles || [];
@@ -26,7 +32,7 @@ export function ProfileDeck() {
if (!profiles || profiles.length === 0) {
return (
<div className="text-muted-foreground text-center py-8">
No profiles configured. Create your first profile to get started.
{t('apiProfiles.noProfilesDesc')}
</div>
);
}
@@ -39,7 +45,7 @@ export function ProfileDeck() {
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Profiles</h2>
<h2 className="text-lg font-semibold">{t('profileDeck.profiles')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{normalizedProfiles.map((profile) => (
<ProfileCard
@@ -19,6 +19,7 @@ import { ChevronDown, ChevronRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const DEFAULT_MODEL = 'claude-sonnet-4-6';
// TODO i18n: missing keys for Zod validation messages ("Invalid URL", "Name is required", "Invalid profile name", "API key must be at least 10 characters")
const optionalUrlSchema = z
.string()
.refine((value) => value.trim().length === 0 || z.string().url().safeParse(value).success, {
@@ -23,6 +23,7 @@ import {
import { MoreHorizontal, Trash2, Edit } from 'lucide-react';
import { useDeleteProfile } from '@/hooks/use-profiles';
import type { Profile } from '@/lib/api-client';
import { useTranslation } from 'react-i18next';
interface ProfilesTableProps {
data: Profile[];
@@ -31,20 +32,21 @@ interface ProfilesTableProps {
export function ProfilesTable({ data, onEditSettings }: ProfilesTableProps) {
const deleteMutation = useDeleteProfile();
const { t } = useTranslation();
const columns: ColumnDef<Profile>[] = [
{
accessorKey: 'name',
header: 'Name',
header: t('profilesTable.name'),
size: 200,
},
{
accessorKey: 'settingsPath',
header: 'Settings Path',
header: t('profileEditor.filePath'),
},
{
accessorKey: 'configured',
header: 'Status',
header: t('cliproxyTable.status'),
size: 100,
cell: ({ row }) => (
<span className={row.original.configured ? 'text-green-600' : 'text-yellow-600'}>
@@ -54,7 +56,7 @@ export function ProfilesTable({ data, onEditSettings }: ProfilesTableProps) {
},
{
id: 'actions',
header: 'Actions',
header: t('profilesTable.actions'),
size: 100,
cell: ({ row }) => (
<DropdownMenu>
@@ -67,7 +69,7 @@ export function ProfilesTable({ data, onEditSettings }: ProfilesTableProps) {
{onEditSettings && (
<DropdownMenuItem onClick={() => onEditSettings(row.original)}>
<Edit className="w-4 h-4 mr-2" />
Edit
{t('profilesTable.edit')}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
@@ -76,7 +78,7 @@ export function ProfilesTable({ data, onEditSettings }: ProfilesTableProps) {
onClick={() => deleteMutation.mutate(row.original.name)}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
{t('apiProfiles.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -93,9 +95,7 @@ export function ProfilesTable({ data, onEditSettings }: ProfilesTableProps) {
if (data.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
No profiles found. Create one to get started.
</div>
<div className="text-center py-8 text-muted-foreground">{t('apiProfiles.noProfilesYet')}</div>
);
}
+6 -6
View File
@@ -192,14 +192,14 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-primary" />
Quick Setup Wizard
{i18n.t('setupWizard.title')}
</DialogTitle>
<DialogDescription>
{step === 'provider' && 'Select a provider to get started'}
{step === 'auth' && 'Authenticate with your provider'}
{step === 'account' && 'Select which account to use'}
{step === 'variant' && 'Create your custom variant'}
{step === 'success' && 'Setup complete!'}
{step === 'provider' && i18n.t('setupWizard.stepProviderDesc')}
{step === 'auth' && i18n.t('setupWizard.stepAuthDesc')}
{step === 'account' && i18n.t('setupWizard.stepAccountDesc')}
{step === 'variant' && i18n.t('setupWizard.stepVariantDesc')}
{step === 'success' && i18n.t('setupWizard.stepSuccessDesc')}
</DialogDescription>
</DialogHeader>
@@ -9,6 +9,7 @@ import { getAccountIdentityPresentation } from '@/lib/account-identity';
import { cn } from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import type { AccountStepProps } from '../types';
import { useTranslation } from 'react-i18next';
export function AccountStep({
accounts,
@@ -17,11 +18,12 @@ export function AccountStep({
onAddNew,
onBack,
}: AccountStepProps) {
const { t } = useTranslation();
return (
<div className="space-y-4">
{/* Existing accounts header */}
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Select an account ({accounts.length})
{t('setupWizard.accountStep.selectAccount', { count: accounts.length })}
</div>
{/* Scrollable account list with max-height for many accounts */}
@@ -63,7 +65,9 @@ export function AccountStep({
</Badge>
)}
{acc.isDefault && (
<span className="text-xs text-muted-foreground">Default account</span>
<span className="text-xs text-muted-foreground">
{t('setupWizard.accountStep.defaultAccount')}
</span>
)}
</div>
</div>
@@ -80,7 +84,9 @@ export function AccountStep({
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or</span>
<span className="bg-background px-2 text-muted-foreground">
{t('setupWizard.accountStep.or')}
</span>
</div>
</div>
@@ -94,15 +100,19 @@ export function AccountStep({
<ExternalLink className="w-4 h-4 text-primary" />
</div>
<div>
<div className="font-medium text-primary">Add new account</div>
<div className="text-xs text-muted-foreground">Authenticate with a different account</div>
<div className="font-medium text-primary">
{t('setupWizard.accountStep.addNewAccount')}
</div>
<div className="text-xs text-muted-foreground">
{t('setupWizard.accountStep.addNewAccountDesc')}
</div>
</div>
</button>
<div className="flex items-center justify-between pt-2">
<Button variant="ghost" onClick={onBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
{t('setupWizard.accountStep.back')}
</Button>
</div>
</div>
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Copy, Check, RefreshCw, ArrowLeft, Terminal, ExternalLink, Loader2 } from 'lucide-react';
import type { AuthStepProps } from '../types';
import { useTranslation } from 'react-i18next';
export function AuthStep({
selectedProvider,
@@ -18,6 +19,7 @@ export function AuthStep({
onStartAuth,
onRefresh,
}: AuthStepProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copyCommand = async (cmd: string) => {
@@ -31,26 +33,25 @@ export function AuthStep({
{/* Primary: OAuth Button */}
<div className="text-center space-y-3">
<p className="text-sm text-muted-foreground">
Authenticate with {providers.find((p) => p.id === selectedProvider)?.name} to add an
account
{t('setupWizard.authStep.authenticateWith', {
provider: providers.find((p) => p.id === selectedProvider)?.name,
})}
</p>
<Button onClick={onStartAuth} disabled={isPending} className="w-full gap-2" size="lg">
{isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Authenticating...
{t('setupWizard.authStep.authenticating')}
</>
) : (
<>
<ExternalLink className="w-4 h-4" />
Authenticate in Browser
{t('setupWizard.authStep.authenticateInBrowser')}
</>
)}
</Button>
{isPending && (
<p className="text-xs text-muted-foreground">
Complete the OAuth flow in your browser...
</p>
<p className="text-xs text-muted-foreground">{t('setupWizard.authStep.completeOAuth')}</p>
)}
</div>
@@ -60,7 +61,9 @@ export function AuthStep({
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or use terminal</span>
<span className="bg-background px-2 text-muted-foreground">
{t('setupWizard.authStep.orUseTerminal')}
</span>
</div>
</div>
@@ -69,7 +72,7 @@ export function AuthStep({
<CardContent className="p-4 space-y-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Terminal className="w-4 h-4" />
Run this command in your terminal:
{t('setupWizard.authStep.runCommandHint')}
</div>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-muted rounded-md font-mono text-sm">
@@ -85,11 +88,13 @@ export function AuthStep({
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={onBack} disabled={isPending}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
{t('setupWizard.authStep.back')}
</Button>
<Button variant="outline" onClick={onRefresh} disabled={isRefreshing || isPending}>
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
{isRefreshing ? 'Checking...' : 'Refresh Status'}
{isRefreshing
? t('setupWizard.authStep.checking')
: t('setupWizard.authStep.refreshStatus')}
</Button>
</div>
</div>
@@ -6,8 +6,10 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Check } from 'lucide-react';
import type { SuccessStepProps } from '../types';
import { useTranslation } from 'react-i18next';
export function SuccessStep({ variantName, onClose }: SuccessStepProps) {
const { t } = useTranslation();
return (
<div className="space-y-4 text-center">
<div className="flex justify-center">
@@ -16,19 +18,19 @@ export function SuccessStep({ variantName, onClose }: SuccessStepProps) {
</div>
</div>
<div>
<div className="font-semibold text-lg">Variant Created!</div>
<div className="text-sm text-muted-foreground">Your custom variant is ready to use</div>
<div className="font-semibold text-lg">{t('setupWizard.successStep.title')}</div>
<div className="text-sm text-muted-foreground">{t('setupWizard.successStep.subtitle')}</div>
</div>
<Card>
<CardContent className="p-4 space-y-2">
<div className="text-sm text-muted-foreground">Usage:</div>
<div className="text-sm text-muted-foreground">{t('setupWizard.successStep.usage')}</div>
<code className="block px-3 py-2 bg-muted rounded-md font-mono text-sm">
ccs {variantName} "your prompt here"
</code>
</CardContent>
</Card>
<Button onClick={onClose} className="w-full">
Done
{t('setupWizard.successStep.done')}
</Button>
</div>
);
@@ -127,9 +127,7 @@ export function VariantStep({
placeholder={t('setupVariant.modelPlaceholder')}
/>
{deniedCustomModel && (
<p className="text-xs text-destructive">
Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.
</p>
<p className="text-xs text-destructive">{t('providerEditor.agyDenylist')}</p>
)}
<button
type="button"
+4 -2
View File
@@ -1,4 +1,5 @@
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
interface CcsLogoProps {
size?: 'sm' | 'md' | 'lg';
@@ -13,18 +14,19 @@ const sizeMap = {
};
export function CcsLogo({ size = 'md', className, showText = true }: CcsLogoProps) {
const { t } = useTranslation();
const dimension = sizeMap[size];
return (
<div className={cn('flex items-center gap-2', className)}>
<img
src="/logo/ccs-logo-256.png"
alt="CCS Logo"
alt={t('ccsLogo.alt')}
width={dimension}
height={dimension}
className="rounded"
/>
{showText && <span className="font-bold text-lg">CCS Config</span>}
{showText && <span className="font-bold text-lg">{t('ccsLogo.text')}</span>}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More